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:
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user