diff --git a/conf/base.js b/conf/base.js index 647ffde..c17e8f6 100644 --- a/conf/base.js +++ b/conf/base.js @@ -58,5 +58,8 @@ module.exports = { organize: { includePrivate: false, interval: 10000, + }, + wanted: { + interval: 21600000, // re-check the wishlist every 6h } }; diff --git a/controller/index.js b/controller/index.js index c8b4879..85fac82 100644 --- a/controller/index.js +++ b/controller/index.js @@ -5,4 +5,5 @@ module.exports = { pubsub: require('./pubsub'), torrent: require('./torrent'), organizeWatcher: require('./organizeWatcher'), + wantedWatcher: require('./wantedWatcher'), } \ No newline at end of file diff --git a/controller/search.js b/controller/search.js index edd8b0d..3e98f2a 100644 --- a/controller/search.js +++ b/controller/search.js @@ -259,7 +259,7 @@ async function findReleases(tmdbId, mediaType){ } if(!candidates.length){ - return { title: meta.title, year: meta.year, options: [] }; + return { title: meta.title, year: meta.year, tmdbId, mediaType, options: [] }; } let today = new Date().toISOString().slice(0, 10); @@ -274,7 +274,7 @@ async function findReleases(tmdbId, mediaType){ options = decorateOptions(options, candidates); if(!options.length) options = decorateOptions(fallbackReleases(candidates), candidates); - let result = { title: meta.title, year: meta.year, options }; + let result = { title: meta.title, year: meta.year, tmdbId, mediaType, options }; // Quality-aware dedup: flag options you already own at equal-or-better quality. if(mediaType !== 'tv'){ diff --git a/controller/wantedWatcher.js b/controller/wantedWatcher.js new file mode 100644 index 0000000..80d9175 --- /dev/null +++ b/controller/wantedWatcher.js @@ -0,0 +1,47 @@ +'use strict'; + +const conf = require('>/conf'); +const search = require('>/controller/search'); + +let lock = false; + +// Periodically re-run Smart Search for wishlisted movies; when a good copy finally +// appears, queue it( which then flows through the normal download + organize pipeline). +async function tick(){ + if(lock) return; + lock = true; + try{ + const { Wanted, Torrent } = require('>/models'); + + let wanted = await Wanted.findAll({ where: { status: 'wanted' } }); + for(let w of wanted){ + if(!w.requestedBy) continue; + try{ + let result = await search.findReleases(w.tmdbId, w.mediaType); + let pick = result.options.find(o => o.role === 'recommended' && !o.alreadyOwned) + || result.options.find(o => !o.alreadyOwned); + if(!pick) continue; + + await Torrent.create({ + magnetLink: pick.magnetLink, + isPrivate: false, + added_by: w.requestedBy, + category: pick.category, + }); + w.status = 'fulfilled'; + await w.save(); + }catch(error){ + // leave it wanted; try again next tick + } + } + }catch(error){ + // DB/Transmission transient — retry next tick + }finally{ + lock = false; + } +} + +setInterval(tick, conf.wanted.interval || 6 * 60 * 60 * 1000); +tick(); // check once on boot + +module.exports = { tick }; diff --git a/models/index.js b/models/index.js index a3f7d28..9651a2b 100644 --- a/models/index.js +++ b/models/index.js @@ -3,5 +3,6 @@ module.exports = { User: require('./ldap/user').User, AuthToken: require('./sql').AuthToken, - Torrent: require('./sql').Torrent + Torrent: require('./sql').Torrent, + Wanted: require('./sql').Wanted }; diff --git a/models/sql/migrations/20260707120000-create-wanted.js b/models/sql/migrations/20260707120000-create-wanted.js new file mode 100644 index 0000000..1392ddc --- /dev/null +++ b/models/sql/migrations/20260707120000-create-wanted.js @@ -0,0 +1,23 @@ +'use strict'; +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('Wanteds', { + tmdbId: { + type: Sequelize.STRING, + primaryKey: true, + allowNull: false, + }, + mediaType: { type: Sequelize.STRING, allowNull: false, defaultValue: 'movie' }, + title: { type: Sequelize.STRING }, + year: { type: Sequelize.STRING }, + status: { type: Sequelize.STRING, allowNull: false, defaultValue: 'wanted' }, + requestedBy: { type: Sequelize.STRING }, + createdAt: { allowNull: false, type: Sequelize.DATE }, + updatedAt: { allowNull: false, type: Sequelize.DATE }, + }); + }, + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('Wanteds'); + } +}; diff --git a/models/sql/wanted.js b/models/sql/wanted.js new file mode 100644 index 0000000..f7d7c72 --- /dev/null +++ b/models/sql/wanted.js @@ -0,0 +1,32 @@ +'use strict'; + +module.exports = (sequelize, DataTypes, Model) => { + class Wanted extends Model { + static associate(models) { + } + } + Wanted.init({ + tmdbId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + }, + mediaType: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'movie', + }, + title: DataTypes.STRING, + year: DataTypes.STRING, + status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'wanted', // wanted | fulfilled + }, + requestedBy: DataTypes.STRING, + }, { + sequelize, + modelName: 'Wanted', + }); + return Wanted; +}; diff --git a/public/partial/header.html b/public/partial/header.html index c63df04..0f892c5 100644 --- a/public/partial/header.html +++ b/public/partial/header.html @@ -396,7 +396,12 @@ // Step 3: render the release cards; a tap feeds the existing add dialog. function tbpRenderReleases(data){ - if(!data.options.length){ tbpSearchBody('

No good-quality copy of '+ tbpEsc(data.title) +' found — it may not be released yet, or only low-quality (CAM) copies exist.

'); return; } + if(!data.options.length){ + var msg = '

No good-quality copy of '+ tbpEsc(data.title) +' found — it may not be released yet, or only low-quality (CAM) copies exist.

'; + if(data.remembered) msg += '

✓ Added to your wishlist — we\'ll grab it automatically when a good copy appears.

'; + tbpSearchBody(msg); + return; + } 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); diff --git a/routes/search.js b/routes/search.js index 71c3841..83f36c3 100644 --- a/routes/search.js +++ b/routes/search.js @@ -2,7 +2,7 @@ const router = require('express').Router(); const search = require('>/controller/search'); -const { Torrent } = require('>/models'); +const { Torrent, Wanted } = require('>/models'); // Step 1: fuzzy query -> a few TMDB title candidates( with posters) to confirm. router.get('/title', async function(req, res, next){ @@ -16,7 +16,41 @@ router.get('/title', async function(req, res, next){ // Step 2: confirmed title -> TPB search + LLM-curated release options. router.post('/releases', async function(req, res, next){ try{ - res.json(await search.findReleases(req.body.tmdbId, req.body.mediaType)); + let result = await search.findReleases(req.body.tmdbId, req.body.mediaType); + + // Nothing good yet( e.g. unreleased) -> remember it and grab it later. + if(!result.options.length && result.mediaType !== 'tv' && result.tmdbId){ + await Wanted.upsert({ + tmdbId: String(result.tmdbId), + mediaType: result.mediaType, + title: result.title, + year: result.year, + status: 'wanted', + requestedBy: req.user.username, + }); + result.remembered = true; + } + + res.json(result); + }catch(error){ + next(error); + } +}); + +// Wishlist: things we couldn't find yet and are watching for. +router.get('/wanted', async function(req, res, next){ + try{ + res.json(await Wanted.findAll({ order: [['createdAt', 'DESC']] })); + }catch(error){ + next(error); + } +}); + +router.delete('/wanted/:tmdbId', async function(req, res, next){ + try{ + let wanted = await Wanted.findByPk(req.params.tmdbId); + if(wanted) await wanted.destroy(); + res.json({ deleted: true }); }catch(error){ next(error); }