diff --git a/controller/library.js b/controller/library.js new file mode 100644 index 0000000..db56fdb --- /dev/null +++ b/controller/library.js @@ -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 }; diff --git a/controller/search.js b/controller/search.js index f4642cf..9d009fe 100644 --- a/controller/search.js +++ b/controller/search.js @@ -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":"","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, diff --git a/controller/tmdb.js b/controller/tmdb.js index 64f5e5c..d55953d 100644 --- a/controller/tmdb.js +++ b/controller/tmdb.js @@ -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 }; diff --git a/public/partial/header.html b/public/partial/header.html index a895af8..9cad9ae 100644 --- a/public/partial/header.html +++ b/public/partial/header.html @@ -386,6 +386,7 @@ // Step 2: ask the server( TPB + LLM) for the curated release options. function tbpFindReleases(title){ + if(title.mediaType === 'tv') return tbpSeasonPlan(title); tbpSearchBody('

Finding the best copies for '+ tbpEsc(title.title) +'
this can take a few seconds.

'); app.api.post('search/releases', { tmdbId: title.tmdbId, mediaType: title.mediaType }, function(error, data){ if(error || !data || !data.options){ tbpSearchBody('

Search failed. Please try again.

'); return; } @@ -396,8 +397,17 @@ // Step 3: render the release cards; a tap feeds the existing add dialog. function tbpRenderReleases(data){ if(!data.options.length){ tbpSearchBody('

No good torrents found for '+ tbpEsc(data.title) +'.

'); return; } - tbpSearchBody('

'+ tbpEsc(data.title +' ('+ (data.year || '?') +')') +'

'); + var header = '

'+ tbpEsc(data.title +' ('+ (data.year || '?') +')') +'

'; + if(data.library && data.library.owned) header += '

✓ Already in your library ('+ tbpEsc(data.library.quality) +') — only upgrades are offered.

'; + tbpSearchBody(header); data.options.forEach(function(o){ + if(o.alreadyOwned){ + $('
') + .html(''+ tbpEsc(o.label || o.role) +' — you already have this quality or better' + + '
'+ tbpEsc(o.name) +'
') + .appendTo('#tbp_proxy_search_dialog_body'); + return; + } var warn = o.warning ? '
⚠ '+ tbpEsc(o.warning) +'
' : ''; var why = o.why ? '
'+ tbpEsc(o.why) +'
' : ''; $('
') @@ -409,6 +419,46 @@ } // Reuse the existing add flow: fill the torrentAdd scope and open the add dialog. + function tbpSeasonPlan(title){ + tbpSearchBody('

Checking your library for '+ tbpEsc(title.title) +'

'); + app.api.get('search/seasons?tmdbId='+ encodeURIComponent(title.tmdbId), function(error, data){ + if(error || !data || !data.seasons){ tbpSearchBody('

Could not load seasons.

'); return; } + tbpRenderSeasons(title, data); + }); + } + + function tbpRenderSeasons(title, data){ + tbpSearchBody('

'+ tbpEsc(data.title +' ('+ (data.year || '?') +')') +'

'); + data.seasons.forEach(function(s){ + var badge, color; + if(s.status === 'complete'){ badge = '✓ complete'; color = '#2a2'; } + else if(s.status === 'partial'){ badge = '⚠ '+ s.haveCount +'/'+ s.episodeCount; color = '#b80'; } + else { badge = '✗ missing'; color = '#b00'; } + $('
') + .html('Season '+ s.season +' '+ badge +'') + .appendTo('#tbp_proxy_search_dialog_body'); + }); + if(data.missing && data.missing.length){ + $('') + .on('click', function(){ tbpFillGaps(title); }) + .appendTo('#tbp_proxy_search_dialog_body'); + }else{ + $('#tbp_proxy_search_dialog_body').append('

✓ You already have every season.

'); + } + } + + function tbpFillGaps(title){ + tbpSearchBody('

Finding the best packs for the missing seasons of '+ tbpEsc(title.title) +'
this can take a bit.

'); + app.api.post('search/fill', { tmdbId: title.tmdbId }, function(error, data){ + if(error || !data){ tbpSearchBody('

Fill failed. Please try again.

'); return; } + var html = '

'+ tbpEsc(data.title) +'

'; + (data.queued || []).forEach(function(q){ html += '

✓ Queued Season '+ q.season +': '+ tbpEsc(q.name) +'

'; }); + (data.skipped || []).forEach(function(s){ html += '

✗ Season '+ s.season +': '+ tbpEsc(s.reason) +'

'; }); + if(!(data.queued || []).length && !(data.skipped || []).length) html += '

Nothing to fill.

'; + tbpSearchBody(html); + }); + } + function tbpAddRelease(o){ $.scope.torrentAdd.update({ magnetLink: o.magnetLink, diff --git a/routes/search.js b/routes/search.js index ca478e6..71c3841 100644 --- a/routes/search.js +++ b/routes/search.js @@ -2,6 +2,7 @@ const router = require('express').Router(); const search = require('>/controller/search'); +const { Torrent } = require('>/models'); // Step 1: fuzzy query -> a few TMDB title candidates( with posters) to confirm. router.get('/title', async function(req, res, next){ @@ -21,4 +22,37 @@ router.post('/releases', async function(req, res, next){ } }); +// TV: which seasons you have vs are missing (Emby inventory vs TMDB counts). +router.get('/seasons', async function(req, res, next){ + try{ + res.json(await search.seasonPlan(req.query.tmdbId)); + }catch(error){ + next(error); + } +}); + +// TV: queue the best complete-season pack for every missing/incomplete season. +router.post('/fill', async function(req, res, next){ + try{ + let plan = await search.fillMissing(req.body.tmdbId); + let queued = []; + for(let pick of plan.picks){ + try{ + await Torrent.create({ + magnetLink: pick.magnetLink, + isPrivate: false, + added_by: req.user.username, + category: pick.category, + }); + queued.push({ season: pick.season, name: pick.name }); + }catch(error){ + plan.skipped.push({ season: pick.season, reason: error.message }); + } + } + res.json({ title: plan.title, year: plan.year, queued, skipped: plan.skipped }); + }catch(error){ + next(error); + } +}); + module.exports = router;