diff --git a/app.js b/app.js index e35eb68..474a3a4 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,7 @@ const frontEndModules = [ ]; // Hold list of functions to run when the server is ready -app.onListen = [function(){console.log('hello')}]; +app.onListen = []; // Allow the express app to be exported into other files. module.exports = app; @@ -104,7 +104,7 @@ app.use(function(err, req, res, next) { err.status = 422; } - if(![404, 422].includes(err.status || res.status)){ + if(![404, 422].includes(err.status || res.statusCode)){ console.error(err.message); console.error(err.stack); console.error('========================================='); diff --git a/conf/base.js b/conf/base.js index 9cd61b9..647ffde 100644 --- a/conf/base.js +++ b/conf/base.js @@ -23,5 +23,40 @@ module.exports = { username: 'william', password: '__IN SRECREST FILE__', statusUpdateInterval: 500, + }, + tmdb: { + apiKey: '__IN SRECREST FILE__', + }, + ollama: { + url: 'https://ollama.com', + apiKey: '__IN SRECREST FILE__', + model: 'gemma4:31b-cloud', + }, + search: { + // TPB HTML mirror we scrape search results from( same host the proxy targets). + // apibay's JSON q.php is Cloudflare-gated, so we parse the HTML listing instead. + tpbBase: 'https://piratebay.party', + trackers: [ + 'udp://tracker.opentrackr.org:1337/announce', + 'udp://open.stealth.si:80/announce', + 'udp://tracker.torrent.eu.org:451/announce', + 'udp://tracker.openbittorrent.com:6969/announce', + 'udp://tracker.coppersurfer.tk:6969/announce', + 'udp://open.demonii.com:1337/announce', + 'udp://exodus.desync.com:6969/announce', + ], + }, + emby: { + url: 'https://emby.718it.biz', + apiKey: '__IN SRECREST FILE__', + }, + library: { + root: '/media/stuff', + movie: 'movies', + tv: 'tv', + }, + organize: { + includePrivate: false, + interval: 10000, } }; diff --git a/controller/emby.js b/controller/emby.js new file mode 100644 index 0000000..9206cb2 --- /dev/null +++ b/controller/emby.js @@ -0,0 +1,19 @@ +'use strict'; + +const conf = require('>/conf'); + +// Ask Emby to (re)scan its libraries so a newly filed item gets indexed. +async function refresh(){ + let res = await fetch(`${conf.emby.url}/Library/Refresh?api_key=${conf.emby.apiKey}`, { + method: 'POST', + }); + if(!res.ok){ + let error = new Error('EmbyError'); + error.message = `Emby library refresh failed( ${res.status})`; + error.status = 502; + throw error; + } + return true; +} + +module.exports = { refresh }; diff --git a/controller/index.js b/controller/index.js index 77f0202..c8b4879 100644 --- a/controller/index.js +++ b/controller/index.js @@ -4,4 +4,5 @@ module.exports = { auth: require('./auth'), pubsub: require('./pubsub'), torrent: require('./torrent'), + organizeWatcher: require('./organizeWatcher'), } \ No newline at end of file diff --git a/controller/ollama.js b/controller/ollama.js new file mode 100644 index 0000000..abebe3b --- /dev/null +++ b/controller/ollama.js @@ -0,0 +1,42 @@ +'use strict'; + +const conf = require('>/conf'); + +// Pull the first JSON object out of a model response( tolerates ```json fences / prose). +function extractJSON(content){ + let text = String(content).replace(/```json/gi, '').replace(/```/g, '').trim(); + let start = text.indexOf('{'); + let end = text.lastIndexOf('}'); + if(start === -1 || end === -1) throw new Error('No JSON in model response'); + return JSON.parse(text.slice(start, end + 1)); +} + +// One-shot JSON chat against the Ollama cloud model. +async function chatJSON(system, user){ + let res = await fetch(`${conf.ollama.url}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${conf.ollama.apiKey}`, + }, + body: JSON.stringify({ + model: conf.ollama.model, + stream: false, + format: 'json', + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + }), + }); + if(!res.ok){ + let error = new Error('OllamaError'); + error.message = `Ollama chat failed( ${res.status})`; + error.status = 502; + throw error; + } + let data = await res.json(); + return extractJSON(data.message && data.message.content); +} + +module.exports = { chatJSON, extractJSON }; diff --git a/controller/organize.js b/controller/organize.js new file mode 100644 index 0000000..4e350aa --- /dev/null +++ b/controller/organize.js @@ -0,0 +1,286 @@ +'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 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 ollama.chatJSON(buildExtractPrompt(today), user); +} + +// 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, +}; diff --git a/controller/organizeWatcher.js b/controller/organizeWatcher.js new file mode 100644 index 0000000..48f063e --- /dev/null +++ b/controller/organizeWatcher.js @@ -0,0 +1,42 @@ +'use strict'; + +const conf = require('>/conf'); +const organize = require('>/controller/organize'); + +let lock = false; + +// Poll unorganized Movie/TV downloads; when Transmission reports one finished, file it. +async function tick(){ + if(lock) return; + lock = true; + try{ + const { Torrent } = require('>/models'); + + let where = { organizedAt: null, category: ['Movie', 'TV'] }; + if(!conf.organize.includePrivate) where.isPrivate = false; + + let pending = await Torrent.findAll({ where }); + if(!pending.length) return; + + let byHash = {}; + for(let t of pending) byHash[t.hashString.toLowerCase()] = t; + + let res = await Torrent.trClient.get(pending.map(t => t.hashString), ['hashString', 'percentDone', 'isFinished']); + for(let tor of res.torrents){ + if(!(tor.isFinished || tor.percentDone === 1)) continue; + let t = byHash[String(tor.hashString).toLowerCase()]; + if(!t) continue; + if(t.metadata && t.metadata.organizeError) continue; // already tried; needs manual fix + await organize.fileTorrent(t); + } + }catch(error){ + // Transmission down / transient — just try again next tick. + }finally{ + lock = false; + } +} + +setInterval(tick, conf.organize.interval || 10000); +tick(); // reconcile once on boot + +module.exports = { tick }; diff --git a/controller/pubsub.js b/controller/pubsub.js index 902dd69..9fda06c 100644 --- a/controller/pubsub.js +++ b/controller/pubsub.js @@ -1,6 +1,8 @@ +'use strict'; + const {PubSub} = require('p2psub'); -ps = new PubSub(); +const ps = new PubSub(); module.exports = ps; diff --git a/controller/search.js b/controller/search.js new file mode 100644 index 0000000..f4642cf --- /dev/null +++ b/controller/search.js @@ -0,0 +1,277 @@ +'use strict'; + +const conf = require('>/conf'); +const { tmdbSearch, tmdbDetails } = require('>/controller/tmdb'); + +// TPB numeric categories that count as Movies / TV( used for the q.php search and to +// keep the LLM from ever seeing non-video results). +const MOVIE_CATS = [201, 202, 207, 209, 211]; +const TV_CATS = [205, 208, 212]; + +function humanSize(bytes){ + bytes = Number(bytes) || 0; + let units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + while(bytes >= 1024 && i < units.length - 1){ + bytes /= 1024; + i++; + } + return `${bytes.toFixed(bytes < 10 && i > 0 ? 1 : 0)} ${units[i]}`; +} + +async function fetchJSON(url, options){ + let res = await fetch(url, options); + if(!res.ok){ + let error = new Error(`UpstreamError`); + error.message = `Request to ${url.split('?')[0]} failed( ${res.status})`; + error.status = 502; + throw error; + } + return await res.json(); +} + +// Turn a raw TPB category id into 'movie' | 'tv' | null. +function videoKind(category){ + category = parseInt(category, 10); + if(TV_CATS.includes(category)) return 'tv'; + if(MOVIE_CATS.includes(category) || (category >= 200 && category < 300)) return 'movie'; + return null; +} + +// --- TPB HTML search ------------------------------------------------------ + +const SIZE_UNITS = { B: 1, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3, TIB: 1024 ** 4 }; + +function sizeToBytes(value, unit){ + return Math.round(parseFloat(value) * (SIZE_UNITS[String(unit).toUpperCase()] || 1)); +} + +function decodeEntities(text){ + return String(text) + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/�?39;/g, "'").replace(/'/g, "'"); +} + +// Parse the classic TPB HTML result table( category cell, details link name, magnet, +// then right-aligned size / seeders / leechers cells) into apibay-shaped objects. +function parseTPBHtml(html){ + let out = []; + for(let row of html.split(/\s*([\d.]+)(?: |\s)+(GiB|MiB|KiB|TiB)\s*<\/td>/i); + let nums = [...row.matchAll(/\s*([\d,]+)\s*<\/td>/gi)].map(m => Number(m[1].replace(/,/g, ''))); + + if(!name) continue; + // nums are [size, seeders, leechers]; size cell also matched the regex above so + // seeders/leechers are the last two numeric right-aligned cells. + let seeders = nums.length >= 2 ? nums[nums.length - 2] : 0; + let leechers = nums.length >= 1 ? nums[nums.length - 1] : 0; + + out.push({ + name: decodeEntities(name[1]), + info_hash: hash[1].toLowerCase(), + category: cat ? Number(cat[1]) : 0, + size: size ? sizeToBytes(size[1], size[2]) : 0, + seeders: seeders, + leechers: leechers, + imdb: null, + }); + } + return out; +} + +async function tpbSearch(title){ + // Category 200 = all Video; we narrow to movie/tv subcats in prefilter(). + let url = `${conf.search.tpbBase}/search/${encodeURIComponent(title)}/1/99/200`; + let res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }); + if(!res.ok){ + let error = new Error('UpstreamError'); + error.message = `TPB search failed( ${res.status})`; + error.status = 502; + throw error; + } + return parseTPBHtml(await res.text()); +} + +// Deterministic pre-filter before the torrents ever reach the LLM. +function prefilter(torrents, imdbId){ + let out = torrents.filter(t => videoKind(t.category) && Number(t.seeders) > 0); + + // If we can positively identify the title by IMDB id, trust it and drop the rest. + if(imdbId){ + let matches = out.filter(t => t.imdb && t.imdb === imdbId); + if(matches.length) out = matches; + } + + return out + .sort((a, b) => Number(b.seeders) - Number(a.seeders)) + .slice(0, 40); +} + +function buildMagnet(infoHash, name){ + let magnet = `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(name)}`; + for(let tracker of conf.search.trackers){ + magnet += `&tr=${encodeURIComponent(tracker)}`; + } + return magnet; +} + +// --- LLM ranking ---------------------------------------------------------- + +function buildSystemPrompt(title, year, today){ + return [ + `You are a torrent-selection assistant. Today's date is ${today}.`, + `Recent releases are legitimate: NEVER reject a torrent for being too new or assume`, + `a current-year release is fake.`, + ``, + `The user wants "${title}" (${year || 'unknown year'}). From the candidate list,`, + `select the best releases.`, + year ? `IMPORTANT: only pick releases whose name contains the year ${year}; reject other years or remakes of a same-named title.` : ``, + `Preferences, in order:`, + `- video codec HEVC/x265 (over x264/AVC)`, + `- 1080p resolution`, + `- total size around 1.5 GB (avoid needlessly huge files)`, + `- English audio and English subtitles present`, + `- prefer EXTENDED / UNRATED / UNCUT / DIRECTOR'S CUT editions`, + `Reject: CAM, TS, TC, TELESYNC, HDCAM, SCREENER/SCR, and non-English-only copies.`, + ``, + `Return STRICT JSON only, shape:`, + `{"options":[{"role":"recommended|uhd|other","label":"...","info_hash":"...",`, + `"name":"...","resolution":"...","codec":"...","hasSubs":true,"cut":"...",`, + `"warning":"...","why":"..."}]}`, + `Rules: exactly one "recommended" (the 1080p sweet spot). Include one "uhd" ONLY`, + `if a 4K/2160p release exists, and set its "warning" to note it is a much larger,`, + `slower download. Add "other" entries for genuinely distinct useful releases`, + `(e.g. a different cut or a much smaller copy). "why" is one short sentence.`, + `Copy info_hash verbatim from the chosen candidate.`, + ].join('\n'); +} + +// Pull the first JSON object out of a model response( tolerates ```json fences / prose). +function extractJSON(content){ + let text = String(content).replace(/```json/gi, '').replace(/```/g, '').trim(); + let start = text.indexOf('{'); + let end = text.lastIndexOf('}'); + if(start === -1 || end === -1) throw new Error('No JSON in model response'); + return JSON.parse(text.slice(start, end + 1)); +} + +async function rankReleases(candidates, meta){ + let compact = candidates.map(t => ({ + name: t.name, + sizeHuman: humanSize(t.size), + seeders: Number(t.seeders), + info_hash: t.info_hash, + imdb: t.imdb || null, + })); + + let body = { + model: conf.ollama.model, + stream: false, + format: 'json', + messages: [ + { role: 'system', content: buildSystemPrompt(meta.title, meta.year, meta.today) }, + { role: 'user', content: JSON.stringify(compact) }, + ], + }; + + let data = await fetchJSON(`${conf.ollama.url}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${conf.ollama.apiKey}`, + }, + body: JSON.stringify(body), + }); + + let parsed = extractJSON(data.message && data.message.content); + let options = Array.isArray(parsed.options) ? parsed.options : []; + if(!options.length) throw new Error('Model returned no options'); + return options; +} + +// Deterministic fallback if the LLM is unavailable or returns junk: pick the most-seeded +// candidate, preferring x265 + 1080p, so the feature degrades instead of failing. +function fallbackReleases(candidates){ + let score = t => (/x265|hevc/i.test(t.name) ? 2 : 0) + (/1080p/i.test(t.name) ? 1 : 0); + let best = [...candidates].sort((a, b) => + (score(b) - score(a)) || (Number(b.seeders) - Number(a.seeders)) + )[0]; + if(!best) return []; + return [{ + role: 'recommended', + label: 'Most seeded', + info_hash: best.info_hash, + name: best.name, + sizeHuman: humanSize(best.size), + seeders: Number(best.seeders), + why: 'Automatically chosen (AI ranking unavailable): most seeders.', + }]; +} + +// Attach the fields the front end / add flow need to each option and drop any the model +// hallucinated( info_hash must exist in the candidate set). +function decorateOptions(options, candidates){ + let byHash = {}; + for(let t of candidates) byHash[String(t.info_hash).toLowerCase()] = t; + + let out = []; + for(let opt of options){ + let src = byHash[String(opt.info_hash).toLowerCase()]; + if(!src) continue; + out.push({ + ...opt, + info_hash: src.info_hash, + name: opt.name || src.name, + category: Number(src.category), + sizeHuman: opt.sizeHuman || humanSize(src.size), + seeders: Number(src.seeders), + magnetLink: buildMagnet(src.info_hash, src.name), + }); + } + return out; +} + +async function findReleases(tmdbId, mediaType){ + let meta = await tmdbDetails(tmdbId, mediaType); + let torrents = await tpbSearch(meta.title); + let candidates = prefilter(torrents, meta.imdbId); + + if(!candidates.length){ + return { title: meta.title, year: meta.year, options: [] }; + } + + let today = new Date().toISOString().slice(0, 10); + let options; + try{ + options = await rankReleases(candidates, { title: meta.title, year: meta.year, today }); + }catch(error){ + console.error('rankReleases failed, using fallback:', error.message); + options = fallbackReleases(candidates); + } + + options = decorateOptions(options, candidates); + if(!options.length) options = decorateOptions(fallbackReleases(candidates), candidates); + + return { title: meta.title, year: meta.year, options }; +} + +module.exports = { + tmdbSearch, + findReleases, + // exported for unit tests + prefilter, + buildMagnet, + humanSize, + videoKind, + extractJSON, + decorateOptions, + fallbackReleases, + tpbSearch, + parseTPBHtml, + sizeToBytes, +}; diff --git a/controller/tmdb.js b/controller/tmdb.js new file mode 100644 index 0000000..64f5e5c --- /dev/null +++ b/controller/tmdb.js @@ -0,0 +1,72 @@ +'use strict'; + +const conf = require('>/conf'); + +async function fetchJSON(url, options){ + let res = await fetch(url, options); + if(!res.ok){ + let error = new Error('UpstreamError'); + error.message = `Request to ${url.split('?')[0]} failed( ${res.status})`; + error.status = 502; + throw error; + } + return await res.json(); +} + +async function tmdbSearch(query){ + let url = `https://api.themoviedb.org/3/search/multi?include_adult=false` + + `&query=${encodeURIComponent(query)}&api_key=${conf.tmdb.apiKey}`; + + let data = await fetchJSON(url); + + return (data.results || []) + .filter(item => item.media_type === 'movie' || item.media_type === 'tv') + .slice(0, 4) + .map(item => { + let date = item.release_date || item.first_air_date || ''; + return { + tmdbId: item.id, + mediaType: item.media_type, + title: item.title || item.name, + year: date ? date.slice(0, 4) : '', + posterUrl: item.poster_path ? `https://image.tmdb.org/t/p/w200${item.poster_path}` : null, + }; + }); +} + +async function tmdbDetails(tmdbId, mediaType){ + let url = `https://api.themoviedb.org/3/${mediaType}/${tmdbId}` + + `?append_to_response=external_ids&api_key=${conf.tmdb.apiKey}`; + + let data = await fetchJSON(url); + let date = data.release_date || data.first_air_date || ''; + + return { + title: data.title || data.name, + year: date ? date.slice(0, 4) : '', + imdbId: data.imdb_id || (data.external_ids && data.external_ids.imdb_id) || null, + }; +} + +// Canonicalize a( possibly messy) title + year to the authoritative TMDB record. +// Returns null when nothing matches so callers can flag instead of guessing. +async function tmdbFindBest(title, year, mediaType){ + let results = await tmdbSearch(title); + + let pool = results.filter(r => r.mediaType === mediaType); + if(!pool.length) pool = results; + if(!pool.length) return null; + + let pick = (year && pool.find(r => r.year === String(year))) || pool[0]; + let details = await tmdbDetails(pick.tmdbId, pick.mediaType); + + return { + title: details.title, + year: details.year, + tmdbId: pick.tmdbId, + mediaType: pick.mediaType, + imdbId: details.imdbId, + }; +} + +module.exports = { fetchJSON, tmdbSearch, tmdbDetails, tmdbFindBest }; diff --git a/models/ldap/user.js b/models/ldap/user.js index 46eaf60..843593b 100644 --- a/models/ldap/user.js +++ b/models/ldap/user.js @@ -11,6 +11,12 @@ var userLUR = new LRUCache({ maxAge: 60000, }); +const escapeFilterValue = function(value){ + return String(value).replace(/[\0()*\\]/g, function(char){ + return '\\' + char.charCodeAt(0).toString(16).padStart(2, '0'); + }); +} + const user_parse = function(data){ if(data[conf.userNameAttribute]){ data.username = data[conf.userNameAttribute]; @@ -89,7 +95,7 @@ User.get = async function(data, key){ data.searchKey = data.searchKey || key || conf.userNameAttribute; data.searchValue = data.searchValue || data.uid; - let filter = `(&${conf.userFilter}(${data.searchKey}=${data.searchValue}))`; + let filter = `(&${conf.userFilter}(${escapeFilterValue(data.searchKey)}=${escapeFilterValue(data.searchValue)}))`; if(userLUR.get(filter)) return userLUR.get(filter); const client = new Client({ @@ -136,7 +142,14 @@ User.exists = async function(data, key){ User.login = async function(data){ try{ - + if(!data.password){ + let error = new Error('LDAPLoginFailed'); + error.name = 'LDAPLoginFailed'; + error.message = 'Invalid Credentials, login failed.'; + error.status = 401; + throw error; + } + let user = await this.get(data.uid || data[conf.userNameAttribute] || data.username); const client = new Client({ diff --git a/models/sql/migrations/20260701120000-add-category-to-torrent.js b/models/sql/migrations/20260701120000-add-category-to-torrent.js new file mode 100644 index 0000000..c1f9282 --- /dev/null +++ b/models/sql/migrations/20260701120000-add-category-to-torrent.js @@ -0,0 +1,14 @@ +'use strict'; +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('Torrents', 'category', { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'Other' + }); + }, + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('Torrents', 'category'); + } +}; diff --git a/models/sql/migrations/20260701130000-add-organize-fields.js b/models/sql/migrations/20260701130000-add-organize-fields.js new file mode 100644 index 0000000..1a42ba1 --- /dev/null +++ b/models/sql/migrations/20260701130000-add-organize-fields.js @@ -0,0 +1,18 @@ +'use strict'; +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('Torrents', 'organizedAt', { + type: Sequelize.DATE, + allowNull: true, + }); + await queryInterface.addColumn('Torrents', 'metadata', { + type: Sequelize.JSON, + allowNull: true, + }); + }, + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('Torrents', 'organizedAt'); + await queryInterface.removeColumn('Torrents', 'metadata'); + } +}; diff --git a/models/sql/torrent.js b/models/sql/torrent.js index 11f3682..0d0a5b3 100644 --- a/models/sql/torrent.js +++ b/models/sql/torrent.js @@ -29,6 +29,21 @@ module.exports = (sequelize, DataTypes, Model) => { static trClient = tr_client; + // Map a raw TPB category id( e.g. 207) to one of our buckets. TPB groups by the + // hundreds digit; TV shows are the exception pulled out of the Video group. + static categoryFromTPB(id){ + id = parseInt(id, 10); + if([205, 208, 212].includes(id)) return 'TV'; + return { + 1: 'Music', + 2: 'Movie', + 3: 'App', + 4: 'Game', + 5: 'Adult', + 6: 'Other', + }[Math.floor(id / 100)] || 'Other'; + } + static async create(data, ...args){ try{ @@ -50,6 +65,7 @@ module.exports = (sequelize, DataTypes, Model) => { isPrivate: data.isPrivate, name: res.name, added_by: data.added_by, + category: this.categoryFromTPB(data.category), status: 0, percentDone: 0, }, args); @@ -59,41 +75,7 @@ module.exports = (sequelize, DataTypes, Model) => { } } - static async migrate(hashString, username){ - try{ - let exists = await this.findByPk(hashString); - - if(exists){ - console.log('torrent in DB, skipping') - return {} - } - - let res = ( await tr_client.get(hashString, [ - "eta", "percentDone", "status", "rateDownload", - "errorString", "hashString", 'name', - 'downloadDir', - 'addedDate', - 'magnetLink', - 'files', //array of files - 'filesStats', // array of files with status - 'isFinished', - 'isStalled', - 'peers', - 'peersConnected', // array of peers, - 'sizeWhenDone', - ]) ).torrents[0]; - - // console.log('date:', res.addedDate, new Date(res.addedDate*1000), 'res:', res) - - let instance = await this.build({createdAt: new Date(res.addedDate*1000), ...res, added_by: username}); - await instance.save(); - return {...res, ...instance.dataValues}; - }catch(error){ - console.error('migrate error', error); - } - } - - async getTorrentData(noUpdate){ + async getTorrentData(){ try{ if(this.percentDone === 1) return this.dataValues @@ -113,8 +95,7 @@ module.exports = (sequelize, DataTypes, Model) => { ]) ).torrents[0]; await this.update(res); - if(noUpdate) await this.save(); - + return {...res, ...this.dataValues}; }catch(error){ if(error.code === 'ECONNREFUSED'){ @@ -139,7 +120,7 @@ module.exports = (sequelize, DataTypes, Model) => { } async destroy(){ - await await this.constructor.trClient.remove(this.hashString, true); + await this.constructor.trClient.remove(this.hashString, true); return await super.destroy() } } @@ -162,6 +143,11 @@ module.exports = (sequelize, DataTypes, Model) => { defaultValue: false, }, name: DataTypes.STRING, + category: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'Other', + }, added_by: { type: DataTypes.STRING, ldapModel: 'User', @@ -188,6 +174,14 @@ module.exports = (sequelize, DataTypes, Model) => { createdAt: { type: DataTypes.DATE }, + organizedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + metadata: { + type: DataTypes.JSON, + allowNull: true, + }, }, { sequelize, modelName: 'Torrent', diff --git a/public/js/app.js b/public/js/app.js index 915c35f..43aec1e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -168,12 +168,23 @@ app.auth = (function(app) { var user = {} function setToken(token){ localStorage.setItem('APIToken', token); + setCookieToken(token); } function getToken(){ return localStorage.getItem('APIToken'); } + // The proxy gates page navigation on a cookie( the auth-token header can not ride + // along on a navigation), so mirror the token into a cookie. + function setCookieToken(token){ + document.cookie = 'auth-token=' + encodeURIComponent(token) + '; path=/; max-age=31536000; samesite=lax'; + } + + function clearCookieToken(){ + document.cookie = 'auth-token=; path=/; max-age=0; samesite=lax'; + } + function isLoggedIn(callback){ if(getToken()){ return app.api.get('user/me', function(error, data){ @@ -198,6 +209,7 @@ app.auth = (function(app) { function logOut(callback){ callback = callback || app.util.emptyFuction; localStorage.removeItem('APIToken'); + clearCookieToken(); callback(); } @@ -231,9 +243,14 @@ app.auth = (function(app) { $( document ).ready( function(){ isLoggedIn(function(error, isLoggedIn){ if(!error && isLoggedIn){ + // Refresh the navigation cookie; if this is the logged out gate page, + // reload now that the cookie is set so the real content is proxied. + setCookieToken(getToken()); + if(window.__tbpLoginGate) return location.reload(); $('.tbp_proxy_is_authed').show(); $('.tbp_proxy_not_authed').hide(); }else{ + clearCookieToken(); $('.tbp_proxy_is_authed').hide(); $('.tbp_proxy_not_authed').show(); } diff --git a/public/partial/header.html b/public/partial/header.html index 6f349d2..a895af8 100644 --- a/public/partial/header.html +++ b/public/partial/header.html @@ -185,6 +185,15 @@

Done! HTTP Link

+ {{#organized}} +

📁 Filed to {{libraryPath}}

+ {{/organized}} + {{#organizeError}} +

⚠ Not filed: {{organizeError}}

+ {{/organizeError}} + {{/isFinished}}
@@ -241,6 +250,8 @@

+ +

@@ -268,11 +279,20 @@ -

+
+ + +
@@ -320,6 +340,104 @@ $('#tbp_proxy_torrent_add_dialog').dialog(commonDialogOptions); + /* Smart Search button and dialog */ + $('#tbp_proxy_search_dialog').dialog(commonDialogOptions); + + // Escape untrusted text before dropping it into HTML. + function tbpEsc(text){ return $('').text(text == null ? '' : text).html(); } + function tbpSearchBody(html){ $('#tbp_proxy_search_dialog_body').html(html); } + + // Step 1: render the TMDB title candidates to confirm which one they meant. + // onPick defaults to the search flow; the "Fix match" flow passes its own. + function tbpRenderTitles(results, onPick){ + onPick = onPick || tbpFindReleases; + if(!results.length){ tbpSearchBody('

No matches found.

'); return; } + tbpSearchBody('

Which one did you mean?

'); + results.forEach(function(r){ + var poster = r.posterUrl ? '' : ''; + $('
') + .html(poster +''+ tbpEsc(r.title) +' ('+ tbpEsc(r.year || '?') +') '+ tbpEsc(r.mediaType) +'') + .on('click', function(){ onPick(r); }) + .appendTo('#tbp_proxy_search_dialog_body'); + }); + } + + // "Fix match": re-run the title picker for a finished torrent and re-file it under + // the chosen TMDB match via POST /torrent/:hash/organize/match. + window.tbpFixMatch = function(hash){ + var item = ($.scope.tbp_proxy_torrent_dialog_torrents || []).filter(function(t){ return t.hashString === hash; })[0]; + var query = (item ? item.name : '').replace(/[._]/g, ' ').replace(/\b(19|20)\d\d\b.*/, function(m){ return m.slice(0, 4); }).trim(); + + openDialog($('#tbp_proxy_search_dialog')); + tbpSearchBody('

Searching for a better match…

'); + app.api.get('search/title?q='+ encodeURIComponent(query || (item ? item.name : '')), function(error, data){ + if(error || !data || !data.results){ tbpSearchBody('

Search failed. Please try again.

'); return; } + tbpRenderTitles(data.results, function(r){ + tbpSearchBody('

Re-filing as '+ tbpEsc(r.title) +' ('+ tbpEsc(r.year) +')

'); + app.api.post('torrent/'+ hash +'/organize/match', { tmdbId: r.tmdbId, mediaType: r.mediaType }, function(err, res){ + if(err || !res || res.organized !== true){ + tbpSearchBody('

Re-file failed: '+ tbpEsc((res && res.message) || err) +'

'); return; + } + tbpSearchBody('

✓ Re-filed to '+ tbpEsc(res.libraryPath) +'. Emby will re-index.

'); + }); + }); + }); + }; + + // Step 2: ask the server( TPB + LLM) for the curated release options. + function tbpFindReleases(title){ + tbpSearchBody('

Finding the best copies for '+ tbpEsc(title.title) +'
this can take a few seconds.

'); + app.api.post('search/releases', { tmdbId: title.tmdbId, mediaType: title.mediaType }, function(error, data){ + if(error || !data || !data.options){ tbpSearchBody('

Search failed. Please try again.

'); return; } + tbpRenderReleases(data); + }); + } + + // Step 3: render the release cards; a tap feeds the existing add dialog. + function tbpRenderReleases(data){ + if(!data.options.length){ tbpSearchBody('

No good torrents found for '+ tbpEsc(data.title) +'.

'); return; } + tbpSearchBody('

'+ tbpEsc(data.title +' ('+ (data.year || '?') +')') +'

'); + data.options.forEach(function(o){ + var warn = o.warning ? '
⚠ '+ tbpEsc(o.warning) +'
' : ''; + var why = o.why ? '
'+ tbpEsc(o.why) +'
' : ''; + $('
') + .html(''+ tbpEsc(o.label || o.role) +' '+ tbpEsc(o.sizeHuman || '') +' · '+ tbpEsc(o.seeders || 0) +' seeders' + + '
'+ tbpEsc(o.name) +'
' + why + warn) + .on('click', function(){ tbpAddRelease(o); }) + .appendTo('#tbp_proxy_search_dialog_body'); + }); + } + + // Reuse the existing add flow: fill the torrentAdd scope and open the add dialog. + function tbpAddRelease(o){ + $.scope.torrentAdd.update({ + magnetLink: o.magnetLink, + name: o.name, + hashString: String(o.info_hash).toLowerCase(), + category: o.category, + }); + if(localStorage.getItem('isPrivate') === 'true'){ + $('#isPrivate-true').prop('checked', true); + }else{ + $('#isPrivate-false').prop('checked', true); + } + $('#tbp_proxy_search_dialog').dialog('close'); + openDialog($('#tbp_proxy_torrent_add_dialog')); + } + + $('#tbp_proxy_search_form').on('submit', function(){ + var q = $('#tbp_proxy_search_input').val(); + if(!q) return false; + openDialog($('#tbp_proxy_search_dialog')); + tbpSearchBody('

Searching…

'); + app.api.get('search/title?q='+ encodeURIComponent(q), function(error, data){ + if(error || !data || !data.results){ tbpSearchBody('

Search failed. Please try again.

'); return; } + tbpRenderTitles(data.results); + }); + return false; + }); + + /* Enable tooltips*/ $('#tbp_proxy_header').tooltip({ track: true @@ -332,10 +450,17 @@ // magnetLink let magnetLinkParams = new URLSearchParams($(this).data('link')); + // Grab the TPB category id from the /browse/ link in this + // torrent's row( listing) or the Type: field( detail page). + let $cat = $(this).closest('tr').find('a[href*="/browse/"]').first(); + if(!$cat.length) $cat = $('dd a[href*="/browse/"]').first(); + let category = $cat.length ? $cat.attr('href').replace(/.*\/browse\//, '').replace(/\D.*$/, '') : ''; + $.scope.torrentAdd.update({ magnetLink: $(this).data('link'), name: magnetLinkParams.get('dn'), hashString: magnetLinkParams.get('magnet:?xt').split(':').pop().toLowerCase(), + category: category, }); if(localStorage.getItem('isPrivate') === 'true'){ @@ -521,7 +646,9 @@ "isActive": [3, 4, 5, 6].includes(torrent.status), // DOWNLOAD_WAIT ,DOWNLOAD, SEED_WAIT, SEED "isFinished": torrent.isFinished || percentDone === 100, "createdAtString": moment(torrent.createdAt).fromNow(), - + "organized": !!torrent.organizedAt, + "libraryPath": torrent.metadata && torrent.metadata.libraryPath, + "organizeError": torrent.metadata && torrent.metadata.organizeError, } } diff --git a/routes/api.js b/routes/api.js index cfc19ba..5be6113 100644 --- a/routes/api.js +++ b/routes/api.js @@ -4,8 +4,9 @@ const router = require('express').Router(); const middleware = require('>/middleware/auth'); router.use('/auth', require('./auth')); -router.use('/token/auth', require('./authtoken')); +router.use('/token/auth', middleware.auth, require('./authtoken')); router.use('/torrent', middleware.auth, require('./transmission')); +router.use('/search', middleware.auth, require('./search')); router.use('/user', middleware.auth, require('./user')); module.exports = router; diff --git a/routes/authtoken.js b/routes/authtoken.js index e24c663..5d4afb0 100644 --- a/routes/authtoken.js +++ b/routes/authtoken.js @@ -3,10 +3,22 @@ const router = require('express').Router(); const {AuthToken} = require('>/models'); +function ownToken(token, req){ + if(!token || token.username !== req.user.username){ + let error = new Error('AuthTokenNotFound'); + error.name = 'AuthTokenNotFound'; + error.message = 'Token not found'; + error.status = 404; + throw error; + } + return token; +} router.get('/', async function(req, res, next){ try{ - return res.json(await AuthToken.findAll()); + return res.json(await AuthToken.findAll({where:{ + username: req.user.username + }})); }catch(error){ next(error); } @@ -14,7 +26,7 @@ router.get('/', async function(req, res, next){ router.post('/', async function(req, res, next){ try{ - return res.json(await AuthToken.create(req.body)); + return res.json(await AuthToken.create({...req.body, username: req.user.username})); }catch(error){ console.error(error) next(error); @@ -23,6 +35,14 @@ router.post('/', async function(req, res, next){ router.get('/user/:username', async function(req, res, next){ try{ + if(req.params.username !== req.user.username){ + let error = new Error('AuthTokenNotFound'); + error.name = 'AuthTokenNotFound'; + error.message = 'Token not found'; + error.status = 404; + throw error; + } + return res.json(await AuthToken.findAll({where:{ username: req.params.username }})); @@ -33,7 +53,7 @@ router.get('/user/:username', async function(req, res, next){ router.get('/:token', async function(req, res, next){ try{ - let token = await AuthToken.findByPk(req.params.token) + let token = ownToken(await AuthToken.findByPk(req.params.token), req); token.dataValues.user = await token.getUser() return res.json(token); @@ -44,7 +64,7 @@ router.get('/:token', async function(req, res, next){ router.put('/:token', async function(req, res, next){ try{ - let token = await AuthToken.findByPk(req.params.token); + let token = ownToken(await AuthToken.findByPk(req.params.token), req); await token.update(req.body); return res.json(token); }catch(error){ @@ -54,7 +74,7 @@ router.put('/:token', async function(req, res, next){ router.delete('/:token', async function(req, res, next){ try{ - let token = await AuthToken.findByPk(req.params.token); + let token = ownToken(await AuthToken.findByPk(req.params.token), req); await token.destroy(); return res.json({'deleted': true}); diff --git a/routes/proxy.js b/routes/proxy.js index ac3729b..cb11aaa 100644 --- a/routes/proxy.js +++ b/routes/proxy.js @@ -1,19 +1,57 @@ -'use static'; +'use strict'; const router = require('express').Router(); +const path = require('path'); const zlib = require('zlib'); const fs = require('fs'); const https = require('https'); const http = require("http"); const proxy = require('http-proxy-middleware'); +const { Auth } = require('>/controller/auth'); -const inject = fs.readFileSync('./inject.html', 'utf8'); -const mainjs = fs.readFileSync('./static/main.js', 'utf8'); +const inject = fs.readFileSync(path.join(__dirname, '..', 'inject.html'), 'utf8'); +const mainjs = fs.readFileSync(path.join(__dirname, '..', 'static', 'main.js'), 'utf8'); + +// Page served to users who are not logged in. It reuses the injected front end +// (jQuery + header partial) so the login dialog is available, but nothing is proxied. +const loginPage = "Login" + + inject + + "" + + ""; + +function parseCookies(req){ + let out = {}; + let header = req.headers.cookie; + if(!header) return out; + for(let pair of header.split(';')){ + let idx = pair.indexOf('='); + if(idx < 0) continue; + out[pair.slice(0, idx).trim()] = decodeURIComponent(pair.slice(idx + 1).trim()); + } + return out; +} + +// Keep the proxy( and everything it serves) out of search engines. +router.get('/robots.txt', function(req, res){ + res.type('text/plain').send('User-agent: *\nDisallow: /\n'); +}); + +// Block all proxying for users who are not logged in. Page navigation can not send +// the auth-token header, so the token is read from a cookie set by the front end. +router.use(async function(req, res, next){ + res.set('X-Robots-Tag', 'noindex, nofollow'); + try{ + await Auth.checkToken(parseCookies(req)['auth-token']); + return next(); + }catch(error){ + return res.status(401).send(loginPage); + } +}); // app.all("/*.js", function(req, res){res.send('')}); router.all('/static/main.js', function(req,res){ - res.write(mainjs); + res.end(mainjs); }); const proxyTarget = { @@ -70,7 +108,7 @@ router.all("/*", proxy({ // res.set(key, proxyRes.headers[key].toString().replace('http://', 'https://')) } - let body = new Buffer(''); + let body = Buffer.alloc(0); proxyRes.on('error', function(e){ console.error('ERROR!', e) }); diff --git a/routes/search.js b/routes/search.js new file mode 100644 index 0000000..ca478e6 --- /dev/null +++ b/routes/search.js @@ -0,0 +1,24 @@ +'use strict'; + +const router = require('express').Router(); +const search = require('>/controller/search'); + +// Step 1: fuzzy query -> a few TMDB title candidates( with posters) to confirm. +router.get('/title', async function(req, res, next){ + try{ + res.json({results: await search.tmdbSearch(req.query.q)}); + }catch(error){ + next(error); + } +}); + +// Step 2: confirmed title -> TPB search + LLM-curated release options. +router.post('/releases', async function(req, res, next){ + try{ + res.json(await search.findReleases(req.body.tmdbId, req.body.mediaType)); + }catch(error){ + next(error); + } +}); + +module.exports = router; diff --git a/routes/transmission.js b/routes/transmission.js index 3bfcdab..0a7f5f1 100644 --- a/routes/transmission.js +++ b/routes/transmission.js @@ -1,12 +1,28 @@ -'use static'; +'use strict'; const router = require('express').Router(); const {Torrent} = require('>/models'); +const organize = require('>/controller/organize'); + +function authTorrent(torrent, req){ + if(torrent && torrent.isPrivate && torrent.added_by !== req.user.username){ + let error = new Error('TorrentNotFound'); + error.name = 'TorrentNotFound'; + error.message = 'Torrent not found'; + error.status = 404; + throw error; + } + return torrent; +} router.get('/', async function(req, res, next){ try{ + let username = req.query.username || req.user.username; + let where = {added_by: username}; + if(username !== req.user.username) where.isPrivate = false; + res.json({results: await Torrent.findAll({ - where:{added_by: req.query.username || req.user.username}, + where, limit: req.query.limit, offset: req.query.offset, order: [ @@ -26,14 +42,6 @@ router.post("/", async function(req, res, next){ } }); -router.post("/:hashString", async function(req, res, next){ - try{ - res.json(await Torrent.migrate(req.params.hashString, req.user.username)) - }catch(error){ - next(error); - } -}); - router.get('/server', async function(req, res, next){ try{ res.json(await Torrent.trClient.sessionStats()) @@ -44,7 +52,7 @@ router.get('/server', async function(req, res, next){ router.get("/:hashString", async function(req, res, next){ try{ - let torrent = await Torrent.findByPk(req.params.hashString); + let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req); if('latest' in req.query){ torrent = await torrent.getTorrentData(); } @@ -56,7 +64,7 @@ router.get("/:hashString", async function(req, res, next){ router.delete("/:hashString", async function(req, res, next){ try{ - let torrent = await Torrent.findByPk(req.params.hashString); + let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req); res.json({result: torrent, activity: await torrent.destroy()}); }catch(error){ @@ -66,7 +74,7 @@ router.delete("/:hashString", async function(req, res, next){ router.post("/:hashString/stop", async function(req, res, next){ try{ - let torrent = await Torrent.findByPk(req.params.hashString); + let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req); res.json({result: torrent, activity: await torrent.stop()}); }catch(error){ @@ -76,7 +84,7 @@ router.post("/:hashString/stop", async function(req, res, next){ router.post("/:hashString/start", async function(req, res, next){ try{ - let torrent = await Torrent.findByPk(req.params.hashString); + let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req); res.json({result: torrent, activity: await torrent.start()}); }catch(error){ @@ -84,6 +92,30 @@ router.post("/:hashString/start", async function(req, res, next){ } }); +// Manually (re)run organization for a finished torrent. +router.post("/:hashString/organize", async function(req, res, next){ + try{ + let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req); + if(!torrent){ let e = new Error('TorrentNotFound'); e.status = 404; throw e; } + + res.json(await organize.fileTorrent(torrent)); + }catch(error){ + next(error); + } +}); + +// Correct a wrong match: re-file an already-organized torrent under a chosen TMDB id. +router.post("/:hashString/organize/match", async function(req, res, next){ + try{ + let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req); + if(!torrent){ let e = new Error('TorrentNotFound'); e.status = 404; throw e; } + + res.json(await organize.refileWithMatch(torrent, req.body.tmdbId, req.body.mediaType)); + }catch(error){ + next(error); + } +}); + module.exports = router;