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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+64
-11
@@ -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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 bestSeasonPack(cands, { title, season, today }); }catch(error){ 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, reason: 'no suitable season pack' }); continue; }
|
||||
if(!src){ skipped.push({ season, episode, reason: 'no suitable episode torrent' }); 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 });
|
||||
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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user