Harden auth, add AI smart-search and post-download organization #11

Merged
wmantly merged 5 commits from llm into master 2026-07-19 04:26:53 +00:00
5 changed files with 241 additions and 4 deletions
Showing only changes of commit 27a6656ac5 - Show all commits
+69
View File
@@ -0,0 +1,69 @@
'use strict';
const conf = require('>/conf');
// Resolution rank so we can compare "what you own" against a candidate release.
function rankFromWidth(w){
w = Number(w) || 0;
if(w >= 3000) return 4; // 4k
if(w >= 1700) return 3; // 1080p
if(w >= 1000) return 2; // 720p
if(w > 0) return 1; // sd
return 0;
}
function rankFromLabel(label){
label = String(label || '').toLowerCase();
if(/2160|4k|uhd/.test(label)) return 4;
if(label.includes('1080')) return 3;
if(label.includes('720')) return 2;
if(label.includes('480')) return 1;
return 0;
}
function labelFromRank(rank){ return ['', '480p', '720p', '1080p', '4k'][rank] || ''; }
async function embyGet(pathAndQuery){
let sep = pathAndQuery.includes('?') ? '&' : '?';
let res = await fetch(`${conf.emby.url}${pathAndQuery}${sep}api_key=${conf.emby.apiKey}`);
if(!res.ok) throw new Error(`Emby ${res.status}`);
return await res.json();
}
// Is this movie already in the library, and at what best quality? Never throws — an
// unreachable Emby just means "unknown", so search still works.
async function movieInLibrary(tmdbId){
try{
let data = await embyGet(`/Items?Recursive=true&IncludeItemTypes=Movie&AnyProviderIdEquals=tmdb.${tmdbId}&Fields=Width`);
let items = data.Items || [];
if(!items.length) return { owned: false, rank: 0, quality: '' };
let rank = Math.max(...items.map(i => rankFromWidth(i.Width)));
return { owned: true, rank, quality: labelFromRank(rank), count: items.length };
}catch(error){
return { owned: false, rank: 0, quality: '', unknown: true };
}
}
// Which episodes of a series are already present, grouped by season( season -> count).
async function seriesInventory(tmdbId){
try{
let s = await embyGet(`/Items?Recursive=true&IncludeItemTypes=Series&AnyProviderIdEquals=tmdb.${tmdbId}&Fields=ProviderIds`);
let series = (s.Items || [])[0];
if(!series) return { owned: false, have: {} };
let ep = await embyGet(`/Shows/${series.Id}/Episodes`);
let sets = {};
for(let e of ep.Items || []){
let sn = e.ParentIndexNumber, en = e.IndexNumber;
if(sn == null || en == null || sn === 0) continue; // skip specials
(sets[sn] = sets[sn] || new Set()).add(en);
}
let have = {};
for(let sn of Object.keys(sets)) have[sn] = sets[sn].size;
return { owned: true, seriesId: series.Id, have };
}catch(error){
return { owned: false, have: {}, unknown: true };
}
}
module.exports = { movieInLibrary, seriesInventory, rankFromWidth, rankFromLabel, labelFromRank };
+73 -2
View File
@@ -1,7 +1,10 @@
'use strict';
const conf = require('>/conf');
const { tmdbSearch, tmdbDetails } = require('>/controller/tmdb');
const tmdb = require('>/controller/tmdb');
const { tmdbSearch, tmdbDetails } = tmdb;
const library = require('>/controller/library');
const ollama = require('>/controller/ollama');
// 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).
@@ -257,12 +260,80 @@ async function findReleases(tmdbId, mediaType){
options = decorateOptions(options, candidates);
if(!options.length) options = decorateOptions(fallbackReleases(candidates), candidates);
return { title: meta.title, year: meta.year, options };
let result = { title: meta.title, year: meta.year, options };
// Quality-aware dedup: flag options you already own at equal-or-better quality.
if(mediaType !== 'tv'){
let lib = await library.movieInLibrary(tmdbId);
if(lib.owned){
for(let o of options) o.alreadyOwned = library.rankFromLabel(o.resolution) <= lib.rank;
result.library = { owned: true, quality: lib.quality };
}
}
return result;
}
// --- TV: library-aware season fill -----------------------------------------
function buildSeasonPrompt(title, season, today){
return [
`You are a torrent selector. Today's date is ${today}.`,
`Pick the single best COMPLETE season pack for "${title}" Season ${season}.`,
`It MUST be the full season (an S${String(season).padStart(2, '0')} / "Season ${season}"`,
`complete pack), NOT a single episode. Prefer HEVC/x265, 1080p, English audio +`,
`English subtitles, reasonable size. Reject single episodes, wrong seasons,`,
`CAM/TS/telesync, and non-English-only.`,
`Return STRICT JSON {"info_hash":"<from candidates>","why":"one short sentence"};`,
`if nothing suitable, {"info_hash":null}.`,
].join('\n');
}
async function bestSeasonPack(candidates, meta){
let compact = candidates.map(t => ({ name: t.name, sizeHuman: humanSize(t.size), seeders: Number(t.seeders), info_hash: t.info_hash }));
let parsed = await ollama.chatJSON(buildSeasonPrompt(meta.title, meta.season, meta.today), JSON.stringify(compact));
return parsed && parsed.info_hash ? parsed : null;
}
// Compare TMDB's season/episode counts against the Emby inventory to classify each season.
async function seasonPlan(tmdbId){
let [tv, inv] = await Promise.all([ tmdb.tmdbSeasons(tmdbId), library.seriesInventory(tmdbId) ]);
let seasons = tv.seasons.map(s => {
let have = Number(inv.have && inv.have[s.season]) || 0;
let status = have === 0 ? 'missing' : (have >= s.episodeCount ? 'complete' : 'partial');
return { season: s.season, episodeCount: s.episodeCount, haveCount: have, status };
});
return { title: tv.title, year: tv.year, seasons, missing: seasons.filter(s => s.status !== 'complete').map(s => s.season) };
}
// For every missing/incomplete season, pick the best complete-season pack. Returns the
// chosen packs( the route adds them to Transmission) plus any seasons it couldn't fill.
async function fillMissing(tmdbId){
let plan = await seasonPlan(tmdbId);
let details = await tmdbDetails(tmdbId, 'tv');
let title = details.title || plan.title;
let today = new Date().toISOString().slice(0, 10);
let picks = [], skipped = [];
for(let season of plan.missing){
let cands = prefilter(await tpbSearch(`${title} S${String(season).padStart(2, '0')}`), null);
if(!cands.length){ skipped.push({ season, reason: 'no results' }); continue; }
let pick = null;
try{ pick = await bestSeasonPack(cands, { title, season, today }); }catch(error){ pick = null; }
let src = pick && cands.find(c => c.info_hash.toLowerCase() === String(pick.info_hash).toLowerCase());
if(!src){ skipped.push({ season, reason: 'no suitable season pack' }); continue; }
picks.push({ season, name: src.name, info_hash: src.info_hash, category: Number(src.category), magnetLink: buildMagnet(src.info_hash, src.name), why: pick.why });
}
return { title, year: plan.year, picks, skipped };
}
module.exports = {
tmdbSearch,
findReleases,
seasonPlan,
fillMissing,
// exported for unit tests
prefilter,
buildMagnet,
+14 -1
View File
@@ -69,4 +69,17 @@ async function tmdbFindBest(title, year, mediaType){
};
}
module.exports = { fetchJSON, tmdbSearch, tmdbDetails, tmdbFindBest };
// Per-season episode counts for a show( season 0 / specials excluded).
async function tmdbSeasons(tmdbId){
let data = await fetchJSON(`https://api.themoviedb.org/3/tv/${tmdbId}?api_key=${conf.tmdb.apiKey}`);
let date = data.first_air_date || '';
return {
title: data.name,
year: date ? date.slice(0, 4) : '',
seasons: (data.seasons || [])
.filter(s => s.season_number >= 1 && s.episode_count > 0)
.map(s => ({ season: s.season_number, episodeCount: s.episode_count })),
};
}
module.exports = { fetchJSON, tmdbSearch, tmdbDetails, tmdbFindBest, tmdbSeasons };
+51 -1
View File
@@ -386,6 +386,7 @@
// Step 2: ask the server( TPB + LLM) for the curated release options.
function tbpFindReleases(title){
if(title.mediaType === 'tv') return tbpSeasonPlan(title);
tbpSearchBody('<p>Finding the best copies for <b>'+ tbpEsc(title.title) +'</b>…<br/>this can take a few seconds.</p>');
app.api.post('search/releases', { tmdbId: title.tmdbId, mediaType: title.mediaType }, function(error, data){
if(error || !data || !data.options){ tbpSearchBody('<p>Search failed. Please try again.</p>'); return; }
@@ -396,8 +397,17 @@
// Step 3: render the release cards; a tap feeds the existing add dialog.
function tbpRenderReleases(data){
if(!data.options.length){ tbpSearchBody('<p>No good torrents found for '+ tbpEsc(data.title) +'.</p>'); return; }
tbpSearchBody('<h3>'+ tbpEsc(data.title +' ('+ (data.year || '?') +')') +'</h3>');
var header = '<h3>'+ tbpEsc(data.title +' ('+ (data.year || '?') +')') +'</h3>';
if(data.library && data.library.owned) header += '<p style="color:#2a2">✓ Already in your library ('+ tbpEsc(data.library.quality) +') — only upgrades are offered.</p>';
tbpSearchBody(header);
data.options.forEach(function(o){
if(o.alreadyOwned){
$('<div style="padding:.5em;border-bottom:1px solid #ccc;color:#999;"></div>')
.html('<b>'+ tbpEsc(o.label || o.role) +'</b> — you already have this quality or better'
+ '<div style="font-size:.9em">'+ tbpEsc(o.name) +'</div>')
.appendTo('#tbp_proxy_search_dialog_body');
return;
}
var warn = o.warning ? '<div style="color:#b00;font-weight:bold;">⚠ '+ tbpEsc(o.warning) +'</div>' : '';
var why = o.why ? '<div style="color:#555;font-style:italic;">'+ tbpEsc(o.why) +'</div>' : '';
$('<div class="tbp_search_card" style="cursor:pointer;padding:.5em;border-bottom:1px solid #ccc;"></div>')
@@ -409,6 +419,46 @@
}
// Reuse the existing add flow: fill the torrentAdd scope and open the add dialog.
function tbpSeasonPlan(title){
tbpSearchBody('<p>Checking your library for <b>'+ tbpEsc(title.title) +'</b>…</p>');
app.api.get('search/seasons?tmdbId='+ encodeURIComponent(title.tmdbId), function(error, data){
if(error || !data || !data.seasons){ tbpSearchBody('<p>Could not load seasons.</p>'); return; }
tbpRenderSeasons(title, data);
});
}
function tbpRenderSeasons(title, data){
tbpSearchBody('<h3>'+ tbpEsc(data.title +' ('+ (data.year || '?') +')') +'</h3>');
data.seasons.forEach(function(s){
var badge, color;
if(s.status === 'complete'){ badge = '✓ complete'; color = '#2a2'; }
else if(s.status === 'partial'){ badge = '⚠ '+ s.haveCount +'/'+ s.episodeCount; color = '#b80'; }
else { badge = '✗ missing'; color = '#b00'; }
$('<div style="padding:.3em;border-bottom:1px solid #eee;"></div>')
.html('<b>Season '+ s.season +'</b> <span style="color:'+ color +'">'+ badge +'</span>')
.appendTo('#tbp_proxy_search_dialog_body');
});
if(data.missing && data.missing.length){
$('<button class="ui-button ui-corner-all ui-widget" style="margin-top:.6em;">Fill all gaps ('+ data.missing.length +' season'+ (data.missing.length > 1 ? 's' : '') +')</button>')
.on('click', function(){ tbpFillGaps(title); })
.appendTo('#tbp_proxy_search_dialog_body');
}else{
$('#tbp_proxy_search_dialog_body').append('<p style="color:#2a2">✓ You already have every season.</p>');
}
}
function tbpFillGaps(title){
tbpSearchBody('<p>Finding the best packs for the missing seasons of <b>'+ tbpEsc(title.title) +'</b>…<br/>this can take a bit.</p>');
app.api.post('search/fill', { tmdbId: title.tmdbId }, function(error, data){
if(error || !data){ tbpSearchBody('<p>Fill failed. Please try again.</p>'); return; }
var html = '<h3>'+ tbpEsc(data.title) +'</h3>';
(data.queued || []).forEach(function(q){ html += '<p style="color:#2a2">✓ Queued Season '+ q.season +': '+ tbpEsc(q.name) +'</p>'; });
(data.skipped || []).forEach(function(s){ html += '<p style="color:#b00">✗ Season '+ s.season +': '+ tbpEsc(s.reason) +'</p>'; });
if(!(data.queued || []).length && !(data.skipped || []).length) html += '<p>Nothing to fill.</p>';
tbpSearchBody(html);
});
}
function tbpAddRelease(o){
$.scope.torrentAdd.update({
magnetLink: o.magnetLink,
+34
View File
@@ -2,6 +2,7 @@
const router = require('express').Router();
const search = require('>/controller/search');
const { Torrent } = require('>/models');
// Step 1: fuzzy query -> a few TMDB title candidates( with posters) to confirm.
router.get('/title', async function(req, res, next){
@@ -21,4 +22,37 @@ router.post('/releases', async function(req, res, next){
}
});
// TV: which seasons you have vs are missing (Emby inventory vs TMDB counts).
router.get('/seasons', async function(req, res, next){
try{
res.json(await search.seasonPlan(req.query.tmdbId));
}catch(error){
next(error);
}
});
// TV: queue the best complete-season pack for every missing/incomplete season.
router.post('/fill', async function(req, res, next){
try{
let plan = await search.fillMissing(req.body.tmdbId);
let queued = [];
for(let pick of plan.picks){
try{
await Torrent.create({
magnetLink: pick.magnetLink,
isPrivate: false,
added_by: req.user.username,
category: pick.category,
});
queued.push({ season: pick.season, name: pick.name });
}catch(error){
plan.skipped.push({ season: pick.season, reason: error.message });
}
}
res.json({ title: plan.title, year: plan.year, queued, skipped: plan.skipped });
}catch(error){
next(error);
}
});
module.exports = router;