Make Smart Search library-aware (Emby dedup + TV gap-fill)

New controller/library.js queries Emby by TMDB provider id (resilient: an
unreachable Emby degrades to "unknown" so search still works).

Movies — quality-aware dedup:
- findReleases checks movieInLibrary(tmdbId) and flags each option alreadyOwned
  when its resolution is <= what you already own, so only genuine upgrades are
  offered; the release picker greys owned qualities and shows a library banner.

TV — library-driven "fill the gaps":
- GET /__api/search/seasons compares Emby's episode inventory against TMDB's
  per-season episode counts to mark each season complete/partial/missing.
- POST /__api/search/fill picks the best complete-season pack (LLM) for every
  missing/incomplete season and queues them. Front-end routes TV titles to a
  season plan + "Fill all gaps" instead of the movie release list.

Adds tmdb.tmdbSeasons; shared tmdb module import in search.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 19:11:30 -04:00
parent fd5ef14999
commit 27a6656ac5
5 changed files with 241 additions and 4 deletions
+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,