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){
|
async function seriesInventory(tmdbId){
|
||||||
try{
|
try{
|
||||||
let s = await embyGet(`/Items?Recursive=true&IncludeItemTypes=Series&AnyProviderIdEquals=tmdb.${tmdbId}&Fields=ProviderIds`);
|
let s = await embyGet(`/Items?Recursive=true&IncludeItemTypes=Series&AnyProviderIdEquals=tmdb.${tmdbId}&Fields=ProviderIds`);
|
||||||
let series = (s.Items || [])[0];
|
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 ep = await embyGet(`/Shows/${series.Id}/Episodes`);
|
||||||
let sets = {};
|
let have = {};
|
||||||
for(let e of ep.Items || []){
|
for(let e of ep.Items || []){
|
||||||
let sn = e.ParentIndexNumber, en = e.IndexNumber;
|
let sn = e.ParentIndexNumber, en = e.IndexNumber;
|
||||||
if(sn == null || en == null || sn === 0) continue; // skip specials
|
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 = {};
|
let counts = {};
|
||||||
for(let sn of Object.keys(sets)) have[sn] = sets[sn].size;
|
for(let sn of Object.keys(have)) counts[sn] = have[sn].size;
|
||||||
return { owned: true, seriesId: series.Id, have };
|
return { owned: true, seriesId: series.Id, have, counts };
|
||||||
}catch(error){
|
}catch(error){
|
||||||
return { owned: false, have: {}, unknown: true };
|
return { owned: false, have: {}, counts: {}, unknown: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+66
-13
@@ -286,15 +286,41 @@ async function bestSeasonPack(candidates, meta){
|
|||||||
async function seasonPlan(tmdbId){
|
async function seasonPlan(tmdbId){
|
||||||
let [tv, inv] = await Promise.all([ tmdb.tmdbSeasons(tmdbId), library.seriesInventory(tmdbId) ]);
|
let [tv, inv] = await Promise.all([ tmdb.tmdbSeasons(tmdbId), library.seriesInventory(tmdbId) ]);
|
||||||
let seasons = tv.seasons.map(s => {
|
let seasons = tv.seasons.map(s => {
|
||||||
let have = Number(inv.have && inv.have[s.season]) || 0;
|
let haveSet = inv.have[s.season] || new Set();
|
||||||
let status = have === 0 ? 'missing' : (have >= s.episodeCount ? 'complete' : 'partial');
|
let haveCount = haveSet.size;
|
||||||
return { season: s.season, episodeCount: s.episodeCount, haveCount: have, status };
|
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) };
|
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
|
// Best single episode torrent for a specific SxxExx.
|
||||||
// chosen packs( the route adds them to Transmission) plus any seasons it couldn't fill.
|
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){
|
async function fillMissing(tmdbId){
|
||||||
let plan = await seasonPlan(tmdbId);
|
let plan = await seasonPlan(tmdbId);
|
||||||
let details = await tmdbDetails(tmdbId, 'tv');
|
let details = await tmdbDetails(tmdbId, 'tv');
|
||||||
@@ -302,16 +328,43 @@ async function fillMissing(tmdbId){
|
|||||||
let today = new Date().toISOString().slice(0, 10);
|
let today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
let picks = [], skipped = [];
|
let picks = [], skipped = [];
|
||||||
for(let season of plan.missing){
|
for(let seasonInfo of plan.seasons.filter(s => s.status !== 'complete')){
|
||||||
let cands = prefilter(await tpbSearch(`${title} S${String(season).padStart(2, '0')}`), null);
|
let season = seasonInfo.season;
|
||||||
if(!cands.length){ skipped.push({ season, reason: 'no results' }); continue; }
|
|
||||||
|
|
||||||
let pick = null;
|
// 1) Try a complete season pack first.
|
||||||
try{ pick = await bestSeasonPack(cands, { title, season, today }); }catch(error){ pick = null; }
|
let seasonCands = prefilter(await tpbSearch(`${title} S${String(season).padStart(2, '0')}`), null);
|
||||||
let src = pick && cands.find(c => c.info_hash.toLowerCase() === String(pick.info_hash).toLowerCase());
|
if(seasonCands.length){
|
||||||
if(!src){ skipped.push({ season, reason: 'no suitable season pack' }); continue; }
|
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 };
|
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,
|
added_by: req.user.username,
|
||||||
category: pick.category,
|
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){
|
}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 });
|
res.json({ title: plan.title, year: plan.year, queued, skipped: plan.skipped });
|
||||||
|
|||||||
Reference in New Issue
Block a user