'use strict'; const fs = require('fs'); const path = require('path'); const conf = require('>/conf'); const ollama = require('>/controller/ollama'); const tmdb = require('>/controller/tmdb'); const emby = require('>/controller/emby'); const ps = require('>/controller/pubsub'); const { withRetry } = require('>/controller/retry'); const VIDEO_EXT = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.ts', '.wmv', '.mov']); const SUB_EXT = new Set(['.srt', '.ass', '.ssa', '.sub', '.vtt', '.idx']); function ext(name){ return path.extname(name).toLowerCase(); } function isVideo(name){ return VIDEO_EXT.has(ext(name)) && !/sample/i.test(name); } function isSub(name){ return SUB_EXT.has(ext(name)); } // Strip characters that are illegal / awkward in file paths. function sanitize(text){ return String(text || '').replace(/[\/\\:*?"<>|]/g, '').replace(/\s+/g, ' ').trim(); } function pad2(n){ return String(n).padStart(2, '0'); } // Normalize resolution to the library's convention( 2160p/uhd -> 4k). function normalizeRes(res){ res = String(res || '').toLowerCase(); if(/2160|4k|uhd/.test(res)) return '4k'; let m = res.match(/(480|720|1080)p?/); return m ? `${m[1]}p` : ''; } // "Unrated 1080p" style suffix from edition + quality. function qualitySuffix(meta){ let res = normalizeRes(meta.quality && meta.quality.resolution); return [sanitize(meta.edition), res].filter(Boolean).join(' '); } // --- LLM metadata extraction ---------------------------------------------- function buildExtractPrompt(today){ return [ `You are a media library organizer. Today's date is ${today}.`, `Given a torrent's raw release name, its coarse type (Movie or TV), and its list of`, `video file names, extract clean metadata for filing. Return STRICT JSON only:`, `{"mediaType":"movie"|"tv","title":"clean title, no year/quality/tags",`, `"year":"YYYY" or "","edition":"Extended|Director's Cut|Unrated|Uncut|Remastered|`, `Theatrical|IMAX|Redux|" (notable edition if present, else empty),`, `"quality":{"resolution":"2160p|1080p|720p|480p|","codec":"x265|x264|"},`, `"episodes":[{"file":"","season":,"episode":}]}`, `Movies: "episodes" is []. TV: one entry per video file, mapping each file to its`, `season/episode inferred from the file name (SxxExx, 1x02, etc.).`, `Do not invent data; leave a field empty or [] when unknown.`, ].join('\n'); } async function llmExtract({ name, category, files }){ let today = new Date().toISOString().slice(0, 10); let user = JSON.stringify({ name, type: category, files }); return await withRetry( () => ollama.chatJSON(buildExtractPrompt(today), user), { label: 'ollama llmExtract' } ); } // Parse + canonicalize. Returns a metadata object, or one carrying `organizeError` // (so the caller flags the torrent instead of mis-filing it). async function resolveMeta({ name, category, files }){ let llm; try{ llm = await llmExtract({ name, category, files }); }catch(error){ return { organizeError: `parse failed: ${error.message}` }; } let mediaType = llm.mediaType === 'tv' || llm.mediaType === 'movie' ? llm.mediaType : (category === 'TV' ? 'tv' : 'movie'); if(!llm.title) return { organizeError: `could not determine a title for "${name}"` }; let best = await tmdb.tmdbFindBest(llm.title, llm.year, mediaType); if(!best) return { organizeError: `no TMDB match for "${llm.title}" (${llm.year || '?'})` }; return { mediaType, title: best.title, year: best.year, edition: llm.edition || '', quality: llm.quality || {}, episodes: Array.isArray(llm.episodes) ? llm.episodes : [], tmdbId: best.tmdbId, imdbId: best.imdbId, }; } // --- Target path building -------------------------------------------------- // Build the destination path for one video file. `episode` is the matching entry from // meta.episodes (TV only). function buildTarget(meta, fileName, episode){ let title = sanitize(meta.title); let folder = `${title} (${meta.year})`; let suffix = qualitySuffix(meta); let e = ext(fileName); if(meta.mediaType === 'tv' && episode){ let dir = path.join(conf.library.root, conf.library.tv, folder, `Season ${pad2(episode.season)}`); let se = `S${pad2(episode.season)}E${pad2(episode.episode)}`; let base = `${folder} - ${se}${suffix ? ' - ' + suffix : ''}${e}`; return path.join(dir, base); } let dir = path.join(conf.library.root, conf.library.movie, folder); let base = `${folder}${suffix ? ' - ' + suffix : ''}${e}`; return path.join(dir, base); } // If the exact target exists, disambiguate with codec then a counter. function resolveCollision(target, meta){ if(!fs.existsSync(target)) return target; let dir = path.dirname(target); let e = path.extname(target); let baseNoExt = path.basename(target, e); let codec = meta.quality && meta.quality.codec ? ' ' + sanitize(meta.quality.codec) : ''; let candidate = path.join(dir, `${baseNoExt}${codec}${e}`); let n = 2; while(fs.existsSync(candidate)){ candidate = path.join(dir, `${baseNoExt}${codec} (${n})${e}`); n++; } return candidate; } // --- Filesystem move ------------------------------------------------------- async function moveFile(src, dst){ await fs.promises.mkdir(path.dirname(dst), { recursive: true }); try{ await fs.promises.rename(src, dst); }catch(error){ if(error.code === 'EXDEV'){ await fs.promises.copyFile(src, dst); await fs.promises.unlink(src); }else{ throw error; } } } // Subs that belong to a given video( same basename, ignoring a language suffix). function subsFor(videoName, subFiles, isMovie){ if(isMovie) return subFiles; let stem = path.basename(videoName, ext(videoName)).toLowerCase(); return subFiles.filter(s => path.basename(s, ext(s)).toLowerCase().startsWith(stem.slice(0, 20))); } // --- Orchestration --------------------------------------------------------- // File a finished torrent into the library, then remove it from Transmission and refresh // Emby. Returns { organized:true, ... } or { organized:false, error }. async function fileTorrent(torrent){ const { Torrent } = require('>/models'); let info = (await Torrent.trClient.get(torrent.hashString, ['downloadDir', 'files', 'name'])).torrents[0]; if(!info) return await flag(torrent, 'torrent not found in Transmission'); let downloadDir = info.downloadDir; let videoFiles = info.files.filter(f => isVideo(f.name)); let subFiles = info.files.filter(f => isSub(f.name)).map(f => f.name); if(!videoFiles.length) return await flag(torrent, 'no video files in torrent'); let meta = await resolveMeta({ name: torrent.name, category: torrent.category, files: videoFiles.map(f => f.name), }); if(meta.organizeError) return await flag(torrent, meta.organizeError, meta); let isMovie = meta.mediaType !== 'tv'; // Movie: file only the largest video. TV: file every episode video. let toFile = isMovie ? [videoFiles.reduce((a, b) => (b.length > a.length ? b : a))] : videoFiles; let filed = []; let libraryPath = null; for(let vf of toFile){ let episode = isMovie ? null : (meta.episodes.find(e => e.file === vf.name) || null); if(!isMovie && !episode) continue; // can't place an episode we couldn't map let dst = resolveCollision(buildTarget(meta, vf.name, episode), meta); await moveFile(path.join(downloadDir, vf.name), dst); filed.push({ path: dst, season: episode ? episode.season : null, episode: episode ? episode.episode : null }); libraryPath = path.dirname(dst); // carry matching sidecar subtitles, renamed to the video's base name let dstBase = path.basename(dst, ext(dst)); for(let sub of subsFor(vf.name, subFiles, isMovie)){ try{ await moveFile(path.join(downloadDir, sub), path.join(path.dirname(dst), dstBase + ext(sub))); }catch(error){ /* subs are best-effort */ } } } if(!filed.length) return await flag(torrent, 'could not map any video files to episodes', meta); // Move + stop seeding: drop the torrent and delete whatever download data is left. try{ await Torrent.trClient.remove(torrent.hashString, true); }catch(error){ /* already gone */ } torrent.organizedAt = new Date(); torrent.metadata = { ...meta, libraryPath, files: filed }; await torrent.save(); try{ await emby.refresh(); }catch(error){ console.error('emby refresh failed:', error.message); } ps.publish('torrent:organized', { hashString: torrent.hashString, libraryPath }); return { organized: true, libraryPath, files: filed }; } // Re-file an already-organized torrent under a user-chosen TMDB match. Moves the files // already in the library to their new canonical paths. async function refileWithMatch(torrent, tmdbId, mediaType){ if(!torrent.metadata || !Array.isArray(torrent.metadata.files) || !torrent.metadata.files.length){ let error = new Error('NothingToRefile'); error.message = 'This torrent has no filed media to re-match'; error.status = 409; throw error; } let details = await tmdb.tmdbDetails(tmdbId, mediaType); let meta = { ...torrent.metadata, mediaType, title: details.title, year: details.year, tmdbId, imdbId: details.imdbId, }; delete meta.organizeError; let filed = []; let libraryPath = null; for(let entry of torrent.metadata.files){ let old = typeof entry === 'string' ? entry : entry.path; let episode = (entry && entry.season != null) ? { season: entry.season, episode: entry.episode } : null; let dst = resolveCollision(buildTarget(meta, old, episode), meta); if(dst !== old) await moveFile(old, dst); filed.push({ path: dst, season: episode ? episode.season : null, episode: episode ? episode.episode : null }); libraryPath = path.dirname(dst); let oldDir = path.dirname(old); try{ if(oldDir !== libraryPath) await fs.promises.rmdir(oldDir); }catch(error){ /* not empty / gone */ } } meta.files = filed; meta.libraryPath = libraryPath; torrent.metadata = meta; torrent.organizedAt = new Date(); await torrent.save(); try{ await emby.refresh(); }catch(error){ console.error('emby refresh failed:', error.message); } ps.publish('torrent:organized', { hashString: torrent.hashString, libraryPath }); return { organized: true, libraryPath, files: filed }; } // Record a problem in metadata without setting organizedAt( leaves it flagged for a // manual "fix match"). async function flag(torrent, message, meta){ console.error(`organize: ${torrent.hashString} skipped — ${message}`); torrent.metadata = { ...(meta || {}), organizeError: message }; await torrent.save(); return { organized: false, error: message }; } module.exports = { fileTorrent, refileWithMatch, resolveMeta, llmExtract, buildTarget, qualitySuffix, normalizeRes, sanitize, moveFile, resolveCollision, };