import db from '@adonisjs/lucid/services/db' import logger from '@adonisjs/core/services/logger' import { parseSfv, sfvReleaseName } from '#lib/content/index' /** Releases made by name rules alone; an SFV's listing outranks them. */ const WEAK_SOURCES = ['stem', 'weak-stem', 'subject'] /** Files further apart than this from the SFV in posting time aren't taken. */ const WINDOW_MS = 2 * 24 * 60 * 60 * 1000 /** * SFV grouping (CONCEPT.md D34). An uploader that posts every track of an * album as its own file (13.5k such releases from one poster on homelab) * still posts the album's SFV, which names them all. */ class SfvService { /** * Records an SFV file's listing on its release, moves the listed files that * sit in weak releases into it, and names the release after the album when * it's still named after a track or the SFV itself. Returns how many files * moved, or null when it isn't an SFV. */ async attach(sfvFile: any, text: string): Promise { const entries = parseSfv(text) if (entries.length === 0 || !sfvFile.release_id) return null const releaseId = Number(sfvFile.release_id) const postedAt = sfvFile.date ? new Date(Number(sfvFile.date)) : null await db .knexQuery() .table('sfv_entries') .insert(entries.map((e) => ({ release_id: releaseId, name: e.name, crc: e.crc, poster: sfvFile.poster, posted_at: postedAt }))) .onConflict(['release_id', 'name']) .ignore() await db.from('files').where('id', sfvFile.id).update({ content_type: 'sfv' }) const candidates = await db .from('files as f') .join('releases as r', 'r.id', 'f.release_id') .whereIn(db.raw('coalesce(f.real_name, f.filename)') as any, entries.map((e) => e.name)) .whereNot('f.release_id', releaseId) .whereIn('r.source', WEAK_SOURCES) .select('f.id', 'f.poster', 'f.date') const moving = candidates.filter((f) => this.sameUpload(f, sfvFile.poster, postedAt)) await this.move(moving.map((f) => Number(f.id)), releaseId) const release = await db.from('releases').where('id', releaseId).first() const album = sfvReleaseName(String(sfvFile.real_name ?? sfvFile.filename)) if (release && WEAK_SOURCES.includes(release.source) && album !== release.name && /^\d{1,3}-/.test(release.name)) { await db.from('releases').where('id', releaseId).update({ name: album }) } logger.info(`SFV "${sfvFile.filename}": ${entries.length} files listed, ${moving.length} moved into release ${releaseId} "${album}".`) return moving.length } /** A new file an SFV already listed joins that SFV's release. Returns the release, or null. */ async claim(file: { id: number; name: string; poster?: string | null; date?: number | null; release_id?: number | null }): Promise { const entries = await db.from('sfv_entries').where('name', file.name) const entry = entries.find((e) => this.sameUpload(file, e.poster, e.posted_at ? new Date(e.posted_at) : null)) if (!entry || Number(entry.release_id) === Number(file.release_id)) return null if (file.release_id) { const current = await db.from('releases').where('id', file.release_id).first() if (current && !WEAK_SOURCES.includes(current.source)) return null } await this.move([file.id], Number(entry.release_id)) return Number(entry.release_id) } private sameUpload(file: { poster?: string | null; date?: number | string | null }, poster: string | null, postedAt: Date | null) { if (poster && file.poster && file.poster !== poster) return false if (postedAt && file.date && Math.abs(Number(file.date) - postedAt.getTime()) > WINDOW_MS) return false return true } private async move(fileIds: number[], releaseId: number) { if (fileIds.length === 0) return await db.from('files').whereIn('id', fileIds).update({ release_id: releaseId }) await db.from('binaries').whereIn('file_id', fileIds).update({ release_id: releaseId }) } } export default new SfvService()