Add per-episode fallback for incomplete TV seasons

When a complete-season pack cannot be found for a missing/partial season,

fillMissing now searches each missing SxxExx individually and queues the

best episode torrent per gap. Also exposes episode-level detail in the

/fill response and season plan.

Signed-off-by: William Mantly <wmantly@gmail.com>
This commit is contained in:
2026-07-20 14:35:08 -04:00
parent ce0e0bdd4f
commit 803a6db033
3 changed files with 77 additions and 23 deletions
+9 -8
View File
@@ -44,25 +44,26 @@ async function movieInLibrary(tmdbId){
}
}
// Which episodes of a series are already present, grouped by season( season -> count).
// Which episodes of a series are already present, grouped by season.
// Returns { owned, seriesId, have: { season: Set(episode) }, counts: { season: n } }.
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: {} };
if(!series) return { owned: false, have: {}, counts: {} };
let ep = await embyGet(`/Shows/${series.Id}/Episodes`);
let sets = {};
let have = {};
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);
(have[sn] = have[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 };
let counts = {};
for(let sn of Object.keys(have)) counts[sn] = have[sn].size;
return { owned: true, seriesId: series.Id, have, counts };
}catch(error){
return { owned: false, have: {}, unknown: true };
return { owned: false, have: {}, counts: {}, unknown: true };
}
}
+66 -13
View File
@@ -286,15 +286,41 @@ async function bestSeasonPack(candidates, meta){
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 };
let haveSet = inv.have[s.season] || new Set();
let haveCount = haveSet.size;
let status = haveCount === 0 ? 'missing' : (haveCount >= s.episodeCount ? 'complete' : 'partial');
let missingEps = [];
if(status === 'partial'){
for(let e = 1; e <= s.episodeCount; e++){
if(!haveSet.has(e)) missingEps.push(e);
}
}
return { season: s.season, episodeCount: s.episodeCount, haveCount, status, missingEpisodes: missingEps };
});
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.
// Best single episode torrent for a specific SxxExx.
async function bestEpisodeTorrent(candidates, meta){
let compact = candidates.map(t => ({
name: t.name,
sizeHuman: humanSize(t.size),
seeders: Number(t.seeders),
info_hash: t.info_hash,
}));
let prompt = [
`You are a torrent selector. Pick the single best torrent for "${meta.title}"`,
`Season ${meta.season} Episode ${meta.episode}.`,
`It MUST be this exact episode (S${String(meta.season).padStart(2, '0')}E${String(meta.episode).padStart(2, '0')}), not a pack or a different episode.`,
`Prefer HEVC/x265, 1080p, English audio, reasonable size. Reject 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(' ');
let parsed = await ollama.chatJSON(prompt, JSON.stringify(compact));
return parsed && parsed.info_hash ? parsed : null;
}
// For every missing/incomplete season, first try a complete-season pack. If that fails,
// fall back to searching each missing individual episode and queue them separately.
async function fillMissing(tmdbId){
let plan = await seasonPlan(tmdbId);
let details = await tmdbDetails(tmdbId, 'tv');
@@ -302,16 +328,43 @@ async function fillMissing(tmdbId){
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; }
for(let seasonInfo of plan.seasons.filter(s => s.status !== 'complete')){
let season = seasonInfo.season;
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; }
// 1) Try a complete season pack first.
let seasonCands = prefilter(await tpbSearch(`${title} S${String(season).padStart(2, '0')}`), null);
if(seasonCands.length){
let pick = null;
try{ pick = await bestSeasonPack(seasonCands, { title, season, today }); }catch(error){ pick = null; }
let src = pick && seasonCands.find(c => c.info_hash.toLowerCase() === String(pick.info_hash).toLowerCase());
if(src){
picks.push({ type: 'season', season, name: src.name, info_hash: src.info_hash, category: Number(src.category), magnetLink: buildMagnet(src.info_hash, src.name), why: pick.why });
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 });
// 2) No pack. If we know exactly which episodes are missing, grab them one by one.
let missingEps = seasonInfo.missingEpisodes.length ? seasonInfo.missingEpisodes : [];
if(!missingEps.length){
// Fallback: episode-by-episode for the whole season when Emby inventory is unavailable.
for(let e = 1; e <= seasonInfo.episodeCount; e++) missingEps.push(e);
}
let seasonQueued = 0;
for(let episode of missingEps){
let seLabel = `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`;
let cands = prefilter(await tpbSearch(`${title} ${seLabel}`), null);
if(!cands.length){ skipped.push({ season, episode, reason: 'no results' }); continue; }
let pick = null;
try{ pick = await bestEpisodeTorrent(cands, { title, season, episode }); }catch(error){ pick = null; }
let src = pick && cands.find(c => c.info_hash.toLowerCase() === String(pick.info_hash).toLowerCase());
if(!src){ skipped.push({ season, episode, reason: 'no suitable episode torrent' }); continue; }
picks.push({ type: 'episode', season, episode, name: src.name, info_hash: src.info_hash, category: Number(src.category), magnetLink: buildMagnet(src.info_hash, src.name), why: pick.why });
seasonQueued++;
}
if(!seasonQueued) skipped.push({ season, reason: 'no suitable season pack or episodes' });
}
return { title, year: plan.year, picks, skipped };
}
+2 -2
View File
@@ -78,9 +78,9 @@ router.post('/fill', async function(req, res, next){
added_by: req.user.username,
category: pick.category,
});
queued.push({ season: pick.season, name: pick.name });
queued.push({ type: pick.type, season: pick.season, episode: pick.episode, name: pick.name });
}catch(error){
plan.skipped.push({ season: pick.season, reason: error.message });
plan.skipped.push({ season: pick.season, episode: pick.episode, reason: error.message });
}
}
res.json({ title: plan.title, year: plan.year, queued, skipped: plan.skipped });