diff --git a/controller/library.js b/controller/library.js index db56fdb..cd45dd3 100644 --- a/controller/library.js +++ b/controller/library.js @@ -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 }; } } diff --git a/controller/search.js b/controller/search.js index 7bc981e..89e1e79 100644 --- a/controller/search.js +++ b/controller/search.js @@ -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":"","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 }; } diff --git a/routes/search.js b/routes/search.js index 83f36c3..5f2287a 100644 --- a/routes/search.js +++ b/routes/search.js @@ -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 });