33 Commits

Author SHA1 Message Date
wmantly 8abc2a65e1 Validate torrent after hashString is resolved
The static Torrent.create override was validating the input data before

calling Transmission, but hashString isn't known until after addUrl.

Move validation to the final createData object so search/fill can queue

torrents without providing a hash up front.

Signed-off-by: William Mantly <wmantly@gmail.com>
2026-07-20 15:10:58 -04:00
wmantly a66982a00d Fallback to magnet info-hash when Transmission omits hashString
Transmission addUrl sometimes returns a response without a hashString

(e.g. duplicate or malformed response). Extract the info hash from the

magnet link as a fallback, and log the response if neither works.

Signed-off-by: William Mantly <wmantly@gmail.com>
2026-07-20 14:57:19 -04:00
wmantly 51e6817393 Add exponential backoff retry to all downstream calls
New controller/retry.js wraps async calls with infinite retries, capped

at 30s with jitter. Applied to: TPB search, TMDB, Emby, Ollama chat,

Transmission RPC, and the status/status interval calls inherit it via

a Proxy around the Transmission client.

Also handles ollama package returning pre-parsed JSON objects.

Signed-off-by: William Mantly <wmantly@gmail.com>
2026-07-20 14:44:41 -04:00
wmantly 803a6db033 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>
2026-07-20 14:35:08 -04:00
wmantly ce0e0bdd4f Point Ollama to local host 192.168.1.148:11434
Signed-off-by: William Mantly <wmantly@gmail.com>
2026-07-20 12:49:37 -04:00
wmantly bd9f058f26 Use official ollama npm client for all LLM calls
Replaces hand-rolled fetch to /api/chat with the ollama@0.6.3 package.

The package supplies proper Accept/Content-Type/User-Agent headers and

auto-handles the Ollama protocol, fixing the CDN 403s Node's bare fetch

gets against ollama.com during filing.

Both organize and search LLM ranking now route through controller/ollama.js.

Signed-off-by: William Mantly <wmantly@gmail.com>
2026-07-20 12:43:25 -04:00
wmantly 47a930eddb Add explicit User-Agent to Ollama API calls
Node's default fetch sends a bare User-Agent that Google's CDN

fronting ollama.com rejects with 403. curl worked because it

sends curl/. Fixes organize (and search LLM ranking).

Signed-off-by: William Mantly <wmantly@gmail.com>
2026-07-20 12:29:09 -04:00
wmantly 40a39b2d14 Add Ollama/organize logging to debug 403 filing failures
- Log raw Ollama HTTP body on non-2xx responses

- Log JSON extraction failures with raw content

- Log organizeWatcher tick errors instead of swallowing them

Signed-off-by: William Mantly <wmantly@gmail.com>
2026-07-20 11:55:41 -04:00
wmantly e9a376925d jq-repeat update 2026-07-19 00:25:04 -04:00
wmantly b166403309 Remember unavailable movies and auto-grab them later (wishlist)
When Smart Search finds no good copy of a movie (unreleased / only CAMs), we
now record it instead of dropping it:
- New Wanted model + migration (dedup by tmdbId, status wanted|fulfilled).
- POST /__api/search/releases remembers a movie when it returns no options,
  and the UI confirms it was added to the wishlist.
- controller/wantedWatcher re-runs Smart Search for wishlisted items every 6h
  (and on boot); when a good copy appears it queues the recommended release,
  which then flows through the normal download + organize pipeline, and marks
  the item fulfilled.
- GET/DELETE /__api/search/wanted to view/remove the wishlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:51:48 -04:00
wmantly 2046e5080b Stop Smart Search recommending the wrong movie / CAMs
TPB search is fuzzy: querying "Toy Story 5" returns Toy Story 4/3/… plus the
only real 2026 copies, which are CAM/Telesync. The LLM rejected them all, then
fallbackReleases picked the most-seeded (Toy Story 4), ignoring title/year.

Two deterministic guards:
- prefilter now drops low-quality source tags (CAM/TS/TELESYNC/SCR/…) via a
  token match, so neither the LLM nor the fallback can surface them.
- findReleases requires the confirmed TMDB year in a movie's release name, so a
  fuzzy match can't cross to a different film. If nothing survives, return no
  options and tell the user it may be unreleased / only CAMs exist.

Verified: "Toy Story 5" now returns 0 options (was Toy Story 4); Night of the
Living Dead (1990) still recommends its 1080p x265 pack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:34:24 -04:00
wmantly 27a6656ac5 Make Smart Search library-aware (Emby dedup + TV gap-fill)
New controller/library.js queries Emby by TMDB provider id (resilient: an
unreachable Emby degrades to "unknown" so search still works).

Movies — quality-aware dedup:
- findReleases checks movieInLibrary(tmdbId) and flags each option alreadyOwned
  when its resolution is <= what you already own, so only genuine upgrades are
  offered; the release picker greys owned qualities and shows a library banner.

TV — library-driven "fill the gaps":
- GET /__api/search/seasons compares Emby's episode inventory against TMDB's
  per-season episode counts to mark each season complete/partial/missing.
- POST /__api/search/fill picks the best complete-season pack (LLM) for every
  missing/incomplete season and queues them. Front-end routes TV titles to a
  season plan + "Fill all gaps" instead of the movie release list.

Adds tmdb.tmdbSeasons; shared tmdb module import in search.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:11:30 -04:00
wmantly fd5ef14999 Harden auth, add AI smart-search and post-download organization
Security & correctness hardening:
- Gate /__api/token/auth behind auth and self-scope every handler to the
  caller (was fully unauthenticated — account-takeover hole).
- Escape LDAP filter values (injection) and reject empty-password binds.
- Enforce per-torrent ownership so private torrents aren't exposed via IDOR.
- Assorted cleanup: fix 'use static' typos, drop dead Torrent.migrate + the
  getTorrentData noUpdate flag, Buffer.alloc, __dirname-relative reads,
  res.statusCode in the error handler, const-scope pubsub.

Login-gated proxy + anti-indexing:
- Block all proxying for logged-out users via an auth-token cookie the front
  end mirrors from its token; serve a local login page instead of hitting TPB.
- robots.txt disallow-all + X-Robots-Tag noindex.

Torrent category:
- Store a normalized category (TV/Movie/Music/Adult/App/Game/Other) mapped
  from the TPB category id; captured at add time (migration).

Smart Search (movies/TV):
- New /__api/search: TMDB title confirm -> scrape piratebay.party HTML ->
  Ollama ranks releases against quality prefs (x265/1080p/~1.5GB/subs,
  prefer uncut) returning a recommended pick, optional warned 4K, and other
  editions. Front-end Smart Search box + dialog feeding the existing add flow.

Post-download organization -> Emby (public Movie/TV only):
- Completion watcher files finished torrents: Ollama parses the release name,
  TMDB canonicalizes title/year, files main video (+subs) into the library
  with edition/quality-aware names (movies + TV SxxExx), stops seeding, and
  triggers an Emby library scan. Low-confidence matches are flagged, not
  mis-filed; correctable via "Fix match". Adds organizedAt/metadata columns.

Shared helpers: controller/tmdb.js, controller/ollama.js. Config blocks for
tmdb/ollama/search/emby/library/organize (secrets stay in gitignored secrets.js).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:19:13 -04:00
wmantly b30fe748b2 Merge pull request 'private' (#10) from private into master
Reviewed-on: http://git.theta42.com/718itbiz/tpbproxy/pulls/10
2025-11-17 18:35:56 +00:00
wmantly 331298825e Merge branch 'master' of ssh://git.theta42.com:2222/718itbiz/tpbproxy into private 2025-11-17 13:34:58 -05:00
wmantly 4719117504 Private session 2025-11-17 13:34:35 -05:00
wmantly c0c33b8859 Merge pull request 'Private download' (#9) from private into master
Reviewed-on: http://git.theta42.com/718itbiz/tpbproxy/pulls/9
2025-11-16 22:44:13 +00:00
wmantly 1922cf5b87 Private download 2025-11-16 17:43:35 -05:00
wmantly fa5b7a3249 Merge pull request 'proxy fixes' (#8) from cache-fix into master
Reviewed-on: #8
2025-02-22 21:50:33 +00:00
wmantly 60f65152cb proxy fixes 2025-02-22 16:49:26 -05:00
wmantly d34b74e505 Merge pull request 'bind issue fixed' (#7) from cache-fix into master
Reviewed-on: #7
2025-02-22 21:47:31 +00:00
wmantly 600173c959 bind issue fixed 2025-02-22 16:46:48 -05:00
wmantly 49fd73aaf9 Merge pull request 'Stopped completed torrents for doing GET for updated data' (#5) from sql into master
Reviewed-on: #5
2024-01-07 06:47:20 +00:00
wmantly ffa0f07daf Stopped completed torrents for doing GET for updated data 2024-01-07 01:46:28 -05:00
wmantly dc9f73961e Merge pull request 'sql' (#4) from sql into master
Reviewed-on: #4
2024-01-07 06:39:14 +00:00
wmantly bbcd955f28 Fixed issue where migrate deletes auth token 2024-01-07 01:37:43 -05:00
wmantly c6cc529d54 completed torrents no longer ask transmission for update 2024-01-07 01:37:19 -05:00
wmantly a4094cb795 Merge pull request 'SSO login' (#3) from sql into master
Reviewed-on: #3
2024-01-07 05:35:35 +00:00
wmantly 1c7e2e794e works 2024-01-07 00:30:53 -05:00
wmantly 7fad640cca more... 2024-01-06 15:10:39 -05:00
wmantly abc547c642 lots... 2024-01-05 22:06:34 -05:00
wmantly c8f00cdeaf merged 2022-01-01 17:07:30 -05:00
root fb08554aa7 ? 2020-05-15 22:42:38 +00:00
49 changed files with 9642 additions and 1712 deletions
+5
View File
@@ -76,3 +76,8 @@ typings/
# FuseBox cache
.fusebox/
# keys
secrets.js
secrets.json
*.sqlite
+8
View File
@@ -0,0 +1,8 @@
const path = require('path');
module.exports = {
'config': path.resolve(__dirname, 'models/sql/config/config.js'),
'models-path': path.resolve(__dirname, 'models/sql'),
'seeders-path': path.resolve(__dirname, 'models/sql/seeders'),
'migrations-path': path.resolve(__dirname, 'models/sql/migrations')
};
+103 -101
View File
@@ -1,116 +1,118 @@
const zlib = require('zlib');
const fs = require('fs');
var https = require('https');
'use strict';
const Module = require('module');
const original_resolveFilename = Module._resolveFilename;
Module._resolveFilename = function(...args){
args[0] = args[0].startsWith('>') ? args[0].replace('>', __dirname) : args[0];
return original_resolveFilename(...args);
};
const path = require('path');
const ejs = require('ejs')
const express = require('express');
const proxy = require('http-proxy-middleware');
// Set up the express app.
const app = express();
// List of front end node modules to be served
const frontEndModules = [
'jquery', 'jquery-ui', 'moment', 'mustache', 'jq-repeat',
// 'bootstrap', '@fortawesome',
];
// Hold list of functions to run when the server is ready
app.onListen = [];
// Allow the express app to be exported into other files.
module.exports = app;
// Build the conf object from the conf files.
app.conf = require('./conf');
// Grab the projects PubSub
app.contollers = require('./controller');
// Push pubsub over the socket and back.
app.onListen.push(function(){
app.io.use(middleware.authIO);
app.contollers.pubsub.subscribe(/./g, function(data, topic){
app.io.emit('P2PSub', { topic, data });
});
app.io.on('connection', (socket) => {
// console.log('socket', socket)
var user = socket.user;
socket.on('P2PSub', (msg) => {
app.contollers.pubsub.publish(msg.topic, {...msg.data, __from:socket.user});
// socket.broadcast.emit('P2PSub', msg);
});
});
});
// Hold onto the auth middleware
const middleware = require('./middleware/auth');
// load the JSON parser middleware. Express will parse JSON into native objects
// for any request that has JSON in its content type.
app.use(express.json());
app.set('json spaces', 2);
const port = process.env.NODE_PORT || '3000';
// Set up the templating engine to build HTML for the front end.
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
const inject = fs.readFileSync('./inject.html', 'utf8');
// Have express server static content( images, CSS, browser JS) from the public
// local folder.
app.use('/__static', express.static(path.join(__dirname, 'public')))
const Transmission = require('transmission-promise')
var tr_client = new Transmission({
host:'tran.718it.biz',
ssl: true,
port: 443,
username: 'william',
password: 'palm7',
})
app.post("/__api/torrent", async function(req, res, next){
if(req.body.password !== '4412'){
return res.status(421);
}
try{
let cres = await tr_client.addUrl(Buffer.from(req.body.magnet, 'base64').toString('binary'));
res.json(cres)
}catch(e){
console.error('error', e)
res.status(500).json({message: e})
}
// Server front end modules
// https://stackoverflow.com/a/55700773/3140931
frontEndModules.forEach(dep => {
app.use(`/__static-modules/${dep}`, express.static(path.join(__dirname, `node_modules/${dep}`)))
});
app.get("/__api/torrent/:id", async function(req, res, next){
// API route
app.use('/__api', require('./routes/api'));
try{
let cres = await tr_client.get(Number(req.params.id), ["eta", "percentDone", "status", "rateDownload", "errorString", "hashString", 'name']);
res.json({id:req.params.id, cres})
// Routes for front end content.
app.use('/', require('./routes/proxy'));
}catch(e){
console.error('error', e)
res.status(500).json({message: e})
}
// Catch 404 and forward to error handler. If none of the above routes are
// used, this is what will be called.
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.message = 'Page not found'
err.status = 404;
next(err);
});
// Error handler. This is where `next()` will go on error
app.use(function(err, req, res, next) {
console.error(err.status || 500, err.name, req.method, req.url);
app.all("/*.js", function(req, res){res.send('')});
// Parse key error for Sequilzw
let keyErrors = {}
if(['SequelizeValidationError'].includes(err.name) && err.errors){
for(let item of err.errors){
if(item.path){
keyErrors[item.path] = item.message;
}
}
err.status = 422;
}
app.all("/*", proxy({
target: 'https://piratebay.party',
agent: https.globalAgent,
autoRewrite: true,
secure: false,
followRedirects: true,
autoRewrite: true,
changeOrigin: true,
secure: false,
followRedirects: true,
autoRewrite: true,
changeOrigin: true,
headers: {
host: 'piratebay.party'
},
selfHandleResponse: true, // so that the onProxyRes takes care of sending the response
onProxyRes: function(proxyRes, req, res){
if(proxyRes.statusCode === 403 && proxyRes.headers['content-type'] &&
proxyRes.headers['content-type'].match('html')
){
console.log('403')
var url = (req.protocol + '://' + req.get('host') + req.originalUrl);
proxyRes.headers['location'] = url.replace(/\??ckattempt\=\d+/, '');
proxyRes.statusCode == 307
return res.end()
}
for(let key of Object.keys(proxyRes.headers)){
if(['content-encoding'].includes(key)) continue;
// res.set(key, proxyRes.headers[key].toString().replace('http://', 'https://'))
}
let body = new Buffer('');
proxyRes.on('error', function(e){
console.error('ERROR!', e)
});
proxyRes.on('data', function(data){
body = Buffer.concat([body, data]);
});
proxyRes.on('end', function(){
body = proxyRes.headers['content-encoding'] === 'gzip' ? zlib.gunzipSync(body).toString('utf8') : body;
body = proxyRes.headers['content-encoding'] === 'br' ? zlib.brotliDecompressSync(body).toString('utf8') : body;
if(proxyRes.statusCode === 200 &&
proxyRes.headers['content-type'] &&
proxyRes.headers['content-type'].match('html')
){
body = body.toString().replace(/<\s*script[^]*?script>/igm, '');
body = body.replace(/piratebay\.party/ig, 'tpb.718it.biz')
body = body.replace(/<\s*iframe[^]*?iframe>/igm, '');
body = body.replace("</html>", '');
body = body+inject+"</html>";
}
res.status(proxyRes.statusCode).end(body);
});
}
}));
app.listen(port, () => console.log(`The Pirate Bay TOR proxy listening on ${port}!`))
if(![404, 422].includes(err.status || res.statusCode)){
console.error(err.message);
console.error(err.stack);
console.error('=========================================');
}
res.status(err.status || 500);
res.json({
name: err.name || 'Unknown error',
message: err.message,
keyErrors,
});
});
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('proxy-api:server');
var http = require('http');
const conf = require('../conf');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
var io = require('socket.io')(server);
app.io = io;
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
console.log('Listening on ' + bind);
for(let listener of app.onListen){
listener()
}
}
+65
View File
@@ -0,0 +1,65 @@
'use strict';
module.exports = {
privateDownloadLocation: '/media/torrents-zfs',
ldap: {
url: 'ldap://10.1.0.55:389',
bindDN: 'cn=ldapclient service,ou=people,dc=theta42,dc=com',
bindPassword: '__IN SRECREST FILE__',
userBase: 'ou=people,dc=theta42,dc=com',
groupBase: 'ou=groups,dc=theta42,dc=com',
userFilter: '(objectClass=posixAccount)',
userNameAttribute: 'uid'
},
sql: {
"storage": "database_test.sqlite",
"dialect": "sqlite",
logging: false,
},
transmission: {
host:'10.2.254.40',
ssl: false,
port: 9091,
username: 'william',
password: '__IN SRECREST FILE__',
statusUpdateInterval: 500,
},
tmdb: {
apiKey: '__IN SRECREST FILE__',
},
ollama: {
url: 'http://192.168.1.148:11434',
apiKey: '__IN SRECREST FILE__',
model: 'gemma4:31b-cloud',
},
search: {
// TPB HTML mirror we scrape search results from( same host the proxy targets).
// apibay's JSON q.php is Cloudflare-gated, so we parse the HTML listing instead.
tpbBase: 'https://piratebay.party',
trackers: [
'udp://tracker.opentrackr.org:1337/announce',
'udp://open.stealth.si:80/announce',
'udp://tracker.torrent.eu.org:451/announce',
'udp://tracker.openbittorrent.com:6969/announce',
'udp://tracker.coppersurfer.tk:6969/announce',
'udp://open.demonii.com:1337/announce',
'udp://exodus.desync.com:6969/announce',
],
},
emby: {
url: 'https://emby.718it.biz',
apiKey: '__IN SRECREST FILE__',
},
library: {
root: '/media/stuff',
movie: 'movies',
tv: 'tv',
},
organize: {
includePrivate: false,
interval: 10000,
},
wanted: {
interval: 21600000, // re-check the wishlist every 6h
}
};
+4
View File
@@ -0,0 +1,4 @@
'use strict';
module.exports = {
};
+32
View File
@@ -0,0 +1,32 @@
'use strict';
const extend = require('extend');
const environment = process.env.NODE_ENV || 'development';
function load(filePath, required){
try {
return require(filePath);
} catch(error){
if(error.name === 'SyntaxError'){
console.error(`Loading ${filePath} file failed!\n`, error);
process.exit(1);
} else if (error.code === 'MODULE_NOT_FOUND'){
console.warn(`No config file ${filePath} FOUND! This may cause issues...`);
if (required){
process.exit(1);
}
return {};
}else{
console.dir(`Unknown error in loading ${filePath} config file.\n`, error);
}
}
};
module.exports = extend(
true, // enable deep copy
load('./base', true),
load(`./${environment}`),
load('./secrets'),
{environment}
);
+56
View File
@@ -0,0 +1,56 @@
'use strict';
const {User, AuthToken} = require('>/models');
class Auth{
static errors = {
login: function(){
let error = new Error('LDAPLoginFailed');
error.name = 'LDAPLoginFailed';
error.message = `Invalid Credentials, login failed.`;
error.status = 401;
return error;
}
}
static async login(data){
try{
let user = await User.login(data);
let token = await AuthToken.create({username: user.username});
return {user, token}
}catch(error){
console.log('login error', error);
throw this.errors.login();
}
}
static async checkToken(token){
try{
token = await AuthToken.get(token);
if(token && token.check()) return token;
throw this.errors.login();
}catch(error){
throw this.errors.login();
}
}
static async logout(data){
let token = await AuthToken.get(data);
await token.destroy();
}
}
Auth.logOut = async function(data){
try{
}catch(error){
throw error;
}
}
module.exports = {Auth};
+14
View File
@@ -0,0 +1,14 @@
'use strict';
const conf = require('>/conf');
const { fetchWithRetry } = require('./retry');
// Ask Emby to (re)scan its libraries so a newly filed item gets indexed.
async function refresh(){
let res = await fetchWithRetry(`${conf.emby.url}/Library/Refresh?api_key=${conf.emby.apiKey}`, {
method: 'POST',
}, { label: 'emby refresh' });
return true;
}
module.exports = { refresh };
+9
View File
@@ -0,0 +1,9 @@
'use strict';
module.exports = {
auth: require('./auth'),
pubsub: require('./pubsub'),
torrent: require('./torrent'),
organizeWatcher: require('./organizeWatcher'),
wantedWatcher: require('./wantedWatcher'),
}
+70
View File
@@ -0,0 +1,70 @@
'use strict';
const conf = require('>/conf');
const { fetchJSONWithRetry } = require('./retry');
// 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 fetchJSONWithRetry(`${conf.emby.url}${pathAndQuery}${sep}api_key=${conf.emby.apiKey}`, {}, { label: `emby ${pathAndQuery.split('?')[0]}` });
return res;
}
// 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.
// 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: {}, counts: {} };
let ep = await embyGet(`/Shows/${series.Id}/Episodes`);
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
(have[sn] = have[sn] || new Set()).add(en);
}
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: {}, counts: {}, unknown: true };
}
}
module.exports = { movieInLibrary, seriesInventory, rankFromWidth, rankFromLabel, labelFromRank };
+50
View File
@@ -0,0 +1,50 @@
'use strict';
const { Ollama } = require('ollama');
const conf = require('>/conf');
// Shared Ollama client. The npm package adds its own Accept / Content-Type / User-Agent
// headers and handles the Ollama chat protocol, which avoids the CDN-level 403s Node's
// bare fetch gets against ollama.com.
const client = new Ollama({
host: conf.ollama.url,
headers: {
'Authorization': `Bearer ${conf.ollama.apiKey}`,
},
});
// Pull the first JSON object out of a model response( tolerates ```json fences / prose).
function extractJSON(content){
let text = String(content).replace(/```json/gi, '').replace(/```/g, '').trim();
let start = text.indexOf('{');
let end = text.lastIndexOf('}');
if(start === -1 || end === -1){
console.error('extractJSON: no JSON object found in:', content);
throw new Error('No JSON in model response');
}
try{
return JSON.parse(text.slice(start, end + 1));
}catch(error){
console.error('extractJSON: parse failed. extracted:', text.slice(start, end + 1));
throw error;
}
}
// One-shot JSON chat against the configured Ollama model.
async function chatJSON(system, user){
let res = await client.chat({
model: conf.ollama.model,
stream: false,
format: 'json',
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
});
// The ollama package may return message.content as a JSON string when format:'json' is used.
let content = res.message && res.message.content;
if(typeof content === 'string') return extractJSON(content);
return content;
}
module.exports = { client, chatJSON, extractJSON };
+290
View File
@@ -0,0 +1,290 @@
'use strict';
const fs = require('fs');
const path = require('path');
const conf = require('>/conf');
const ollama = require('>/controller/ollama');
const tmdb = require('>/controller/tmdb');
const emby = require('>/controller/emby');
const ps = require('>/controller/pubsub');
const { withRetry } = require('>/controller/retry');
const VIDEO_EXT = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.ts', '.wmv', '.mov']);
const SUB_EXT = new Set(['.srt', '.ass', '.ssa', '.sub', '.vtt', '.idx']);
function ext(name){ return path.extname(name).toLowerCase(); }
function isVideo(name){ return VIDEO_EXT.has(ext(name)) && !/sample/i.test(name); }
function isSub(name){ return SUB_EXT.has(ext(name)); }
// Strip characters that are illegal / awkward in file paths.
function sanitize(text){
return String(text || '').replace(/[\/\\:*?"<>|]/g, '').replace(/\s+/g, ' ').trim();
}
function pad2(n){ return String(n).padStart(2, '0'); }
// Normalize resolution to the library's convention( 2160p/uhd -> 4k).
function normalizeRes(res){
res = String(res || '').toLowerCase();
if(/2160|4k|uhd/.test(res)) return '4k';
let m = res.match(/(480|720|1080)p?/);
return m ? `${m[1]}p` : '';
}
// "Unrated 1080p" style suffix from edition + quality.
function qualitySuffix(meta){
let res = normalizeRes(meta.quality && meta.quality.resolution);
return [sanitize(meta.edition), res].filter(Boolean).join(' ');
}
// --- LLM metadata extraction ----------------------------------------------
function buildExtractPrompt(today){
return [
`You are a media library organizer. Today's date is ${today}.`,
`Given a torrent's raw release name, its coarse type (Movie or TV), and its list of`,
`video file names, extract clean metadata for filing. Return STRICT JSON only:`,
`{"mediaType":"movie"|"tv","title":"clean title, no year/quality/tags",`,
`"year":"YYYY" or "","edition":"Extended|Director's Cut|Unrated|Uncut|Remastered|`,
`Theatrical|IMAX|Redux|" (notable edition if present, else empty),`,
`"quality":{"resolution":"2160p|1080p|720p|480p|","codec":"x265|x264|"},`,
`"episodes":[{"file":"<exact name from the list>","season":<int>,"episode":<int>}]}`,
`Movies: "episodes" is []. TV: one entry per video file, mapping each file to its`,
`season/episode inferred from the file name (SxxExx, 1x02, etc.).`,
`Do not invent data; leave a field empty or [] when unknown.`,
].join('\n');
}
async function llmExtract({ name, category, files }){
let today = new Date().toISOString().slice(0, 10);
let user = JSON.stringify({ name, type: category, files });
return await withRetry(
() => ollama.chatJSON(buildExtractPrompt(today), user),
{ label: 'ollama llmExtract' }
);
}
// Parse + canonicalize. Returns a metadata object, or one carrying `organizeError`
// (so the caller flags the torrent instead of mis-filing it).
async function resolveMeta({ name, category, files }){
let llm;
try{
llm = await llmExtract({ name, category, files });
}catch(error){
return { organizeError: `parse failed: ${error.message}` };
}
let mediaType = llm.mediaType === 'tv' || llm.mediaType === 'movie'
? llm.mediaType
: (category === 'TV' ? 'tv' : 'movie');
if(!llm.title) return { organizeError: `could not determine a title for "${name}"` };
let best = await tmdb.tmdbFindBest(llm.title, llm.year, mediaType);
if(!best) return { organizeError: `no TMDB match for "${llm.title}" (${llm.year || '?'})` };
return {
mediaType,
title: best.title,
year: best.year,
edition: llm.edition || '',
quality: llm.quality || {},
episodes: Array.isArray(llm.episodes) ? llm.episodes : [],
tmdbId: best.tmdbId,
imdbId: best.imdbId,
};
}
// --- Target path building --------------------------------------------------
// Build the destination path for one video file. `episode` is the matching entry from
// meta.episodes (TV only).
function buildTarget(meta, fileName, episode){
let title = sanitize(meta.title);
let folder = `${title} (${meta.year})`;
let suffix = qualitySuffix(meta);
let e = ext(fileName);
if(meta.mediaType === 'tv' && episode){
let dir = path.join(conf.library.root, conf.library.tv, folder, `Season ${pad2(episode.season)}`);
let se = `S${pad2(episode.season)}E${pad2(episode.episode)}`;
let base = `${folder} - ${se}${suffix ? ' - ' + suffix : ''}${e}`;
return path.join(dir, base);
}
let dir = path.join(conf.library.root, conf.library.movie, folder);
let base = `${folder}${suffix ? ' - ' + suffix : ''}${e}`;
return path.join(dir, base);
}
// If the exact target exists, disambiguate with codec then a counter.
function resolveCollision(target, meta){
if(!fs.existsSync(target)) return target;
let dir = path.dirname(target);
let e = path.extname(target);
let baseNoExt = path.basename(target, e);
let codec = meta.quality && meta.quality.codec ? ' ' + sanitize(meta.quality.codec) : '';
let candidate = path.join(dir, `${baseNoExt}${codec}${e}`);
let n = 2;
while(fs.existsSync(candidate)){
candidate = path.join(dir, `${baseNoExt}${codec} (${n})${e}`);
n++;
}
return candidate;
}
// --- Filesystem move -------------------------------------------------------
async function moveFile(src, dst){
await fs.promises.mkdir(path.dirname(dst), { recursive: true });
try{
await fs.promises.rename(src, dst);
}catch(error){
if(error.code === 'EXDEV'){
await fs.promises.copyFile(src, dst);
await fs.promises.unlink(src);
}else{
throw error;
}
}
}
// Subs that belong to a given video( same basename, ignoring a language suffix).
function subsFor(videoName, subFiles, isMovie){
if(isMovie) return subFiles;
let stem = path.basename(videoName, ext(videoName)).toLowerCase();
return subFiles.filter(s => path.basename(s, ext(s)).toLowerCase().startsWith(stem.slice(0, 20)));
}
// --- Orchestration ---------------------------------------------------------
// File a finished torrent into the library, then remove it from Transmission and refresh
// Emby. Returns { organized:true, ... } or { organized:false, error }.
async function fileTorrent(torrent){
const { Torrent } = require('>/models');
let info = (await Torrent.trClient.get(torrent.hashString, ['downloadDir', 'files', 'name'])).torrents[0];
if(!info) return await flag(torrent, 'torrent not found in Transmission');
let downloadDir = info.downloadDir;
let videoFiles = info.files.filter(f => isVideo(f.name));
let subFiles = info.files.filter(f => isSub(f.name)).map(f => f.name);
if(!videoFiles.length) return await flag(torrent, 'no video files in torrent');
let meta = await resolveMeta({
name: torrent.name,
category: torrent.category,
files: videoFiles.map(f => f.name),
});
if(meta.organizeError) return await flag(torrent, meta.organizeError, meta);
let isMovie = meta.mediaType !== 'tv';
// Movie: file only the largest video. TV: file every episode video.
let toFile = isMovie
? [videoFiles.reduce((a, b) => (b.length > a.length ? b : a))]
: videoFiles;
let filed = [];
let libraryPath = null;
for(let vf of toFile){
let episode = isMovie ? null : (meta.episodes.find(e => e.file === vf.name) || null);
if(!isMovie && !episode) continue; // can't place an episode we couldn't map
let dst = resolveCollision(buildTarget(meta, vf.name, episode), meta);
await moveFile(path.join(downloadDir, vf.name), dst);
filed.push({ path: dst, season: episode ? episode.season : null, episode: episode ? episode.episode : null });
libraryPath = path.dirname(dst);
// carry matching sidecar subtitles, renamed to the video's base name
let dstBase = path.basename(dst, ext(dst));
for(let sub of subsFor(vf.name, subFiles, isMovie)){
try{
await moveFile(path.join(downloadDir, sub), path.join(path.dirname(dst), dstBase + ext(sub)));
}catch(error){ /* subs are best-effort */ }
}
}
if(!filed.length) return await flag(torrent, 'could not map any video files to episodes', meta);
// Move + stop seeding: drop the torrent and delete whatever download data is left.
try{ await Torrent.trClient.remove(torrent.hashString, true); }catch(error){ /* already gone */ }
torrent.organizedAt = new Date();
torrent.metadata = { ...meta, libraryPath, files: filed };
await torrent.save();
try{ await emby.refresh(); }catch(error){ console.error('emby refresh failed:', error.message); }
ps.publish('torrent:organized', { hashString: torrent.hashString, libraryPath });
return { organized: true, libraryPath, files: filed };
}
// Re-file an already-organized torrent under a user-chosen TMDB match. Moves the files
// already in the library to their new canonical paths.
async function refileWithMatch(torrent, tmdbId, mediaType){
if(!torrent.metadata || !Array.isArray(torrent.metadata.files) || !torrent.metadata.files.length){
let error = new Error('NothingToRefile');
error.message = 'This torrent has no filed media to re-match';
error.status = 409;
throw error;
}
let details = await tmdb.tmdbDetails(tmdbId, mediaType);
let meta = {
...torrent.metadata,
mediaType,
title: details.title,
year: details.year,
tmdbId,
imdbId: details.imdbId,
};
delete meta.organizeError;
let filed = [];
let libraryPath = null;
for(let entry of torrent.metadata.files){
let old = typeof entry === 'string' ? entry : entry.path;
let episode = (entry && entry.season != null) ? { season: entry.season, episode: entry.episode } : null;
let dst = resolveCollision(buildTarget(meta, old, episode), meta);
if(dst !== old) await moveFile(old, dst);
filed.push({ path: dst, season: episode ? episode.season : null, episode: episode ? episode.episode : null });
libraryPath = path.dirname(dst);
let oldDir = path.dirname(old);
try{ if(oldDir !== libraryPath) await fs.promises.rmdir(oldDir); }catch(error){ /* not empty / gone */ }
}
meta.files = filed;
meta.libraryPath = libraryPath;
torrent.metadata = meta;
torrent.organizedAt = new Date();
await torrent.save();
try{ await emby.refresh(); }catch(error){ console.error('emby refresh failed:', error.message); }
ps.publish('torrent:organized', { hashString: torrent.hashString, libraryPath });
return { organized: true, libraryPath, files: filed };
}
// Record a problem in metadata without setting organizedAt( leaves it flagged for a
// manual "fix match").
async function flag(torrent, message, meta){
console.error(`organize: ${torrent.hashString} skipped — ${message}`);
torrent.metadata = { ...(meta || {}), organizeError: message };
await torrent.save();
return { organized: false, error: message };
}
module.exports = {
fileTorrent,
refileWithMatch,
resolveMeta,
llmExtract,
buildTarget,
qualitySuffix,
normalizeRes,
sanitize,
moveFile,
resolveCollision,
};
+44
View File
@@ -0,0 +1,44 @@
'use strict';
const conf = require('>/conf');
const organize = require('>/controller/organize');
let lock = false;
// Poll unorganized Movie/TV downloads; when Transmission reports one finished, file it.
async function tick(){
if(lock) return;
lock = true;
try{
const { Torrent } = require('>/models');
let where = { organizedAt: null, category: ['Movie', 'TV'] };
if(!conf.organize.includePrivate) where.isPrivate = false;
let pending = await Torrent.findAll({ where });
if(!pending.length) return;
let byHash = {};
for(let t of pending) byHash[t.hashString.toLowerCase()] = t;
let res = await Torrent.trClient.get(pending.map(t => t.hashString), ['hashString', 'percentDone', 'isFinished']);
for(let tor of res.torrents){
if(!(tor.isFinished || tor.percentDone === 1)) continue;
let t = byHash[String(tor.hashString).toLowerCase()];
if(!t) continue;
if(t.metadata && t.metadata.organizeError) continue; // already tried; needs manual fix
await organize.fileTorrent(t);
}
}catch(error){
// Transmission down / transient — just try again next tick.
console.error('organizeWatcher tick failed:', error.name, error.message);
if(error.stack) console.error(error.stack);
}finally{
lock = false;
}
}
setInterval(tick, conf.organize.interval || 10000);
tick(); // reconcile once on boot
module.exports = { tick };
+8
View File
@@ -0,0 +1,8 @@
'use strict';
const {PubSub} = require('p2psub');
const ps = new PubSub();
module.exports = ps;
+85
View File
@@ -0,0 +1,85 @@
'use strict';
// Retry an async function with exponential backoff up to a max delay.
// No attempt limit: keeps retrying on transient failures forever.
// Transient = network errors, 5xx, 429. 4xx client errors are NOT retried by default.
const TRANSIENT_NETWORK_CODES = new Set([
'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN',
'ENOTFOUND', 'EPIPE', 'ERR_SOCKET_TIMEOUT', 'ETIMEOUT',
]);
function isRetryable(error){
// Network / DNS / socket errors
if(error && error.code && TRANSIENT_NETWORK_CODES.has(error.code)) return true;
// HTTP status codes worth retrying
let status = Number(error && error.status) || Number(error && error.status_code);
if(status >= 500 && status < 600) return true;
if(status === 429) return true;
if(status === 502 || status === 503 || status === 504) return true;
// ResponseError from the ollama package carries status_code
if(error && error.name === 'ResponseError' && status >= 500) return true;
return false;
}
async function sleep(ms){
return new Promise(resolve => setTimeout(resolve, ms));
}
async function withRetry(fn, options = {}){
let baseDelay = options.baseDelay || 1000;
let maxDelay = options.maxDelay || 30000;
let multiplier = options.multiplier || 2;
let jitter = options.jitter || 0.2;
let label = options.label || 'operation';
let shouldRetry = options.shouldRetry || isRetryable;
let attempt = 0;
let delay = baseDelay;
while(true){
try{
return await fn();
}catch(error){
attempt++;
if(!shouldRetry(error)){
console.error(`${label} attempt ${attempt} failed permanently:`, error.message);
throw error;
}
console.error(`${label} attempt ${attempt} failed, retrying in ${delay}ms:`, error.message);
let jittered = Math.round(delay * (1 + (Math.random() * 2 - 1) * jitter));
await sleep(jittered);
delay = Math.min(delay * multiplier, maxDelay);
}
}
}
// Convenience: retry a fetch call. On success returns the Response.
async function fetchWithRetry(url, options = {}, retryOptions = {}){
return await withRetry(async () => {
let res = await fetch(url, options);
if(!res.ok){
let error = new Error(`UpstreamError`);
error.message = `Request to ${url.split('?')[0]} failed( ${res.status})`;
error.status = res.status;
throw error;
}
return res;
}, { label: `fetch ${url.split('?')[0]}`, ...retryOptions });
}
// Convenience: retry a fetch call and parse JSON response.
async function fetchJSONWithRetry(url, options = {}, retryOptions = {}){
let res = await fetchWithRetry(url, options, retryOptions);
return await res.json();
}
module.exports = {
withRetry,
fetchWithRetry,
fetchJSONWithRetry,
isRetryable,
};
+393
View File
@@ -0,0 +1,393 @@
'use strict';
const conf = require('>/conf');
const tmdb = require('>/controller/tmdb');
const { tmdbSearch, tmdbDetails } = tmdb;
const library = require('>/controller/library');
const ollama = require('>/controller/ollama');
const { fetchWithRetry, withRetry } = require('>/controller/retry');
// 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).
const MOVIE_CATS = [201, 202, 207, 209, 211];
const TV_CATS = [205, 208, 212];
function humanSize(bytes){
bytes = Number(bytes) || 0;
let units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while(bytes >= 1024 && i < units.length - 1){
bytes /= 1024;
i++;
}
return `${bytes.toFixed(bytes < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
// Turn a raw TPB category id into 'movie' | 'tv' | null.
function videoKind(category){
category = parseInt(category, 10);
if(TV_CATS.includes(category)) return 'tv';
if(MOVIE_CATS.includes(category) || (category >= 200 && category < 300)) return 'movie';
return null;
}
// --- TPB HTML search ------------------------------------------------------
const SIZE_UNITS = { B: 1, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3, TIB: 1024 ** 4 };
function sizeToBytes(value, unit){
return Math.round(parseFloat(value) * (SIZE_UNITS[String(unit).toUpperCase()] || 1));
}
function decodeEntities(text){
return String(text)
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#0?39;/g, "'").replace(/&apos;/g, "'");
}
// Parse the classic TPB HTML result table( category cell, details link name, magnet,
// then right-aligned size / seeders / leechers cells) into apibay-shaped objects.
function parseTPBHtml(html){
let out = [];
for(let row of html.split(/<tr/i)){
let hash = row.match(/magnet:\?xt=urn:btih:([A-Fa-f0-9]+)/i);
if(!hash) continue;
let cat = row.match(/\/browse\/(\d+)/);
let name = row.match(/title="Details for ([^"]+)"/i);
let size = row.match(/<td align="right">\s*([\d.]+)(?:&nbsp;|\s)+(GiB|MiB|KiB|TiB)\s*<\/td>/i);
let nums = [...row.matchAll(/<td align="right">\s*([\d,]+)\s*<\/td>/gi)].map(m => Number(m[1].replace(/,/g, '')));
if(!name) continue;
// nums are [size, seeders, leechers]; size cell also matched the regex above so
// seeders/leechers are the last two numeric right-aligned cells.
let seeders = nums.length >= 2 ? nums[nums.length - 2] : 0;
let leechers = nums.length >= 1 ? nums[nums.length - 1] : 0;
out.push({
name: decodeEntities(name[1]),
info_hash: hash[1].toLowerCase(),
category: cat ? Number(cat[1]) : 0,
size: size ? sizeToBytes(size[1], size[2]) : 0,
seeders: seeders,
leechers: leechers,
imdb: null,
});
}
return out;
}
async function tpbSearch(title){
// Category 200 = all Video; we narrow to movie/tv subcats in prefilter().
let url = `${conf.search.tpbBase}/search/${encodeURIComponent(title)}/1/99/200`;
let res = await fetchWithRetry(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, { label: 'tpb search' });
return parseTPBHtml(await res.text());
}
// Low-quality source tags we never want to recommend( token match to avoid false hits
// like "GHOSTS" matching "TS").
const JUNK = new Set(['cam', 'hdcam', 'camrip', 'ts', 'hdts', 'telesync', 'tc', 'telecine', 'scr', 'screener', 'dvdscr', 'workprint']);
function isJunk(name){
return String(name).toLowerCase().split(/[.\s_\-\[\]()]+/).some(tok => JUNK.has(tok));
}
// Deterministic pre-filter before the torrents ever reach the LLM.
function prefilter(torrents, imdbId){
let out = torrents.filter(t => videoKind(t.category) && Number(t.seeders) > 0 && !isJunk(t.name));
// If we can positively identify the title by IMDB id, trust it and drop the rest.
if(imdbId){
let matches = out.filter(t => t.imdb && t.imdb === imdbId);
if(matches.length) out = matches;
}
return out
.sort((a, b) => Number(b.seeders) - Number(a.seeders))
.slice(0, 40);
}
function buildMagnet(infoHash, name){
let magnet = `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(name)}`;
for(let tracker of conf.search.trackers){
magnet += `&tr=${encodeURIComponent(tracker)}`;
}
return magnet;
}
// --- LLM ranking ----------------------------------------------------------
function buildSystemPrompt(title, year, today){
return [
`You are a torrent-selection assistant. Today's date is ${today}.`,
`Recent releases are legitimate: NEVER reject a torrent for being too new or assume`,
`a current-year release is fake.`,
``,
`The user wants "${title}" (${year || 'unknown year'}). From the candidate list,`,
`select the best releases.`,
year ? `IMPORTANT: only pick releases whose name contains the year ${year}; reject other years or remakes of a same-named title.` : ``,
`Preferences, in order:`,
`- video codec HEVC/x265 (over x264/AVC)`,
`- 1080p resolution`,
`- total size around 1.5 GB (avoid needlessly huge files)`,
`- English audio and English subtitles present`,
`- prefer EXTENDED / UNRATED / UNCUT / DIRECTOR'S CUT editions`,
`Reject: CAM, TS, TC, TELESYNC, HDCAM, SCREENER/SCR, and non-English-only copies.`,
``,
`Return STRICT JSON only, shape:`,
`{"options":[{"role":"recommended|uhd|other","label":"...","info_hash":"...",`,
`"name":"...","resolution":"...","codec":"...","hasSubs":true,"cut":"...",`,
`"warning":"...","why":"..."}]}`,
`Rules: exactly one "recommended" (the 1080p sweet spot). Include one "uhd" ONLY`,
`if a 4K/2160p release exists, and set its "warning" to note it is a much larger,`,
`slower download. Add "other" entries for genuinely distinct useful releases`,
`(e.g. a different cut or a much smaller copy). "why" is one short sentence.`,
`Copy info_hash verbatim from the chosen candidate.`,
].join('\n');
}
// Pull the first JSON object out of a model response( tolerates ```json fences / prose).
function extractJSON(content){
let text = String(content).replace(/```json/gi, '').replace(/```/g, '').trim();
let start = text.indexOf('{');
let end = text.lastIndexOf('}');
if(start === -1 || end === -1) throw new Error('No JSON in model response');
return JSON.parse(text.slice(start, end + 1));
}
async function rankReleases(candidates, meta){
let compact = candidates.map(t => ({
name: t.name,
sizeHuman: humanSize(t.size),
seeders: Number(t.seeders),
info_hash: t.info_hash,
imdb: t.imdb || null,
}));
let parsed = await withRetry(
() => ollama.chatJSON(
buildSystemPrompt(meta.title, meta.year, meta.today),
JSON.stringify(compact)
),
{ label: 'ollama rankReleases' }
);
let options = Array.isArray(parsed.options) ? parsed.options : [];
if(!options.length) throw new Error('Model returned no options');
return options;
}
// Deterministic fallback if the LLM is unavailable or returns junk: pick the most-seeded
// candidate, preferring x265 + 1080p, so the feature degrades instead of failing.
function fallbackReleases(candidates){
let score = t => (/x265|hevc/i.test(t.name) ? 2 : 0) + (/1080p/i.test(t.name) ? 1 : 0);
let best = [...candidates].sort((a, b) =>
(score(b) - score(a)) || (Number(b.seeders) - Number(a.seeders))
)[0];
if(!best) return [];
return [{
role: 'recommended',
label: 'Most seeded',
info_hash: best.info_hash,
name: best.name,
sizeHuman: humanSize(best.size),
seeders: Number(best.seeders),
why: 'Automatically chosen (AI ranking unavailable): most seeders.',
}];
}
// Attach the fields the front end / add flow need to each option and drop any the model
// hallucinated( info_hash must exist in the candidate set).
function decorateOptions(options, candidates){
let byHash = {};
for(let t of candidates) byHash[String(t.info_hash).toLowerCase()] = t;
let out = [];
for(let opt of options){
let src = byHash[String(opt.info_hash).toLowerCase()];
if(!src) continue;
out.push({
...opt,
info_hash: src.info_hash,
name: opt.name || src.name,
category: Number(src.category),
sizeHuman: opt.sizeHuman || humanSize(src.size),
seeders: Number(src.seeders),
magnetLink: buildMagnet(src.info_hash, src.name),
});
}
return out;
}
async function findReleases(tmdbId, mediaType){
let meta = await tmdbDetails(tmdbId, mediaType);
let torrents = await tpbSearch(meta.title);
let candidates = prefilter(torrents, meta.imdbId);
// For movies, require the confirmed release year in the name. TPB search is fuzzy
// (a "Toy Story 5" query returns Toy Story 4/3/…), and without this the fallback can
// cross to the wrong movie. If nothing matches, we genuinely have no good copy yet.
if(mediaType !== 'tv' && meta.year){
candidates = candidates.filter(t => t.name.includes(meta.year));
}
if(!candidates.length){
return { title: meta.title, year: meta.year, tmdbId, mediaType, options: [] };
}
let today = new Date().toISOString().slice(0, 10);
let options;
try{
options = await rankReleases(candidates, { title: meta.title, year: meta.year, today });
}catch(error){
console.error('rankReleases failed, using fallback:', error.message);
options = fallbackReleases(candidates);
}
options = decorateOptions(options, candidates);
if(!options.length) options = decorateOptions(fallbackReleases(candidates), candidates);
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'){
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":"<from candidates>","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 withRetry(
() => ollama.chatJSON(buildSeasonPrompt(meta.title, meta.season, meta.today), JSON.stringify(compact)),
{ label: 'ollama bestSeasonPack' }
);
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 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) };
}
// 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 withRetry(
() => ollama.chatJSON(prompt, JSON.stringify(compact)),
{ label: 'ollama bestEpisodeTorrent' }
);
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');
let title = details.title || plan.title;
let today = new Date().toISOString().slice(0, 10);
let picks = [], skipped = [];
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 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 };
}
module.exports = {
tmdbSearch,
findReleases,
seasonPlan,
fillMissing,
// exported for unit tests
prefilter,
buildMagnet,
humanSize,
videoKind,
extractJSON,
decorateOptions,
fallbackReleases,
tpbSearch,
parseTPBHtml,
sizeToBytes,
isJunk,
};
+75
View File
@@ -0,0 +1,75 @@
'use strict';
const conf = require('>/conf');
const { fetchJSONWithRetry } = require('./retry');
async function tmdbSearch(query){
let url = `https://api.themoviedb.org/3/search/multi?include_adult=false`
+ `&query=${encodeURIComponent(query)}&api_key=${conf.tmdb.apiKey}`;
let data = await fetchJSONWithRetry(url);
return (data.results || [])
.filter(item => item.media_type === 'movie' || item.media_type === 'tv')
.slice(0, 4)
.map(item => {
let date = item.release_date || item.first_air_date || '';
return {
tmdbId: item.id,
mediaType: item.media_type,
title: item.title || item.name,
year: date ? date.slice(0, 4) : '',
posterUrl: item.poster_path ? `https://image.tmdb.org/t/p/w200${item.poster_path}` : null,
};
});
}
async function tmdbDetails(tmdbId, mediaType){
let url = `https://api.themoviedb.org/3/${mediaType}/${tmdbId}`
+ `?append_to_response=external_ids&api_key=${conf.tmdb.apiKey}`;
let data = await fetchJSONWithRetry(url);
let date = data.release_date || data.first_air_date || '';
return {
title: data.title || data.name,
year: date ? date.slice(0, 4) : '',
imdbId: data.imdb_id || (data.external_ids && data.external_ids.imdb_id) || null,
};
}
// Canonicalize a( possibly messy) title + year to the authoritative TMDB record.
// Returns null when nothing matches so callers can flag instead of guessing.
async function tmdbFindBest(title, year, mediaType){
let results = await tmdbSearch(title);
let pool = results.filter(r => r.mediaType === mediaType);
if(!pool.length) pool = results;
if(!pool.length) return null;
let pick = (year && pool.find(r => r.year === String(year))) || pool[0];
let details = await tmdbDetails(pick.tmdbId, pick.mediaType);
return {
title: details.title,
year: details.year,
tmdbId: pick.tmdbId,
mediaType: pick.mediaType,
imdbId: details.imdbId,
};
}
// Per-season episode counts for a show( season 0 / specials excluded).
async function tmdbSeasons(tmdbId){
let data = await fetchJSONWithRetry(`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 = { tmdbSearch, tmdbDetails, tmdbFindBest, tmdbSeasons };
+19
View File
@@ -0,0 +1,19 @@
'use strict';
const ps = require('./pubsub.js');
const {Torrent} = require('>/models');
const conf = require('>/conf');
let statusLock = false;
setInterval(async function(){
if(statusLock) return;
statusLock = true;
try{
ps.publish('torrent:server:status', await Torrent.trClient.sessionStats());
}catch(error){
ps.publish('torrent:server:status:down')
// if(error.code === 'ECONNREFUSED') throw new Error('TorrentGatewayDown');
// console.error('status interval error', error)
}
statusLock = false
}, conf.transmission.statusUpdateInterval, statusLock);
+47
View File
@@ -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 };
+3 -151
View File
@@ -1,155 +1,7 @@
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment-with-locales.min.js"></script>
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<div id="dialog" title="Torrents">
<h3>
Torrents
</h3>
<ul>
</ul>
</div>
<script src="/__static-modules/jquery/dist/jquery.min.js"></script>
<script type="text/javascript">
function post(url, data, callback){
$.ajax({
url: url,
type: 'POST',
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
)
}
});
}
var torrents = (function(){
var torrents = {};
torrents.list = JSON.parse(window.localStorage.getItem('torrents') || '{}' ).list || [];
var in_list = function(data){
return false;
for(let torrent of torrents.list){
if(torrent.hash === data.hash) return true;
}
};
torrents.add = function(data){
if(in_list(data)) return false;
torrents.list.push(data);
torrents.updateStorage();
};
torrents.updateStorage = function(){
window.localStorage.setItem('torrents', JSON.stringify({list: torrents.list}))
};
torrents.statusAll = function(callback){
var list_count = torrents.list.length;
var count = 0;
for(let torrent of torrents.list){
if(torrent.percentDone === 1){
count++;
if(count === list_count){
torrents.updateStorage();
(callback || function(){})(torrents.list)
}
continue;
}
$.getJSON('/__api/torrent/'+torrent.id, function(data){
$.extend(true, torrent, data.cres.torrents[0])
count++;
if(count === list_count){
torrents.updateStorage();
(callback || function(){})(torrents.list)
}
})
}
};
torrents.updateDialog = function(){
var $ol = $($('#dialog').find('ul')[0]);
$ol.html('');
torrents.list.forEach(function(torrent){
let li = `<li>${torrent.name}`;
if(torrent.percentDone < 1){
li += `
<div id="progressbar-${torrent.hashString}"></div>
ETA: ${moment().seconds(torrent.eta).fromNow()} Rate: ${Math.floor(torrent.rateDownload/1024/1024)}mb
`
}else{
li += `<br /> Done! <a href="https://stuff.718it.biz/torrents/${torrent.name}" target="_blank"> HTTP Link</a>`
}
li += `<hr /></li>`
$ol.prepend(li)
$("#progressbar-"+torrent.hashString).progressbar({
value: torrent.percentDone*100
});
});
}
$( document ).ready(function() {
torrents.statusAll();
torrents.timmer = setInterval(function(){
console.log('auto update')
torrents.statusAll(function(){
torrents.updateDialog()
});
}, 2000)
});
return torrents;
})()
$( document ).ready(function() {
$($('input[name="q"]')[0]).before($('<img src="https://chocolatey.org/content/packageimages/transmission.2.92.svg" height=24 width=24/>').on('click', function(){
torrents.statusAll(function(){
console.log('stats all')
$("#dialog").dialog();
torrents.updateDialog()
});
}))
$('a').each(function(idx, el){
var $el = $(el);
if($el.attr('href') && $el.attr('href').match("magnet:?")){
$el.before('<img class="718link" src="https://chocolatey.org/content/packageimages/transmission.2.92.svg" height=24 width=24 data-link="'+$el.attr('href')+'"/>')
}
})
$("body").on('click', 'img.718link', function(el){
post('/__api/torrent', {
magnet: window.btoa($(this).data('link')),
password: prompt('password?')
}, function(err, data){
torrents.add(data);
// console.log(data);
});
});
$('body').prepend('<div id="tbp_proxy_header"></div>');
$('#tbp_proxy_header').load('/__static/partial/header.html');
});
</script>
+25
View File
@@ -0,0 +1,25 @@
'use strict';
const { Auth } = require('>/controller/auth');
async function auth(req, res, next){
try{
req.token = await Auth.checkToken(req.header('auth-token'));
req.user = await req.token.getUser();
return next();
}catch(error){
next(error);
}
}
async function authIO(socket, next){
try{
let token = await Auth.checkToken(socket.handshake.auth.token || 0);
socket.user = await token.getUser();
next();
}catch(error){
next(error);
}
}
module.exports = {auth, authIO};
+8
View File
@@ -0,0 +1,8 @@
'use strict';
module.exports = {
User: require('./ldap/user').User,
AuthToken: require('./sql').AuthToken,
Torrent: require('./sql').Torrent,
Wanted: require('./sql').Wanted
};
+99
View File
@@ -0,0 +1,99 @@
'use strict';
const { Client, Attribute, Change } = require('ldapts');
const conf = require('>/conf').ldap;
const client = new Client({
url: conf.url,
});
async function getGroups(client, member){
try{
let memberFilter = member ? `(member=${member})`: ''
let groups = (await client.search(conf.groupBase, {
scope: 'sub',
filter: `(&(objectClass=groupOfNames)${memberFilter})`,
attributes: ['*', 'createTimestamp', 'modifyTimestamp'],
})).searchEntries;
return groups.map(function(group){
if(!Array.isArray(group.member)) group.member = [group.member];
return group
});
}catch(error){
throw error;
}
}
var Group = {};
Group.list = async function(member){
try{
await client.bind(conf.bindDN, conf.bindPassword);
let groups = await getGroups(client, member)
await client.unbind();
return groups.map(group => group.cn);
}catch(error){
throw error;
}
}
Group.listDetail = async function(member){
try{
await client.bind(conf.bindDN, conf.bindPassword);
let groups = await getGroups(client, member)
await client.unbind();
return groups;
}catch(error){
throw error;
}
}
Group.get = async function(data){
try{
if(typeof data !== 'object'){
let name = data;
data = {};
data.name = name;
}
await client.bind(conf.bindDN, conf.bindPassword);
let group = (await client.search(conf.groupBase, {
scope: 'sub',
filter: `(&(objectClass=groupOfNames)(cn=${data.name}))`,
attributes: ['*', 'createTimestamp', 'modifyTimestamp'],
})).searchEntries[0];
await client.unbind();
if(!Array.isArray(group.member)) group.member = [group.member];
if(group){
let obj = Object.create(this);
Object.assign(obj, group);
return obj;
}else{
let error = new Error('GroupNotFound');
error.name = 'GroupNotFound';
error.message = `LDAP:${data.cn} does not exists`;
error.status = 404;
throw error;
}
}catch(error){
throw error;
}
}
module.exports = {Group};
+6
View File
@@ -0,0 +1,6 @@
'use strict';
module.exports = {
User: require('./user').User,
Group: require('./group').Group,
};
+184
View File
@@ -0,0 +1,184 @@
'use strict';
const {Client, Attribute} = require('ldapts');
const LRUCache = require('lru-native2');
const conf = require('>/conf').ldap;
var userLUR = new LRUCache({
// The maximum age (in milliseconds) of an item.
// The item will be removed if get() is called and the item is too old.
// Default: 0, meaning items will never expire.
maxAge: 60000,
});
const escapeFilterValue = function(value){
return String(value).replace(/[\0()*\\]/g, function(char){
return '\\' + char.charCodeAt(0).toString(16).padStart(2, '0');
});
}
const user_parse = function(data){
if(data[conf.userNameAttribute]){
data.username = data[conf.userNameAttribute];
data.userPassword = undefined;
data.userBacking = "LDAP";
}
return data;
}
var User = {}
User.backing = "LDAP";
User.list = async function(){
try{
const client = new Client({
url: conf.url,
});
await client.bind(conf.bindDN, conf.bindPassword);
const res = await client.search(conf.userBase, {
scope: 'sub',
filter: conf.userFilter,
attributes: ['*', 'createTimestamp', 'modifyTimestamp'],
});
await client.unbind();
return res.searchEntries.map(function(user){return user.uid});
}catch(error){
throw error;
}
};
User.listDetail = async function(){
try{
const client = new Client({
url: conf.url,
});
await client.bind(conf.bindDN, conf.bindPassword);
const res = await client.search(conf.userBase, {
scope: 'sub',
filter: conf.userFilter,
attributes: ['*', 'createTimestamp', 'modifyTimestamp'],
});
await client.unbind();
let users = [];
for(let user of res.searchEntries){
let obj = Object.create(this);
Object.assign(obj, user_parse(user));
users.push(obj);
}
return users;
}catch(error){
throw error;
}
};
User.get = async function(data, key){
try{
if(typeof data !== 'object'){
let uid = data;
data = {};
data.uid = uid;
}
data.searchKey = data.searchKey || key || conf.userNameAttribute;
data.searchValue = data.searchValue || data.uid;
let filter = `(&${conf.userFilter}(${escapeFilterValue(data.searchKey)}=${escapeFilterValue(data.searchValue)}))`;
if(userLUR.get(filter)) return userLUR.get(filter);
const client = new Client({
url: conf.url,
});
await client.bind(conf.bindDN, conf.bindPassword);
const res = await client.search(conf.userBase, {
scope: 'sub',
filter: filter,
attributes: ['*', 'createTimestamp', 'modifyTimestamp'],
});
await client.unbind();
if(res.searchEntries[0]){
let obj = Object.create(this);
Object.assign(obj, user_parse(res.searchEntries[0]));
userLUR.set(filter, obj);
return obj;
}else{
let error = new Error('UserNotFound');
error.name = 'UserNotFound';
error.message = `LDAP:${data.searchValue} does not exists`;
error.status = 404;
throw error;
}
}catch(error){
throw error;
}
};
User.exists = async function(data, key){
// Return true or false if the requested entry exists ignoring error's.
try{
await this.get(data, key);
return true
}catch(error){
return false;
}
};
User.login = async function(data){
try{
if(!data.password){
let error = new Error('LDAPLoginFailed');
error.name = 'LDAPLoginFailed';
error.message = 'Invalid Credentials, login failed.';
error.status = 401;
throw error;
}
let user = await this.get(data.uid || data[conf.userNameAttribute] || data.username);
const client = new Client({
url: conf.url,
});
await client.bind(user.dn, data.password);
await client.unbind();
return user;
}catch(error){
throw error;
}
};
module.exports = {User};
// (async function(){
// try{
// console.log(await User.list());
// console.log(await User.listDetail());
// console.log(await User.get('wmantly'))
// }catch(error){
// console.error(error)
// }
// })()
+49
View File
@@ -0,0 +1,49 @@
'use strict';
module.exports = (sequelize, DataTypes, Model) => {
class AuthToken extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/
static associate(models) {
}
check(){
// check expires_on date
return this.is_valid;
}
}
AuthToken.init({
token:{
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
allowNull: false,
primaryKey: true
},
expires_on: {
type: DataTypes.DATE,
allowNull: true,
validate:{
isDate:true
}
},
username: {
type: DataTypes.STRING,
ldapModel: 'User',
allowNull: false,
validate:{
notNull: true,
},
},
is_valid: {
type: DataTypes.BOOLEAN,
defaultValue: true
}
}, {
sequelize,
modelName: 'AuthToken',
});
return AuthToken;
};
+3
View File
@@ -0,0 +1,3 @@
const conf = require('../../../conf');
module.exports = conf.sql;
+109
View File
@@ -0,0 +1,109 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { Sequelize, Utils } = require('sequelize');
const process = require('process');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/config/config.js');
const db = {};
let sequelize;
// Connect sequelize models to LDAP models
const ldapModels = require('../ldap');
function getFieldWithLdap(attributes){
let out = [];
for (const [attribute, options] of Object.entries(attributes)) {
if(options.ldapModel) out.push(attribute)
}
return out;
}
const sequilze_conf = {
define: {
hooks: {
async afterValidate(instance) {
let hasError = false;
function itemError(key, validator, message){
let error = new Sequelize.ValidationErrorItem(message);
error.type = 'Validation error';
error.path = key;
error.origin = 'FUNCTION';
error.instance = instance;
error.validatorKey = 'validator';
error.validatorName = 'validator';
error.validatorArgs = [];
error.original = [];
throw new Sequelize.ValidationError(null, [error]);
}
for(let attribute of getFieldWithLdap(this.getAttributes())){
let externalModel = ldapModels[this.getAttributes()[attribute].ldapModel];
if(!externalModel) itemError(attribute, 'modelExists', `LDAP model ${this.getAttributes()[attribute].ldapModel} not found.`);
if(!hasError && !(await externalModel.exists(instance[attribute])) ) itemError(attribute, 'foreignKey', `LDAP model has no object ${instance[attribute]}`);
}
}
}
}
}
class _Model extends Sequelize.Model{
constructor(...args){
super(...args)
let hasLdap = getFieldWithLdap(this.constructor.getAttributes())
for(let attribute of hasLdap){
let externalModelName = this.constructor.getAttributes()[attribute].ldapModel;
this[`get${externalModelName}`] = async function(){
return await ldapModels[externalModelName].get(this[attribute]);
}
}
}
// Backward compatible with my LDAP and Redis models.
// I will update the LDAP and Redis stuff to have method interfaces inline with sequilze
static async get(token){
return await this.findByPk(token);
}
}
if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
sequelize = new Sequelize(config.database, config.username, config.password, {...config, ...sequilze_conf});
}
fs
.readdirSync(__dirname)
.filter(file => {
return (
file.indexOf('.') !== 0 &&
file !== basename &&
file.slice(-3) === '.js' &&
file.indexOf('.test.js') === -1
);
})
.forEach(file => {
const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes, _Model);
db[model.name] = model;
});
Object.keys(db).forEach(modelName => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db;
@@ -0,0 +1,38 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('AuthTokens', {
token: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true
},
is_valid: {
type: Sequelize.BOOLEAN,
defaultValue: true,
allowNull: false,
},
username: {
type: Sequelize.STRING,
allowNull: false,
},
expires_on: {
allowNull: true,
type: Sequelize.DATE
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('AuthTokens');
}
};
@@ -0,0 +1,56 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('Torrents', {
hashString: {
primaryKey: true,
allowNull: false,
type: Sequelize.STRING
},
magnetLink: {
type: Sequelize.STRING
},
name: {
type: Sequelize.STRING
},
status: {
type: Sequelize.NUMBER
},
isPrivate: {
type: Sequelize.BOOLEAN,
defaultValue: false,
allowNull: false,
},
percentDone: {
type: Sequelize.FLOAT
},
errorString: {
type: Sequelize.STRING,
allowNull: true
},
downloadDir: {
type: Sequelize.STRING,
allowNull: true
},
sizeWhenDone: {
type: Sequelize.NUMBER,
allowNull: true
},
added_by: {
type: Sequelize.STRING
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('Torrents');
}
};
@@ -0,0 +1,14 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('Torrents', 'category', {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'Other'
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('Torrents', 'category');
}
};
@@ -0,0 +1,18 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('Torrents', 'organizedAt', {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn('Torrents', 'metadata', {
type: Sequelize.JSON,
allowNull: true,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('Torrents', 'organizedAt');
await queryInterface.removeColumn('Torrents', 'metadata');
}
};
@@ -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');
}
};
+221
View File
@@ -0,0 +1,221 @@
'use strict';
const Transmission = require('transmission-promise');
const conf = require('>/conf');
const { withRetry } = require('>/controller/retry');
const tr_client = new Transmission(conf.transmission);
// Wrap the raw Transmission client so every RPC call retries on transient network errors.
const trClient = new Proxy(tr_client, {
get(target, prop){
let value = target[prop];
if(typeof value !== 'function') return value;
return async function(...args){
return await withRetry(
() => value.apply(target, args),
{ label: `transmission ${String(prop)}` }
);
};
}
});
const statusMap = [
'STOPPED', // 0
'CHECK_WAIT', // 1
'CHECK', // 2
'DOWNLOAD_WAIT', // 3
'DOWNLOAD', // 4
'SEED_WAIT', // 5
'SEED', // 6
'ISOLATED', // 7
];
module.exports = (sequelize, DataTypes, Model) => {
class Torrent extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/
static associate(models) {
// define association here
}
static trClient = trClient;
// Map a raw TPB category id( e.g. 207) to one of our buckets. TPB groups by the
// hundreds digit; TV shows are the exception pulled out of the Video group.
static categoryFromTPB(id){
id = parseInt(id, 10);
if([205, 208, 212].includes(id)) return 'TV';
return {
1: 'Music',
2: 'Movie',
3: 'App',
4: 'Game',
5: 'Adult',
6: 'Other',
}[Math.floor(id / 100)] || 'Other';
}
static async create(data, ...args){
try{
data.isPrivate = data.isPrivate === 'true' ? true : false;
let options = {
'download-dir': data.isPrivate ? `${conf.privateDownloadLocation}/${data.added_by}` : undefined,
};
let res = await trClient.addUrl(data.magnetLink, options);
// Transmission usually returns hashString, but if it doesn't (duplicate, malformed
// response, etc.), fall back to parsing the info hash from the magnet link.
let hashString = res && (res.hashString || res.id);
if(!hashString){
let match = data.magnetLink.match(/urn:btih:([A-Fa-f0-9]{40})/i);
if(match) hashString = match[1].toLowerCase();
}
if(!hashString){
console.error('Transmission addUrl response missing hashString:', res);
let error = new Error('TorrentCreateError');
error.message = 'Transmission did not return a hash for this torrent';
throw error;
}
let createData = {
magnetLink: data.magnetLink,
hashString,
isPrivate: data.isPrivate,
name: res && res.name,
added_by: data.added_by,
category: this.categoryFromTPB(data.category),
status: 0,
percentDone: 0,
};
// Validate only after hashString is populated.
await this.build(createData).validate();
return await super.create(createData, args);
}catch (error){
console.log('Torrent create error', error);
throw error;
}
}
async getTorrentData(){
try{
if(this.percentDone === 1) return this.dataValues
let res = ( await trClient.get(this.hashString, [
"eta", "percentDone", "status", "rateDownload",
"errorString", "hashString", 'name',
'downloadDir',
'dateCreated',
'files', //array of files
'filesStats', // array of files with status
'isFinished',
'isStalled',
'peers',
'peersConnected', // array of peers,
'sizeWhenDone',
]) ).torrents[0];
await this.update(res);
return {...res, ...this.dataValues};
}catch(error){
if(error.code === 'ECONNREFUSED'){
let e = new Error('TorrentGatewayDown')
e.status = 555
throw e
}
// console.error(`Torrent ${this.hashString} getTorrentData error`, error);
throw error
}
}
async stop(){
return await this.constructor.trClient.stop(this.hashString);
}
async start(force){
if(force) return await this.constructor.trClient.startNow(this.hashString);
let res = await this.constructor.trClient.start(this.hashString);
console.log('start', res);
return res;
}
async destroy(){
await this.constructor.trClient.remove(this.hashString, true);
return await super.destroy()
}
}
Torrent.init({
hashString: {
type: DataTypes.STRING,
allowNull: false,
primaryKey: true
},
magnetLink: {
type: DataTypes.STRING,
allowNull: false,
validate:{
notNull: true,
notEmpty: true,
},
},
isPrivate: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
name: DataTypes.STRING,
category: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'Other',
},
added_by: {
type: DataTypes.STRING,
ldapModel: 'User',
allowNull: false,
validate:{
notNull: true,
notEmpty: true,
},
},
status: DataTypes.NUMBER,
percentDone: DataTypes.FLOAT,
downloadDir: {
type: DataTypes.STRING,
allowNull: true,
},
errorString: {
type: DataTypes.STRING,
allowNull: true,
},
sizeWhenDone: {
type: DataTypes.NUMBER,
allowNull: true,
},
createdAt: {
type: DataTypes.DATE
},
organizedAt: {
type: DataTypes.DATE,
allowNull: true,
},
metadata: {
type: DataTypes.JSON,
allowNull: true,
},
}, {
sequelize,
modelName: 'Torrent',
logging: false,
});
return Torrent;
};
+32
View File
@@ -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;
};
+4393 -1457
View File
File diff suppressed because it is too large Load Diff
+24 -3
View File
@@ -4,13 +4,34 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"migrate": "npx sequelize-cli db:migrate",
"start": "node ./bin/www",
"start-dev": "npx nodemon ./bin/www"
},
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.17.1",
"ejs": "^3.1.9",
"express": "^4.18.2",
"extend": "^3.0.2",
"http-proxy-middleware": "^0.20.0",
"transmission-promise": "^1.1.4"
"jq-repeat": "^2.2.1",
"jquery": "^3.7.1",
"jquery-ui": "^1.13.2",
"ldapts": "^7.0.7",
"lru-native2": "^1.2.6",
"moment": "^2.30.1",
"mustache": "^4.2.0",
"ollama": "^0.6.3",
"p2psub": "^0.1.9",
"sequelize": "^6.35.2",
"sequelize-cli": "^6.6.2",
"socket.io": "^4.7.2",
"sqlite3": "^5.1.7-rc.0",
"transmission-promise": "^1.1.6"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}
+445
View File
@@ -0,0 +1,445 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="48px"
height="48px"
id="svg5186"
sodipodi:version="0.32"
inkscape:version="0.45+devel"
sodipodi:docname="transmission.svg"
inkscape:output_extension="org.inkscape.output.svg.inkscape"
inkscape:export-filename="/home/andreas/project/application icons/48x48/transmission.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs5188">
<linearGradient
inkscape:collect="always"
id="linearGradient9795">
<stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop9797" />
<stop
style="stop-color:#ffffff;stop-opacity:0;"
offset="1"
id="stop9799" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient9783">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop9785" />
<stop
style="stop-color:#000000;stop-opacity:0;"
offset="1"
id="stop9787" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient9775">
<stop
style="stop-color:#f9f9f9;stop-opacity:1"
offset="0"
id="stop9777" />
<stop
style="stop-color:#eeeeec;stop-opacity:0.62037037"
offset="1"
id="stop9779" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient5948">
<stop
style="stop-color:#787b76;stop-opacity:1;"
offset="0"
id="stop5950" />
<stop
id="stop5956"
offset="0.87125719"
style="stop-color:#babcb9;stop-opacity:1" />
<stop
style="stop-color:#787b76;stop-opacity:1"
offset="1"
id="stop5952" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient5908">
<stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop5910" />
<stop
style="stop-color:#ffffff;stop-opacity:0;"
offset="1"
id="stop5912" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient5898">
<stop
style="stop-color:#cc0000;stop-opacity:1;"
offset="0"
id="stop5900" />
<stop
id="stop5906"
offset="0.36509839"
style="stop-color:#ef0000;stop-opacity:1" />
<stop
style="stop-color:#aa0000;stop-opacity:1"
offset="1"
id="stop5902" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient5871">
<stop
style="stop-color:#f0f2ef;stop-opacity:1"
offset="0"
id="stop5873" />
<stop
style="stop-color:#cdd1c8;stop-opacity:1"
offset="1"
id="stop5875" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient5843">
<stop
style="stop-color:#888a85;stop-opacity:1"
offset="0"
id="stop5845" />
<stop
style="stop-color:#2e3436;stop-opacity:1"
offset="1"
id="stop5847" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient5835">
<stop
style="stop-color:#555753;stop-opacity:1;"
offset="0"
id="stop5837" />
<stop
style="stop-color:#2e3436;stop-opacity:1"
offset="1"
id="stop5839" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient5823">
<stop
style="stop-color:#2e3436;stop-opacity:1;"
offset="0"
id="stop5825" />
<stop
style="stop-color:#2e3436;stop-opacity:0;"
offset="1"
id="stop5827" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient5234">
<stop
style="stop-color:#babdb6;stop-opacity:1;"
offset="0"
id="stop5236" />
<stop
id="stop5242"
offset="0.13299191"
style="stop-color:#eeeeec;stop-opacity:1" />
<stop
style="stop-color:#babdb6;stop-opacity:1"
offset="1"
id="stop5238" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5234"
id="linearGradient5240"
x1="23.738585"
y1="4.156569"
x2="23.738585"
y2="19.46567"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5823"
id="linearGradient5829"
x1="23.732271"
y1="30.057167"
x2="23.688078"
y2="22.632544"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5835"
id="linearGradient5841"
x1="23.9375"
y1="30.616879"
x2="23.9375"
y2="36.357994"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5843"
id="linearGradient5849"
x1="20.771132"
y1="32.248005"
x2="20.563131"
y2="23.939499"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5898"
id="linearGradient5904"
x1="14.8125"
y1="5.6244211"
x2="14.8125"
y2="9"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5908"
id="linearGradient5914"
x1="24.040522"
y1="5.0690055"
x2="24.040522"
y2="10.0086"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5871"
id="linearGradient5928"
x1="13.625"
y1="33.125"
x2="14.125"
y2="24"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5948"
id="linearGradient5954"
x1="10.1875"
y1="20.25"
x2="10.1875"
y2="42.5"
gradientUnits="userSpaceOnUse" />
<filter
inkscape:collect="always"
id="filter9771"
x="-0.02976581"
width="1.0595316"
y="-0.13995509"
height="1.2799102">
<feGaussianBlur
inkscape:collect="always"
stdDeviation="0.5196773"
id="feGaussianBlur9773" />
</filter>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient9775"
id="linearGradient9781"
x1="24.71875"
y1="35.958694"
x2="23.936657"
y2="17.070877"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient9783"
id="linearGradient9789"
x1="18.3125"
y1="20.743757"
x2="18.3125"
y2="21.814325"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient9795"
id="linearGradient9801"
x1="30.4375"
y1="31.82852"
x2="29.742416"
y2="27.45352"
gradientUnits="userSpaceOnUse" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="5.6568542"
inkscape:cx="30.372474"
inkscape:cy="21.423534"
inkscape:current-layer="layer1"
showgrid="true"
inkscape:grid-bbox="true"
inkscape:document-units="px"
inkscape:window-width="1091"
inkscape:window-height="777"
inkscape:window-x="557"
inkscape:window-y="164">
<inkscape:grid
type="xygrid"
id="grid5195" />
</sodipodi:namedview>
<metadata
id="metadata5191">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
inkscape:label="Layer 1"
inkscape:groupmode="layer">
<rect
style="opacity:0.28240741;fill:#2e3436;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter9771)"
id="rect9761"
width="41.901279"
height="8.9116125"
x="3"
y="39"
rx="2.2980971"
ry="2.2980971" />
<path
style="fill:url(#linearGradient5954);fill-rule:evenodd;stroke:#555753;stroke-width:1.00000011999999994px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
d="M 10,16.59375 C 8.8196081,16.548814 7.6402135,17.571722 7.53125,18.8125 C 6.643292,26.100083 5.3269606,33.403527 4.65625,40.6875 L 4.65625,43.75 C 4.6900093,45.329492 5.7271791,46.392039 6.875,46.59375 L 41.5,46.59375 C 42.479024,46.569246 43.565009,45.89005 43.53125,44.59375 L 43.53125,40.65625 L 40.40625,19.4375 C 40.152431,18.135677 39.039534,16.752716 37.5,16.59375 L 10,16.59375 z"
id="path5232"
sodipodi:nodetypes="ccccccccccc" />
<path
style="fill:url(#linearGradient5928);fill-opacity:1;fill-rule:evenodd;stroke:#555753;stroke-width:0.99999994000000003px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 10.601853,39.624614 C 9.47224,39.502143 8.6733861,38.760954 8.7014295,37.401046 L 10.601853,21.407733 C 10.893931,20.339398 11.586949,19.485349 12.680909,19.488442 L 34.605501,19.488442 C 35.691818,19.455762 36.778134,20.208796 37.062569,21.104687 L 39.478435,37.237611 C 39.535481,38.706714 38.931012,39.557098 37.913093,39.523599 L 10.601853,39.624614 z"
id="path5230"
sodipodi:nodetypes="ccccccccc" />
<path
style="fill:url(#linearGradient5841);fill-rule:evenodd;stroke:url(#linearGradient5849);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
d="M 20.46875,20.4375 L 18.40625,32.46875 L 15.4375,32.46875 L 23.46875,37.625 L 32.4375,32.46875 L 29.46875,32.46875 L 27.59375,20.4375 L 20.46875,20.4375 z"
id="path5197"
sodipodi:nodetypes="cccccccc" />
<rect
style="opacity:1;fill:url(#linearGradient5904);fill-opacity:1;stroke:#930000;stroke-width:1.00000011999999994;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect5224"
width="31.113209"
height="6.0609155"
x="8.4847708"
y="4.5135489"
rx="5.0159144"
ry="1.9854566" />
<rect
style="opacity:0.58333333;fill:none;fill-opacity:1;stroke:url(#linearGradient5914);stroke-width:1.00000011999999994;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect5896"
width="29.080278"
height="3.9395947"
x="9.5003824"
y="5.5690055"
rx="1.8339339"
ry="1.2783499" />
<path
style="opacity:0.24537036000000001;fill:none;fill-opacity:1;fill-rule:evenodd;stroke:url(#linearGradient9781);stroke-width:1.00000011999999994px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 10.592965,17.57221 C 9.474152,17.53019 8.3562869,18.486727 8.2530054,19.647002 L 5.4687498,39.722803 C 5.4796612,39.847886 5.4997885,39.979699 5.5279893,40.102694 L 5.5279893,42.966491 C 5.559989,44.443503 6.5430497,45.407885 7.6309909,45.596509 L 40.479283,45.596509 C 41.407232,45.573597 42.406944,44.967688 42.374947,43.755497 L 42.374947,40.073472 C 42.382229,40.044972 42.398547,40.013922 42.404566,39.985805 L 42.374947,39.781247 L 42.374947,39.576691 L 42.345327,39.576691 L 39.442592,20.202228 C 39.202015,18.98487 38.147175,17.72086 36.687956,17.57221 L 10.592965,17.57221 z"
id="path5881" />
<path
style="fill:url(#linearGradient9789);fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1;opacity:0.20833333000000001"
d="M 10.210155,29.955767 L 12.048004,22 L 36.07815,22.05802 L 37.857941,31.044156 L 36.681164,21.969631 C 36.460193,20.967897 35.929863,20 34.957591,20.025088 L 13.037281,19.980893 C 11.606886,19.936699 11.32554,20.864777 11,21.969631 L 10.210155,29.955767 z"
id="path5926"
sodipodi:nodetypes="ccccccccc" />
<rect
style="opacity:1;fill:url(#linearGradient5240);fill-opacity:1;stroke:#888a85;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect5226"
width="7.0964494"
height="25.970053"
x="20.48369"
y="3.6044116"
rx="1.0763195"
ry="1.0763192" />
<rect
style="opacity:1;fill:url(#linearGradient5829);fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect5244"
width="8.1317272"
height="8.0433397"
x="19.975765"
y="22.013826"
rx="1.0763195"
ry="1.0763192" />
<path
style="opacity:0.43518521;fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1"
d="M 11.423372,41.486321 L 39.533811,41.486321"
id="path5879"
sodipodi:nodetypes="cc" />
<rect
style="opacity:0.22685185;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect5892"
width="5.151906"
height="23.93712"
x="21.428234"
y="4.6321397"
rx="1.0763195"
ry="1.0763192" />
<g
id="g5972"
style="opacity:0.62037037">
<path
sodipodi:nodetypes="cc"
id="path5831"
d="M 20.4375,30.5 L 27.5,30.5"
style="fill:none;fill-rule:evenodd;stroke:#888a85;stroke-width:1px;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1;opacity:1" />
<path
sodipodi:nodetypes="cc"
id="path5833"
d="M 19.960998,32.5 L 27.976504,32.5"
style="fill:none;fill-rule:evenodd;stroke:#888a85;stroke-width:1px;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1;opacity:0.68055556" />
<path
sodipodi:nodetypes="cc"
id="path5958"
d="M 20.273498,31.5 L 27.726504,31.5"
style="fill:none;fill-rule:evenodd;stroke:#5d5d5c;stroke-width:1px;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1;opacity:1" />
<path
sodipodi:nodetypes="cc"
id="path5960"
d="M 19.869986,33.488738 L 28.141277,33.488738"
style="fill:none;fill-rule:evenodd;stroke:#5d5d5c;stroke-width:0.99999994000000003px;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1;opacity:0.68055556" />
</g>
<path
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1"
d="M 14.381412,31.513733 L 17.519198,31.513733"
id="path9791"
sodipodi:nodetypes="cc" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1"
d="M 30.443912,31.451233 L 33.581698,31.451233"
id="path9803"
sodipodi:nodetypes="cc" />
<path
sodipodi:type="arc"
style="opacity:0.33500001;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path5119"
sodipodi:cx="9.8553009"
sodipodi:cy="42.188465"
sodipodi:rx="1.1932427"
sodipodi:ry="1.0827572"
d="M 11.048544,42.188465 A 1.1932427,1.0827572 0 1 1 8.6620582,42.188465 A 1.1932427,1.0827572 0 1 1 11.048544,42.188465 z"
transform="matrix(0.4216252,0,0,0.4766032,5.3634688,21.39228)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

+436
View File
@@ -0,0 +1,436 @@
var app = {};
app.pubsub = (function(){
app.topics = {};
app.subscribe = function(topic, listener) {
if(topic instanceof RegExp){
listener.match = topic;
topic = "__REGEX__";
}
// create the topic if not yet created
if(!app.topics[topic]) app.topics[topic] = [];
// add the listener
app.topics[topic].push(listener);
}
app.matchTopics = function(topic){
topic = topic || '';
var matches = [... app.topics[topic] ? app.topics[topic] : []];
if(!app.topics['__REGEX__']) return matches;
for(var listener of app.topics['__REGEX__']){
if(topic.match(listener.match)) matches.push(listener);
}
return matches;
}
app.publish = function(topic, data) {
// send the event to all listeners
app.matchTopics(topic).forEach(function(listener) {
setTimeout(function(data, topic){
listener(data || {}, topic);
}, 0, data, topic);
});
}
return this;
})(app);
app.socket = (function(app){
// $.getScript('/socket.io/socket.io.js')
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
var socket;
$(document).ready(function(){
socket = io({
auth: {
token: app.auth.getToken()
}
});
// socket.emit('chat message', $('#m').val());
socket.on('P2PSub', function(msg){
msg.data.__noSocket = true;
app.publish(msg.topic, msg.data);
});
app.subscribe(/./g, function(data, topic){
// console.log('local_pubs', data, topic)
if(data.__noSocket) return;
// console.log('local_pubs 2', data, topic)
socket.emit('P2PSub', { topic, data });
});
})
return socket;
})(app);
app.api = (function(app){
var baseURL = '/__api/'
$( document ).on( "ajaxError", function(event, res, req) {
console.log('bad!', `app:api:error:`, res.status);
app.publish(`app:api:error:${res.status}`, {res, res});
} );
function post(url, data, callback){
callback = callback || app.util.emptyFuction;
$.ajax({
type: 'POST',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
)
}
});
}
function put(url, data, callback){
$.ajax({
type: 'PUT',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
)
}
});
}
function remove(url, callback, callback2){
if(!$.isFunction(callback)) callback = callback2;
$.ajax({
type: 'delete',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
)
}
});
}
function get(url, callback){
$.ajax({
type: 'GET',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
)
}
});
}
return {post: post, get: get, put: put, delete: remove}
})(app)
app.auth = (function(app) {
var user = {}
function setToken(token){
localStorage.setItem('APIToken', token);
setCookieToken(token);
}
function getToken(){
return localStorage.getItem('APIToken');
}
// The proxy gates page navigation on a cookie( the auth-token header can not ride
// along on a navigation), so mirror the token into a cookie.
function setCookieToken(token){
document.cookie = 'auth-token=' + encodeURIComponent(token) + '; path=/; max-age=31536000; samesite=lax';
}
function clearCookieToken(){
document.cookie = 'auth-token=; path=/; max-age=0; samesite=lax';
}
function isLoggedIn(callback){
if(getToken()){
return app.api.get('user/me', function(error, data){
if(error === 'Unauthorized') logOut();
if(!error) app.auth.user = data;
return callback(error, data);
});
}else{
callback(null, false);
}
}
function logIn(args, callback){
app.api.post('auth/login', args, function(error, data){
if(data.login){
setToken(data.token);
}
callback(error, !!data.token);
});
}
function logOut(callback){
callback = callback || app.util.emptyFuction;
localStorage.removeItem('APIToken');
clearCookieToken();
callback();
}
function makeUserFromInvite(args, callback){
app.api.post('auth/invite/'+ args.token, args, function(error, data){
if(data.login){
callback(null, data);
setToken(data.token);
}
callback(error, !!data.token);
});
}
function forceLogin(){
$.holdReady( true );
app.auth.isLoggedIn(function(error, isLoggedIn){
if(error || !isLoggedIn){
app.auth.logOut(function(){})
location.replace(`/login${location.href.replace(location.origin, '')}`);
}else{
$.holdReady( false );
}
});
}
function logInRedirect(){
window.location.href = location.href.replace(location.origin+'/login', '') || '/'
}
$( document ).ready( function(){
isLoggedIn(function(error, isLoggedIn){
if(!error && isLoggedIn){
// Refresh the navigation cookie; if this is the logged out gate page,
// reload now that the cookie is set so the real content is proxied.
setCookieToken(getToken());
if(window.__tbpLoginGate) return location.reload();
$('.tbp_proxy_is_authed').show();
$('.tbp_proxy_not_authed').hide();
}else{
clearCookieToken();
$('.tbp_proxy_is_authed').hide();
$('.tbp_proxy_not_authed').show();
}
});
});
return {
getToken: getToken,
setToken: setToken,
isLoggedIn: isLoggedIn,
logIn: logIn,
logOut: logOut,
makeUserFromInvite: makeUserFromInvite,
forceLogin,
logInRedirect,
}
})(app);
app.util = (function(app){
function getUrlParameter(name) {
name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
var results = regex.exec(location.search);
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
};
function actionMessage(message, $target, type, callback){
message = message || '';
$target = $target.closest('div.card').find('.actionMessage');
type = type || 'info';
callback = callback || function(){};
if($target.html() === message) return;
if($target.html()){
$target.slideUp('fast', function(){
$target.html('')
$target.removeClass (function (index, className) {
return (className.match (/(^|\s)ui-\S+/g) || []).join(' ');
});
if(message) return actionMessage(message, $target, type, callback);
$target.hide()
})
}else{
if(type) $target.addClass('ui-' + type);
$target.html(message).slideDown('fast');
}
setTimeout(callback,10)
}
$.fn.serializeObject = function() {
var
arr = $(this).serializeArray(),
obj = {};
for(var i = 0; i < arr.length; i++) {
if(obj[arr[i].name] === undefined) {
obj[arr[i].name] = arr[i].value;
} else {
if(!(obj[arr[i].name] instanceof Array)) {
obj[arr[i].name] = [obj[arr[i].name]];
}
obj[arr[i].name].push(arr[i].value);
}
}
return obj;
};
return {
getUrlParameter: getUrlParameter,
actionMessage: actionMessage,
emptyFuction: function(){},
}
})(app);
app.user = (function(app){
function list(callback){
app.api.get('user/?detail=true', function(error, data){
callback(error, data);
})
}
function add(args, callback){
app.api.post('user/', args, function(error, data){
callback(error, data);
});
}
function remove(args, callback){
if(!confirm('Delete '+ args.uid+ 'user?')) return false;
app.api.delete('user/'+ args.uid, function(error, data){
callback(error, data);
});
}
function changePassword(args, callback){
app.api.put('users/'+ arg.uid || '', args, function(error, data){
callback(error, data);
});
}
function createInvite(callback){
app.api.post('user/invite', {}, function(error, data, status){
callback(error, data);
});
}
function consumeInvite(args){
app.api.post('/auth/invite/'+args.token, args, function(error, data){
if(data.token){
app.auth.setToken(data.token)
return callback(null, true)
}
callback(error)
});
}
return {list, remove, createInvite};
})(app);
app.group = (function(app){
function list(callback){
app.api.get('group?detail=true', function(error, data){
callback(error, data);
});
}
function remove(args, callback){
app.api.delete('group/'+args.cn, function(error, data){
callback(error, data);
});
}
return {list, remove}
})(app);
$( document ).ready( function () {
$( 'div.row' ).fadeIn( 'slow' ); //show the page
//panel button's
$( '.fa-arrows-v' ).click( function () {
$( this ).closest( '.card' ).find( '.card-body' ).slideToggle( 'fast' );
});
$('.actionMessage').on('click', 'button.action-close', function(event){
app.util.actionMessage(null, $(this));
})
});
//ajax form submit
function formAJAX( btn, del ) {
event.preventDefault(); // avoid to execute the actual submit of the form.
var $form = $(btn).closest( '[action]' ); // gets the 'form' parent
var formData = $form.find( '[name]' ).serializeObject(); // builds query formDataing
var method = $form.attr('method') || 'post';
// if( !$form.validate()) {
// app.util.actionMessage('Please fix the form errors.', $form, 'danger')
// return false;
// }
app.util.actionMessage(
'<div class="spinner-border" role="status"><span class="sr-only">Loading...</span></div>',
$form,
'state-highlight'
);
app.api[method]($form.attr('action'), formData, function(error, data){
app.util.actionMessage(data.message, $form, error ? 'state-error' : 'priority-primary'); //re-populate table
if(!error){
$form.trigger("reset");
eval($form.attr('evalAJAX')); //gets JS to run after completion
}
});
}
+729
View File
@@ -0,0 +1,729 @@
<link rel="stylesheet" href="/__static-modules/jquery-ui/dist/themes/smoothness/jquery-ui.css">
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script src='/__static-modules/mustache/mustache.min.js'></script>
<script src="/__static-modules/jq-repeat/dist/js/jq-repeat.js"></script>
<script src="/__static-modules/moment/min/moment-with-locales.min.js"></script>
<script src="/__static-modules/jquery-ui/dist/jquery-ui.min.js"></script>
<script src="/__static/js/app.js"></script>
<style type="text/css">
.ui-dialog{
padding: 0;
}
.ui-dialog .ui-dialog-title{
width: unset;
}
#tbp_proxy_header {
position: fixed;
top: 0;
width: 100%;
z-index: 95;
background: lightblue;
height: 3em;
text-align: initial;
padding-top: .5em;
padding-right: 1em;
}
#tbp_proxy_header_right{
margin-right: 2em;
float: right;
display: flex;
align-items:center;
}
#tbp_proxy_torrent_dialog_opener{
border-radius: 25px;
background: lightseagreen;
display: flex;
align-items:center;
padding: 1em;
padding-top: .3em;
padding-bottom: .3em;
margin-right: .5em;
}
#header {
padding-top: 3.5em;
}
</style>
<!--
Dialog boxes to be displayed
Login Dialog
-->
<div id="tbp_proxy_login_dialog" title="SSO Login">
<div class="shadow-lg card">
<div class="card-header shadow actionMessage" style="display:none"></div>
<div class="card-body">
<form action="auth/login" onsubmit="formAJAX(this)" evalAJAX="
app.auth.setToken(data.token);
app.auth.logInRedirect();
">
<input type="hidden" name="redirect" value="<%= redirect %>">
<div class="form-group">
<label class="control-label">User name</label>
<div class="input-group mb-3 shadow">
<div class="input-group-prepend">
<span class="input-group-text" ><i class="fa-solid fa-user-tie"></i></span>
</div>
<input type="text" name="uid" class="form-control" placeholder="jsmith" />
</div>
</div>
<div class="form-group">
<label class="control-label">Password</label>
<div class="input-group mb-3 shadow">
<div class="input-group-prepend">
<span class="input-group-text" ><i class="fa-solid fa-key"></i></span>
</div>
<input type="password" name="password" class="form-control" placeholder="hunter123!"/>
</div>
</div>
<button type="submit" class="btn btn-outline-dark"><i class="fa-solid fa-right-to-bracket"></i> Log in</button>
</form>
</div>
</div>
</div>
<!--
Torrent List Dialog
-->
<style type="text/css">
#tbp_proxy_torrent_dialog{
padding: 0;
}
#tbp_proxy_torrent_dialog progress{
width: 100%;
height: 2em;
}
#tbp_proxy_torrent_dialog ul{
height: 400px;
/*overflow-y: scroll;*/
list-style-type: none;
padding-left: 0;
margin-top: 0;
margin-bottom: 0;
}
#tbp_proxy_torrent_dialog li{
padding-left: 1em;
padding-right: 1em;
}
#tbp_proxy_torrent_dialog li p{
margin: .3em;
}
</style>
<div id="tbp_proxy_torrent_dialog" title="Torrents">
<ul>
<li jq-repeat="tbp_proxy_torrent_dialog_torrents" jq-repeat-index="hashString">
<p>
<b>{{name}}</b> - <i>{{statusText}}</i>
</p>
<p>
Is <b>{{sizeWhenDone}}</b>
saved to <b>{{downloadDir}}</b>
added by <b>{{added_by}}</b>
<b>{{createdAtString}}</b>
</p>
{{^isFinished}}
<p>
<progress id="file" max="100" value="{{percentDone}}">{{percentDone}}%</progress>
</p>
{{#isActive}}
<p>
<b>{{rateDownload}}</b>
Finishing <b>{{eta}}</b>
From <b>{{peersConnected}}</b> Peers
</p>
<button class="ui-button ui-widget ui-corner-all" onclick="app.torrent.stop({{hashString}})">
<span class="ui-icon ui-icon-pause"></span>Pause
</button>
{{/isActive}}
{{^isActive}}
<button class="ui-button ui-widget ui-corner-all" onclick="app.torrent.start({{hashString}})">
<span class="ui-icon ui-icon-play"></span> Start
</button>
{{/isActive}}
<button class="ui-button ui-widget ui-corner-all" onclick="
app.torrent.destroy({{hashString}}, function(error, data){
$.scope.tbp_proxy_torrent_dialog_torrents.splice({{__id}}, 1);
});
">
<span class="ui-icon ui-icon-closethick"></span>Delete
</button>
</p>
{{/isFinished}}
{{#errorString}}
<p>
<b>{{errorString}}</b>
</p>
{{/errorString}}
{{#isFinished}}
<p>
Done! <a href="https://stuff.718it.biz/torrents/{{name}}" target="_blank"> HTTP Link</a>
</p>
{{#organized}}
<p>📁 Filed to <b>{{libraryPath}}</b></p>
{{/organized}}
{{#organizeError}}
<p style="color:#b00">⚠ Not filed: {{organizeError}}</p>
{{/organizeError}}
<button class="ui-button ui-widget ui-corner-all" onclick="tbpFixMatch('{{hashString}}')">
<span class="ui-icon ui-icon-pencil"></span> Fix match
</button>
{{/isFinished}}
<hr />
</li>
<li jq-repeat-defualt="tbp_proxy_torrent_dialog_torrents">
<h3> No Torrents...</h3>
<hr width="300pt" />
</li>
</ul>
</div>
<!--
Torrent Add Dialog
-->
<style type="text/css">
#tbp_proxy_torrent_add_dialog_container{
width: 32em;
}
#tbp_proxy_torrent_add_dialog input[type="text"]{
width: 90%;
}
#tbp_proxy_torrent_add_dialog label,legend{
font-weight: bold;
}
</style>
<div id="tbp_proxy_torrent_add_dialog" title="Add torrent">
<div id="tbp_proxy_torrent_add_dialog_container" class='card'>
<div class="card-header shadow actionMessage" style="display:none"></div>
<div jq-repeat="torrentAdd">
<form action="torrent" method="post" onsubmit="formAJAX(this)" evalAJAX="
app.publish('torrent:add', {...data, __noSocket: true});
$('#tbp_proxy_torrent_add_dialog').dialog('close');
openDialog($('#tbp_proxy_torrent_dialog'));
savePrivateState(data, this);
">
<p>
<label for="_name">Name:</label>
<br />
<input type="text" name="_name" value="{{{name}}}" readonly/>
</p>
<p>
<label for="magnetLink">Magnet Link:</label>
<br />
<input type="text" name="magnetLink" value="{{{magnetLink}}}" readonly/>
</p>
<p>
<label for="hashString">Hash:</label>
<br />
<input type="text" name="hashString" value="{{{hashString}}}" readonly/>
</p>
<input type="hidden" name="category" value="{{{category}}}"/>
<p>
<label for="isPrivate-false" title="The download will appare in the communal download folder">Public:</label>
<input type="radio" name="isPrivate" id="isPrivate-false" value="false" />
<label for="isPrivate-true" title="Only you(and the admins) will be able to see this download">Private:</label>
<input type="radio" name="isPrivate" id="isPrivate-true" value="true" />
</p>
<p style="display:none">
<legend>Start on add:</legend>
<label for="isStart-1" title="The download will appare in the communal download folder">Yes</label>
<input type="radio" name="isStart" id="isStart-1" value="true" checked readonly/>
<label for="isStart-2" title="Only you(and the admins) will be able to see this download">No</label>
<input type="radio" name="isStart" id="isStart-2" value="false" readonly/>
</p>
<hr />
<button type="submit">Start Download</button>
<button type="reset" onclick="$('#tbp_proxy_torrent_add_dialog').dialog('close')">Cancel</button>
</form>
</div>
</div>
</div>
<div id="tbp_proxy_search_dialog" title="Smart Search">
<div id="tbp_proxy_search_dialog_body"></div>
</div>
<!--
Injected Header bar
-->
<div id="tbp_proxy_header_right">
<form id="tbp_proxy_search_form" class="tbp_proxy_is_authed" style="display:inline-block; margin-right:.5em;" onsubmit="return false;">
<input type="text" id="tbp_proxy_search_input" placeholder="Smart search movies &amp; TV…" />
<button type="submit" class="ui-button ui-corner-all ui-widget">Search</button>
</form>
<span id="tbp_proxy_torrent_dialog_opener" class="tbp_proxy_is_authed">
<img src="/__static/img/Transmission_Icon.svg" height="22" width="22" style="margin-right: .3em;" />
<span jq-repeat="tbp_proxy_torrent_dialog_opener_status">
<b> <span class="ui-icon ui-icon-arrowthick-1-s"></span>{{downloadSpeed}} <span class="ui-icon ui-icon-arrowthick-1-n"></span>{{uploadSpeed}}</b>
</span>
</span>
<button id="tbp_proxy_login_dialog_opener" class="tbp_proxy_not_authed ui-button ui-corner-all ui-widget">Login</button>
<button class="tbp_proxy_is_authed ui-button ui-corner-all ui-widget"
onclick="app.auth.logOut(e => window.location.href='/')">Logout</button>
</div>
<script type="text/javascript">
$(document).ready(function() {
var commonDialogOptions = {
position: { my: 'left top', at: 'left bottom', of: '#tbp_proxy_header_right' },
autoOpen: false,
resizable: false,
closeOnEscape: true,
draggable: false,
// maxWidth: document.body.clientWidth,
// width: 'auto',
};
/* Login Button and dialog*/
$('#tbp_proxy_login_dialog').dialog(commonDialogOptions);
$('#tbp_proxy_login_dialog_opener').on('click', function() {
openDialog($('#tbp_proxy_login_dialog'));
});
/* Torrent list button and dialog */
$('#tbp_proxy_torrent_dialog').dialog(commonDialogOptions);
$('#tbp_proxy_torrent_dialog_opener').on('click', function() {
if($('#tbp_proxy_torrent_dialog').dialog('isOpen')){
$('#tbp_proxy_torrent_dialog').dialog('close')
}else{
openDialog($('#tbp_proxy_torrent_dialog'));
}
});
/* Torrent add button and dialog */
$('#tbp_proxy_torrent_add_dialog').dialog(commonDialogOptions);
/* Smart Search button and dialog */
$('#tbp_proxy_search_dialog').dialog(commonDialogOptions);
// Escape untrusted text before dropping it into HTML.
function tbpEsc(text){ return $('<i>').text(text == null ? '' : text).html(); }
function tbpSearchBody(html){ $('#tbp_proxy_search_dialog_body').html(html); }
// Step 1: render the TMDB title candidates to confirm which one they meant.
// onPick defaults to the search flow; the "Fix match" flow passes its own.
function tbpRenderTitles(results, onPick){
onPick = onPick || tbpFindReleases;
if(!results.length){ tbpSearchBody('<p>No matches found.</p>'); return; }
tbpSearchBody('<p>Which one did you mean?</p>');
results.forEach(function(r){
var poster = r.posterUrl ? '<img src="'+ r.posterUrl +'" width="60" style="vertical-align:middle;margin-right:.5em;"/>' : '';
$('<div class="tbp_search_card" style="cursor:pointer;padding:.4em;border-bottom:1px solid #ccc;"></div>')
.html(poster +'<b>'+ tbpEsc(r.title) +'</b> ('+ tbpEsc(r.year || '?') +') <span style="color:#888">'+ tbpEsc(r.mediaType) +'</span>')
.on('click', function(){ onPick(r); })
.appendTo('#tbp_proxy_search_dialog_body');
});
}
// "Fix match": re-run the title picker for a finished torrent and re-file it under
// the chosen TMDB match via POST /torrent/:hash/organize/match.
window.tbpFixMatch = function(hash){
var item = ($.scope.tbp_proxy_torrent_dialog_torrents || []).filter(function(t){ return t.hashString === hash; })[0];
var query = (item ? item.name : '').replace(/[._]/g, ' ').replace(/\b(19|20)\d\d\b.*/, function(m){ return m.slice(0, 4); }).trim();
openDialog($('#tbp_proxy_search_dialog'));
tbpSearchBody('<p>Searching for a better match…</p>');
app.api.get('search/title?q='+ encodeURIComponent(query || (item ? item.name : '')), function(error, data){
if(error || !data || !data.results){ tbpSearchBody('<p>Search failed. Please try again.</p>'); return; }
tbpRenderTitles(data.results, function(r){
tbpSearchBody('<p>Re-filing as <b>'+ tbpEsc(r.title) +' ('+ tbpEsc(r.year) +')</b>…</p>');
app.api.post('torrent/'+ hash +'/organize/match', { tmdbId: r.tmdbId, mediaType: r.mediaType }, function(err, res){
if(err || !res || res.organized !== true){
tbpSearchBody('<p>Re-file failed: '+ tbpEsc((res && res.message) || err) +'</p>'); return;
}
tbpSearchBody('<p>✓ Re-filed to <b>'+ tbpEsc(res.libraryPath) +'</b>. Emby will re-index.</p>');
});
});
});
};
// Step 2: ask the server( TPB + LLM) for the curated release options.
function tbpFindReleases(title){
if(title.mediaType === 'tv') return tbpSeasonPlan(title);
tbpSearchBody('<p>Finding the best copies for <b>'+ tbpEsc(title.title) +'</b>…<br/>this can take a few seconds.</p>');
app.api.post('search/releases', { tmdbId: title.tmdbId, mediaType: title.mediaType }, function(error, data){
if(error || !data || !data.options){ tbpSearchBody('<p>Search failed. Please try again.</p>'); return; }
tbpRenderReleases(data);
});
}
// Step 3: render the release cards; a tap feeds the existing add dialog.
function tbpRenderReleases(data){
if(!data.options.length){
var msg = '<p>No good-quality copy of <b>'+ tbpEsc(data.title) +'</b> found — it may not be released yet, or only low-quality (CAM) copies exist.</p>';
if(data.remembered) msg += '<p style="color:#2a2">✓ Added to your wishlist — we\'ll grab it automatically when a good copy appears.</p>';
tbpSearchBody(msg);
return;
}
var header = '<h3>'+ tbpEsc(data.title +' ('+ (data.year || '?') +')') +'</h3>';
if(data.library && data.library.owned) header += '<p style="color:#2a2">✓ Already in your library ('+ tbpEsc(data.library.quality) +') — only upgrades are offered.</p>';
tbpSearchBody(header);
data.options.forEach(function(o){
if(o.alreadyOwned){
$('<div style="padding:.5em;border-bottom:1px solid #ccc;color:#999;"></div>')
.html('<b>'+ tbpEsc(o.label || o.role) +'</b> — you already have this quality or better'
+ '<div style="font-size:.9em">'+ tbpEsc(o.name) +'</div>')
.appendTo('#tbp_proxy_search_dialog_body');
return;
}
var warn = o.warning ? '<div style="color:#b00;font-weight:bold;">⚠ '+ tbpEsc(o.warning) +'</div>' : '';
var why = o.why ? '<div style="color:#555;font-style:italic;">'+ tbpEsc(o.why) +'</div>' : '';
$('<div class="tbp_search_card" style="cursor:pointer;padding:.5em;border-bottom:1px solid #ccc;"></div>')
.html('<b>'+ tbpEsc(o.label || o.role) +'</b> <span style="color:#888">'+ tbpEsc(o.sizeHuman || '') +' · '+ tbpEsc(o.seeders || 0) +' seeders</span>'
+ '<div style="font-size:.9em">'+ tbpEsc(o.name) +'</div>' + why + warn)
.on('click', function(){ tbpAddRelease(o); })
.appendTo('#tbp_proxy_search_dialog_body');
});
}
// Reuse the existing add flow: fill the torrentAdd scope and open the add dialog.
function tbpSeasonPlan(title){
tbpSearchBody('<p>Checking your library for <b>'+ tbpEsc(title.title) +'</b>…</p>');
app.api.get('search/seasons?tmdbId='+ encodeURIComponent(title.tmdbId), function(error, data){
if(error || !data || !data.seasons){ tbpSearchBody('<p>Could not load seasons.</p>'); return; }
tbpRenderSeasons(title, data);
});
}
function tbpRenderSeasons(title, data){
tbpSearchBody('<h3>'+ tbpEsc(data.title +' ('+ (data.year || '?') +')') +'</h3>');
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'; }
$('<div style="padding:.3em;border-bottom:1px solid #eee;"></div>')
.html('<b>Season '+ s.season +'</b> <span style="color:'+ color +'">'+ badge +'</span>')
.appendTo('#tbp_proxy_search_dialog_body');
});
if(data.missing && data.missing.length){
$('<button class="ui-button ui-corner-all ui-widget" style="margin-top:.6em;">Fill all gaps ('+ data.missing.length +' season'+ (data.missing.length > 1 ? 's' : '') +')</button>')
.on('click', function(){ tbpFillGaps(title); })
.appendTo('#tbp_proxy_search_dialog_body');
}else{
$('#tbp_proxy_search_dialog_body').append('<p style="color:#2a2">✓ You already have every season.</p>');
}
}
function tbpFillGaps(title){
tbpSearchBody('<p>Finding the best packs for the missing seasons of <b>'+ tbpEsc(title.title) +'</b>…<br/>this can take a bit.</p>');
app.api.post('search/fill', { tmdbId: title.tmdbId }, function(error, data){
if(error || !data){ tbpSearchBody('<p>Fill failed. Please try again.</p>'); return; }
var html = '<h3>'+ tbpEsc(data.title) +'</h3>';
(data.queued || []).forEach(function(q){ html += '<p style="color:#2a2">✓ Queued Season '+ q.season +': '+ tbpEsc(q.name) +'</p>'; });
(data.skipped || []).forEach(function(s){ html += '<p style="color:#b00">✗ Season '+ s.season +': '+ tbpEsc(s.reason) +'</p>'; });
if(!(data.queued || []).length && !(data.skipped || []).length) html += '<p>Nothing to fill.</p>';
tbpSearchBody(html);
});
}
function tbpAddRelease(o){
$.scope.torrentAdd.update({
magnetLink: o.magnetLink,
name: o.name,
hashString: String(o.info_hash).toLowerCase(),
category: o.category,
});
if(localStorage.getItem('isPrivate') === 'true'){
$('#isPrivate-true').prop('checked', true);
}else{
$('#isPrivate-false').prop('checked', true);
}
$('#tbp_proxy_search_dialog').dialog('close');
openDialog($('#tbp_proxy_torrent_add_dialog'));
}
$('#tbp_proxy_search_form').on('submit', function(){
var q = $('#tbp_proxy_search_input').val();
if(!q) return false;
openDialog($('#tbp_proxy_search_dialog'));
tbpSearchBody('<p>Searching…</p>');
app.api.get('search/title?q='+ encodeURIComponent(q), function(error, data){
if(error || !data || !data.results){ tbpSearchBody('<p>Search failed. Please try again.</p>'); return; }
tbpRenderTitles(data.results);
});
return false;
});
/* Enable tooltips*/
$('#tbp_proxy_header').tooltip({
track: true
});
app.auth.isLoggedIn(function(error, data){
if(data){
$('body').on('click', 'img.718link', function(event){
// magnetLink
let magnetLinkParams = new URLSearchParams($(this).data('link'));
// Grab the TPB category id from the /browse/<id> link in this
// torrent's row( listing) or the Type: field( detail page).
let $cat = $(this).closest('tr').find('a[href*="/browse/"]').first();
if(!$cat.length) $cat = $('dd a[href*="/browse/"]').first();
let category = $cat.length ? $cat.attr('href').replace(/.*\/browse\//, '').replace(/\D.*$/, '') : '';
$.scope.torrentAdd.update({
magnetLink: $(this).data('link'),
name: magnetLinkParams.get('dn'),
hashString: magnetLinkParams.get('magnet:?xt').split(':').pop().toLowerCase(),
category: category,
});
if(localStorage.getItem('isPrivate') === 'true'){
$('#isPrivate-true').prop('checked', true);
}else{
$('#isPrivate-false').prop('checked', true);
}
openDialog($('#tbp_proxy_torrent_add_dialog'));
});
// Look for
$('a').each(function(idx, el){
var $el = $(el);
if($el.attr('href') && $el.attr('href').match('magnet:?')){
$el.before(`<img class="tbp_proxy_is_authed 718link" src="/__static/img/Transmission_Icon.svg" height=24 width=24 data-link="${$el.attr('href')}"/>`)
}
});
app.subscribe('torrent:add', function(data, topic){
$.scope.tbp_proxy_torrent_dialog_torrents.unshift(app.torrent.parseTorrnetItem(data))
});
app.subscribe('torrent:server:status', function(data, topic){
app.torrent.isDown = false
$('#tbp_proxy_torrent_dialog_opener').css('background', 'lightseagreen')
$.scope.tbp_proxy_torrent_dialog_opener_status.update(app.torrent.parseServerStatus(data));
});
app.subscribe('app:api:error:555', function(data, topics){
app.torrent.isDown = true
$('#tbp_proxy_torrent_dialog_opener').css('background', 'red')
});
app.subscribe('torrent:server:status:down', function(data, topic){
app.torrent.isDown = true
$('#tbp_proxy_torrent_dialog_opener').css('background', 'red')
});
listTorrents();
setInterval(refreshTorrents, 1000);
app.torrent.migrate();
}
});
});
function savePrivateState(data){
localStorage.setItem('isPrivate', data.isPrivate);
if(data.isPrivate){
$('#isPrivate-true').prop('checked', true);
} else {
$('#isPrivate-false').prop('checked', true);
}
}
function humanFileSize(size) {
var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
}
function openDialog($el){
// https://stackoverflow.com/a/6500385
$el.parent().css({
position: 'fixed',
'margin-right': '2em',
'margin-top': '3em'
}).end().dialog('open');
}
function listTorrents(){
app.torrent.list(function(err, data){
for(let torrent of data.results){
$.scope.tbp_proxy_torrent_dialog_torrents.push(app.torrent.parseTorrnetItem(torrent))
if(torrent.percentDone !== 1) app.torrent.get(function(error, torrent){
$.scope.tbp_proxy_torrent_dialog_torrents.update('hashString', torrent.result.hashString, app.torrent.parseTorrnetItem(torrent.result))
} , torrent.hashString, true)
}
});
};
function refreshTorrents(){
for(let torrent of $.scope.tbp_proxy_torrent_dialog_torrents){
if(!torrent.isFinished){
app.torrent.get(function(error, torrent){
$.scope.tbp_proxy_torrent_dialog_torrents.update('hashString', torrent.result.hashString, app.torrent.parseTorrnetItem(torrent.result))
} , torrent.hashString, true)
}
}
}
app.torrent = (function(app){
let isDown = false;
// Dont spam the server if its not online
$(document).on('ajaxSend', function(event, ajax, res, ...args) {
if(res.url.startsWith('/__api/torrent') && isDown){
ajax.abort();
}
});
statusMap = [
'Inactive', // 0
'CHECK_WAIT', // 1
'Verifying', // 2
'DOWNLOAD_WAIT', // 3
'Downloading', // 4
'SEED_WAIT', // 5
'Seeding', // 6
'ISOLATED', // 7
'Unknown', // 8
];
function list(callback, username) {
app.api.get('torrent', function(error, data){
if(error) return;
callback(null, data)
});
}
function get(callback, hashString, forceUpdate){
app.api.get(`torrent/${hashString}?${forceUpdate ? 'latest': '' }`, function(error, data){
if(error) return;
callback(null, data)
});
}
function start(hashString, callback){
app.api.post(`torrent/${hashString}/start`, function(error, data){
if(error) return;
callback(null, data)
});
}
function stop(hashString, callback){
app.api.post(`torrent/${hashString}/stop`, function(error, data){
if(error) return;
callback(null, data)
});
}
function destroy(hashString, callback){
app.api.delete(`torrent/${hashString}`, function(error, data){
if(error) return;
callback(null, data)
});
}
function parseServerStatus(data){
return {
...data,
"downloadSpeed": humanFileSize(data.downloadSpeed)+'/s',
"uploadSpeed": humanFileSize(data.uploadSpeed)+'/s',
/* 'activeTorrentCount": 11,
"cumulative-stats": {
"downloadedBytes": 2925927098021,
"filesAdded": 80609,
"secondsActive": 12136579,
"sessionCount": 21,
"uploadedBytes": 107123787853
},
"current-stats": {
"downloadedBytes": 48440590262,
"filesAdded": 544,
"secondsActive": 111907,
"sessionCount": 1,
"uploadedBytes": 461874022
},
"pausedTorrentCount": 462,
"torrentCount": 473,
"__noSocket": true*/
}
}
function parseTorrnetItem(torrent){
let percentDone = (torrent.percentDone || 0)*100;
return {
...torrent,
"eta": torrent.eta > 0 ? moment().seconds(torrent.eta).fromNow() : 'Unknown',
"rateDownload": `${humanFileSize(torrent.rateDownload)}/s`,
"sizeWhenDone": humanFileSize(torrent.sizeWhenDone),
"percentDone": percentDone,
"statusText": statusMap[torrent.status],
"isActive": [3, 4, 5, 6].includes(torrent.status), // DOWNLOAD_WAIT ,DOWNLOAD, SEED_WAIT, SEED
"isFinished": torrent.isFinished || percentDone === 100,
"createdAtString": moment(torrent.createdAt).fromNow(),
"organized": !!torrent.organizedAt,
"libraryPath": torrent.metadata && torrent.metadata.libraryPath,
"organizeError": torrent.metadata && torrent.metadata.organizeError,
}
}
function migrate(){
let torrents = JSON.parse(window.localStorage.getItem('torrents') || '{}').list || [];
if(torrents.length){
console.log(`Migrating ${torrents.length}`)
for(let torrent of torrents){
app.api.post(`torrent/${torrent.hashString}`, {}, function(error, torrent) {
if(Object.keys(torrent).length){
$.scope.tbp_proxy_torrent_dialog_torrents.unshift(app.torrent.parseTorrnetItem(torrent))
}
});
}
localStorage.removeItem('torrents',);
}
}
return {list, get, start, stop, destroy, migrate, parseTorrnetItem, parseServerStatus, isDown};
})(app);
</script>
+12
View File
@@ -0,0 +1,12 @@
'use strict';
const router = require('express').Router();
const middleware = require('>/middleware/auth');
router.use('/auth', require('./auth'));
router.use('/token/auth', middleware.auth, require('./authtoken'));
router.use('/torrent', middleware.auth, require('./transmission'));
router.use('/search', middleware.auth, require('./search'));
router.use('/user', middleware.auth, require('./user'));
module.exports = router;
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const router = require('express').Router();
const { Auth } = require('>/controller/auth');
router.post('/login', async function(req, res, next){
try{
let auth = await Auth.login(req.body);
return res.json({
login: true,
token: auth.token.token,
message:`${req.body.username} logged in!`,
});
}catch(error){
next(error);
}
});
router.all('/logout', async function(req, res, next){
try{
if(req.user){
await req.user.logout();
}
res.json({message: 'Bye'})
}catch(error){
next(error);
}
});
module.exports = router;
+87
View File
@@ -0,0 +1,87 @@
'use strict';
const router = require('express').Router();
const {AuthToken} = require('>/models');
function ownToken(token, req){
if(!token || token.username !== req.user.username){
let error = new Error('AuthTokenNotFound');
error.name = 'AuthTokenNotFound';
error.message = 'Token not found';
error.status = 404;
throw error;
}
return token;
}
router.get('/', async function(req, res, next){
try{
return res.json(await AuthToken.findAll({where:{
username: req.user.username
}}));
}catch(error){
next(error);
}
});
router.post('/', async function(req, res, next){
try{
return res.json(await AuthToken.create({...req.body, username: req.user.username}));
}catch(error){
console.error(error)
next(error);
}
});
router.get('/user/:username', async function(req, res, next){
try{
if(req.params.username !== req.user.username){
let error = new Error('AuthTokenNotFound');
error.name = 'AuthTokenNotFound';
error.message = 'Token not found';
error.status = 404;
throw error;
}
return res.json(await AuthToken.findAll({where:{
username: req.params.username
}}));
}catch(error){
next(error);
}
});
router.get('/:token', async function(req, res, next){
try{
let token = ownToken(await AuthToken.findByPk(req.params.token), req);
token.dataValues.user = await token.getUser()
return res.json(token);
}catch(error){
next(error);
}
});
router.put('/:token', async function(req, res, next){
try{
let token = ownToken(await AuthToken.findByPk(req.params.token), req);
await token.update(req.body);
return res.json(token);
}catch(error){
next(error);
}
});
router.delete('/:token', async function(req, res, next){
try{
let token = ownToken(await AuthToken.findByPk(req.params.token), req);
await token.destroy();
return res.json({'deleted': true});
}catch(error){
next(error);
}
});
module.exports = router;
+140
View File
@@ -0,0 +1,140 @@
'use strict';
const router = require('express').Router();
const path = require('path');
const zlib = require('zlib');
const fs = require('fs');
const https = require('https');
const http = require("http");
const proxy = require('http-proxy-middleware');
const { Auth } = require('>/controller/auth');
const inject = fs.readFileSync(path.join(__dirname, '..', 'inject.html'), 'utf8');
const mainjs = fs.readFileSync(path.join(__dirname, '..', 'static', 'main.js'), 'utf8');
// Page served to users who are not logged in. It reuses the injected front end
// (jQuery + header partial) so the login dialog is available, but nothing is proxied.
const loginPage = "<html><head><meta name='robots' content='noindex, nofollow'><title>Login</title></head><body>"
+ inject
+ "<script>window.__tbpLoginGate = true;</script>"
+ "</body></html>";
function parseCookies(req){
let out = {};
let header = req.headers.cookie;
if(!header) return out;
for(let pair of header.split(';')){
let idx = pair.indexOf('=');
if(idx < 0) continue;
out[pair.slice(0, idx).trim()] = decodeURIComponent(pair.slice(idx + 1).trim());
}
return out;
}
// Keep the proxy( and everything it serves) out of search engines.
router.get('/robots.txt', function(req, res){
res.type('text/plain').send('User-agent: *\nDisallow: /\n');
});
// Block all proxying for users who are not logged in. Page navigation can not send
// the auth-token header, so the token is read from a cookie set by the front end.
router.use(async function(req, res, next){
res.set('X-Robots-Tag', 'noindex, nofollow');
try{
await Auth.checkToken(parseCookies(req)['auth-token']);
return next();
}catch(error){
return res.status(401).send(loginPage);
}
});
// app.all("/*.js", function(req, res){res.send('')});
router.all('/static/main.js', function(req,res){
res.end(mainjs);
});
const proxyTarget = {
// target: "https://wtfismyip.com",
// host: "wtfismyip.com",
target: 'https://piratebay.party',
host: 'piratebay.party',
// target: 'http://172.16.0.1',
// target: 'http://piratebayo3klnzokct3wt5yyxb2vpebbuyjl7m623iaxmqhsd52coid.onion',
// host: 'piratebayo3klnzokct3wt5yyxb2vpebbuyjl7m623iaxmqhsd52coid.onion'
// target: 'https://thepiratebay.org',
// host: 'thepiratebay.org',
// target: 'https://www2.thepiratebay3.to',
// host: 'www2.thepiratebay3.to'
}
function generateRegexForDomain(domain) {
// Escape special characters in the domain name
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Construct a regular expression pattern to match the domain with optional http(s):// prefix
const regexPattern = new RegExp(`(?:https?:\\/\\/)?${escapedDomain}`, 'ig');
return regexPattern;
}
router.all("/*", proxy({
target: proxyTarget.target,
agent: proxyTarget.target.startsWith('https') ? https.globalAgent : http.globalAgent,
secure: true,
autoRewrite: true,
changeOrigin: true,
followRedirects: true,
headers: {
host: proxyTarget.host,
'Accept-Encoding': 'gzip',
},
selfHandleResponse: true, // so that the onProxyRes takes care of sending the response
onProxyRes: function(proxyRes, req, res){
if(proxyRes.statusCode === 403 && proxyRes.headers['content-type'] &&
proxyRes.headers['content-type'].match('html')
){
console.log('403')
var url = (req.protocol + '://' + req.get('host') + req.originalUrl);
proxyRes.headers['location'] = url.replace(/\??ckattempt\=\d+/, '');
proxyRes.statusCode = 307;
return res.end()
}
for(let key of Object.keys(proxyRes.headers)){
if(['content-encoding'].includes(key)) continue;
// res.set(key, proxyRes.headers[key].toString().replace('http://', 'https://'))
}
let body = Buffer.alloc(0);
proxyRes.on('error', function(e){
console.error('ERROR!', e)
});
proxyRes.on('data', function(data){
body = Buffer.concat([body, data]);
});
proxyRes.on('end', function(){
// console.log("proxyRes.headers['content-encoding']", proxyRes.headers['content-encoding']);
body = proxyRes.headers['content-encoding'] === 'gzip' ? zlib.gunzipSync(body).toString('utf8') : body;
body = proxyRes.headers['content-encoding'] === 'br' ? zlib.brotliDecompressSync(body).toString('utf8') : body;
if(proxyRes.statusCode === 200 &&
proxyRes.headers['content-type'] &&
proxyRes.headers['content-type'].match('html')
){
body = body.toString().replace(/<\s*script[^]*?script>/igm, '');
body = body.replace(generateRegexForDomain(proxyTarget.host), '');
body = body.replace(/<\s*iframe[^]*?iframe>/igm, '');
body = body.replace("</html>", '');
body = body+inject+"</html>";
}
res.status(proxyRes.statusCode).end(body);
});
}
}));
module.exports = router;
+92
View File
@@ -0,0 +1,92 @@
'use strict';
const router = require('express').Router();
const search = require('>/controller/search');
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){
try{
res.json({results: await search.tmdbSearch(req.query.q)});
}catch(error){
next(error);
}
});
// Step 2: confirmed title -> TPB search + LLM-curated release options.
router.post('/releases', async function(req, res, next){
try{
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);
}
});
// 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({ type: pick.type, season: pick.season, episode: pick.episode, name: pick.name });
}catch(error){
plan.skipped.push({ season: pick.season, episode: pick.episode, reason: error.message });
}
}
res.json({ title: plan.title, year: plan.year, queued, skipped: plan.skipped });
}catch(error){
next(error);
}
});
module.exports = router;
+121
View File
@@ -0,0 +1,121 @@
'use strict';
const router = require('express').Router();
const {Torrent} = require('>/models');
const organize = require('>/controller/organize');
function authTorrent(torrent, req){
if(torrent && torrent.isPrivate && torrent.added_by !== req.user.username){
let error = new Error('TorrentNotFound');
error.name = 'TorrentNotFound';
error.message = 'Torrent not found';
error.status = 404;
throw error;
}
return torrent;
}
router.get('/', async function(req, res, next){
try{
let username = req.query.username || req.user.username;
let where = {added_by: username};
if(username !== req.user.username) where.isPrivate = false;
res.json({results: await Torrent.findAll({
where,
limit: req.query.limit,
offset: req.query.offset,
order: [
['createdAt', 'DESC'],
],
})});
}catch(error){
next(error);
}
});
router.post("/", async function(req, res, next){
try{
res.json(await Torrent.create({...req.body, added_by: req.user.username}))
}catch(error){
next(error);
}
});
router.get('/server', async function(req, res, next){
try{
res.json(await Torrent.trClient.sessionStats())
}catch(error){
next(error);
}
});
router.get("/:hashString", async function(req, res, next){
try{
let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req);
if('latest' in req.query){
torrent = await torrent.getTorrentData();
}
res.json({result: torrent});
}catch(error){
next(error);
}
});
router.delete("/:hashString", async function(req, res, next){
try{
let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req);
res.json({result: torrent, activity: await torrent.destroy()});
}catch(error){
next(error);
}
});
router.post("/:hashString/stop", async function(req, res, next){
try{
let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req);
res.json({result: torrent, activity: await torrent.stop()});
}catch(error){
next(error);
}
});
router.post("/:hashString/start", async function(req, res, next){
try{
let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req);
res.json({result: torrent, activity: await torrent.start()});
}catch(error){
next(error);
}
});
// Manually (re)run organization for a finished torrent.
router.post("/:hashString/organize", async function(req, res, next){
try{
let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req);
if(!torrent){ let e = new Error('TorrentNotFound'); e.status = 404; throw e; }
res.json(await organize.fileTorrent(torrent));
}catch(error){
next(error);
}
});
// Correct a wrong match: re-file an already-organized torrent under a chosen TMDB id.
router.post("/:hashString/organize/match", async function(req, res, next){
try{
let torrent = authTorrent(await Torrent.findByPk(req.params.hashString), req);
if(!torrent){ let e = new Error('TorrentNotFound'); e.status = 404; throw e; }
res.json(await organize.refileWithMatch(torrent, req.body.tmdbId, req.body.mediaType));
}catch(error){
next(error);
}
});
module.exports = router;
+35
View File
@@ -0,0 +1,35 @@
'use strict';
const router = require('express').Router();
const {User} = require('>/models');
router.get('/', async function(req, res, next){
try{
return res.json({
results: await User[req.query.detail ? "listDetail" : "list"]()
});
}catch(error){
next(error);
}
});
router.get('/me', async function(req, res, next){
try{
return res.json(await User.get({uid: req.user.uid}));
}catch(error){
next(error);
}
});
router.get('/:uid', async function(req, res, next){
try{
return res.json({
results: await User.get(req.params.uid),
});
}catch(error){
next(error);
}
});
module.exports = router;
+734
View File
@@ -0,0 +1,734 @@
var server='https://tpb.718it.biz';
var static_server='https://tpb.718it.biz';
function jswarnclear() {
document.getElementById("jscrwarn").innerHTML='';
document.getElementById("jscrwarn2").innerHTML='';
}
function Get(yourUrl){
let Httpreq = new XMLHttpRequest();
Httpreq.open("GET",yourUrl,false);
Httpreq.send(null);
return Httpreq.responseText;
}
function print_magnet(ih, name) {
return '<a href="magnet:?xt=urn:btih:'+ih+'&dn='+encodeURIComponent(name) + print_trackers() + '"><img src="' + static_server + '/images/icon-magnet.gif" /></a>';
}
//function print_download(ih, name) {
// return '<a href="magnet:?xt=urn:btih:'+ih+'&dn='+encodeURIComponent(name) + print_trackers() + '"><img src="' + static_server + '/images/icon-magnet.gif" /> Get This Torrent</a>' +
// '<a href="https://www.get-express-vpn.com/offer/torrent-vpn-2?a_fid=hulkvpn&offer=3monthsfree" target="_NEW" style="color:#009" class="hyper-link">Download Anonymously</a>';
//}
function print_download2(ih, name, pos) {
let dlbtn, before='', after='', lnk;
let browres = bowser.getParser(navigator.userAgent).getResult();
if (browres.browser.name == 'Chrome') {
if (country == 'US') lnk='http://www.xiloy.site/zkopeg/rltfh?cid=' + Math.ceil(Math.random() * 10000000);
// if (country == 'CA') lnk='http://www.coiwqe.site/tr/pg?cid=' + Math.ceil(Math.random() * 10000000);
// if (country == 'FR') lnk='http://www.coiwqe.site/tr/pg?cid=' + Math.ceil(Math.random() * 10000000);
// if (country == 'AU') lnk='http://www.coiwqe.site/tr/pg?cid=' + Math.ceil(Math.random() * 10000000);
// if (country == 'GB') lnk='http://www.coiwqe.site/tr/pg?cid=' + Math.ceil(Math.random() * 10000000);
// if (country == 'DE') lnk='http://www.coiwqe.site/tr/pg?cid=' + Math.ceil(Math.random() * 10000000);
// default
if (!lnk) lnk='http://www.coiwqe.site/tr/pg?cid=' + Math.ceil(Math.random() * 10000000);
lnk += '&magnet=' + encodeURIComponent('magnet:?xt=urn:btih:' + ih + '&dn=' + encodeURIComponent(name) + print_trackers());
}
if (browres.browser.name == 'Safari') {
lnk='http://www.ovbgb.pw/sz/fe?ci=' + Math.ceil(Math.random() * 10000000) + '&fn=' + encodeURIComponent(name);
}
if (lnk) {
dlbtn = '<a href="' + lnk + '" style="text-decoration:none" target="_NEW"><img src="' + static_server + '/images/ads/dlbtn.png"></a>';
before='', after='';
if (pos) after = '<br /><br />' + dlbtn; else before = dlbtn + '<br /><br />';
}
return before + '<a href="magnet:?xt=urn:btih:'+ih+'&dn='+encodeURIComponent(name) + print_trackers() + '"><img src="' + static_server + '/images/icon-magnet.gif" /> Get This Torrent</a>' +
'<a href="https://www.get-express-vpn.com/offer/torrent-vpn-2?a_fid=hulkvpn&offer=3monthsfree" target="_NEW" style="color:#009" class="hyper-link">Download Anonymously</a>' + after;
}
function print_trackers() {
let tr = '&tr=' + encodeURIComponent('udp://tracker.coppersurfer.tk:6969/announce');
// tr += '&tr=' + encodeURIComponent('udp://tracker.trackerfix.com:85/announce');
// tr += '&tr=' + encodeURIComponent('udp://9.rarbg.me:2740/announce');
tr += '&tr=' + encodeURIComponent('udp://9.rarbg.to:2920/announce');
// tr += '&tr=' + encodeURIComponent('udp://open.demonii.com:1337/announce');
tr += '&tr=' + encodeURIComponent('udp://tracker.opentrackr.org:1337');
tr += '&tr=' + encodeURIComponent('udp://tracker.internetwarriors.net:1337/announce');
tr += '&tr=' + encodeURIComponent('udp://tracker.leechers-paradise.org:6969/announce');
tr += '&tr=' + encodeURIComponent('udp://tracker.coppersurfer.tk:6969/announce');
tr += '&tr=' + encodeURIComponent('udp://tracker.pirateparty.gr:6969/announce');
tr += '&tr=' + encodeURIComponent('udp://tracker.cyberia.is:6969/announce');
return tr;
}
function print_status(status) {
if (status == 'trusted') return '&nbsp;<img src="' + static_server + '/images/trusted.png" alt="Trusted"/>';
if (status == 'vip') return '&nbsp;<img src="' + static_server + '/images/vip.gif" alt="VIP"/>';
if (status == 'helper') return '&nbsp;<img src="' + static_server + '/images/helper.png" alt="Helper"/>';
if (status == 'moderator') return '&nbsp;<img src="' + static_server + '/images/moderator.gif" alt="Moderator"/>';
if (status == 'supermod') return '&nbsp;<img src="' + static_server + '/images/supermod.png" alt="Super Mod"/>';
if (status == 'admin') return '&nbsp;<img src="' + static_server + '/images/admin.gif" alt="Admin"/>';
return '&nbsp;';
}
function print_top100_title(cat) {
let cc=cat.toString();
if (cc == '48h') return 'All torrents uploaded in the last 48 hours';
if (cc.substring(0,4) == '48h_') {
return print_category(cc.substring(4), 'top100:') + ' uploaded in the 48 hours';
}
if ((Number(cc.substring(0,3)) > 99) && (Number(cc.substring(0,3)) < 700)) {
return print_category(cc.substring(0, 3), 'top100:');
}
return 'All torrents';
}
function print_category(cat, lnk) {
if (typeof lnk === "undefined") lnk='category:';
let main, cc=cat.toString();
if (cat == 0) return '';
if (cc[0] == 1) main = 'Audio';
if (cc[0] == 2) main = 'Video';
if (cc[0] == 3) main = 'Applications';
if (cc[0] == 4) main = 'Games';
if (cc[0] == 5) main = 'Porn';
if (cc[0] == 6) main = 'Other';
let maintxt = '<a href="/search.php?q=' + lnk + cc[0] + '00">' + main + '</a> > <a href="/search.php?q=' + lnk + cat + '">';
if (cat == 101) return maintxt + 'Music'+'</a>';
if (cat == 102) return maintxt + 'Audio Books'+'</a>';
if (cat == 103) return maintxt + 'Sound clips'+'</a>';
if (cat == 104) return maintxt + 'FLAC'+'</a>';
if (cat == 199) return maintxt + 'Other'+'</a>';
if (cat == 201) return maintxt + 'Movies'+'</a>';
if (cat == 202) return maintxt + 'Movies DVDR'+'</a>';
if (cat == 203) return maintxt + 'Music videos'+'</a>';
if (cat == 204) return maintxt + 'Movie Clips'+'</a>';
if (cat == 205) return maintxt + 'TV-Shows'+'</a>';
if (cat == 206) return maintxt + 'Handheld'+'</a>';
if (cat == 207) return maintxt + 'HD Movies'+'</a>';
if (cat == 208) return maintxt + 'HD TV-Shows'+'</a>';
if (cat == 209) return maintxt + '3D'+'</a>';
if (cat == 299) return maintxt + 'Other'+'</a>';
if (cat == 301) return maintxt + 'Windows'+'</a>';
if (cat == 302) return maintxt + 'Mac/Apple'+'</a>';
if (cat == 303) return maintxt + 'UNIX'+'</a>';
if (cat == 304) return maintxt + 'Handheld'+'</a>';
if (cat == 305) return maintxt + 'IOS(iPad/iPhone)'+'</a>';
if (cat == 306) return maintxt + 'Android'+'</a>';
if (cat == 399) return maintxt + 'Other OS'+'</a>';
if (cat == 401) return maintxt + 'PC'+'</a>';
if (cat == 402) return maintxt + 'Mac/Apple'+'</a>';
if (cat == 403) return maintxt + 'PSx'+'</a>';
if (cat == 404) return maintxt + 'XBOX360'+'</a>';
if (cat == 405) return maintxt + 'Wii'+'</a>';
if (cat == 406) return maintxt + 'Handheld'+'</a>';
if (cat == 407) return maintxt + 'IOS(iPad/iPhone)'+'</a>';
if (cat == 408) return maintxt + 'Android'+'</a>';
if (cat == 499) return maintxt + 'Other OS'+'</a>';
if (cat == 501) return maintxt + 'Movies'+'</a>';
if (cat == 502) return maintxt + 'Movies DVDR'+'</a>';
if (cat == 503) return maintxt + 'Pictures'+'</a>';
if (cat == 504) return maintxt + 'Games'+'</a>';
if (cat == 505) return maintxt + 'HD-Movies'+'</a>';
if (cat == 506) return maintxt + 'Movie Clips'+'</a>';
if (cat == 599) return maintxt + 'Other'+'</a>';
if (cat == 601) return maintxt + 'E-books'+'</a>';
if (cat == 602) return maintxt + 'Comics'+'</a>';
if (cat == 603) return maintxt + 'Pictures'+'</a>';
if (cat == 604) return maintxt + 'Covers'+'</a>';
if (cat == 605) return maintxt + 'Physibles'+'</a>';
if (cat == 699) return maintxt + 'Other'+'</a>';
return main;
}
function print_size(size, f) {
let e='';
if (f) {
e='&nbsp;(' + size + ' Bytes)';
}
if (size >= 1125899906842624) return round_to_precision(size/1125899906842624, 0.01) + '&nbsp;PiB' + e;
if (size >= 1099511627776) return round_to_precision(size/1099511627776, 0.01) + '&nbsp;TiB' + e;
if (size >= 1073741824) return round_to_precision(size/1073741824, 0.01) + '&nbsp;GiB' + e;
if (size >= 1048576) return round_to_precision(size/1048576, 0.01) + '&nbsp;MiB' + e;
if (size >= 1024) return round_to_precision(size/1024, 0.01) + '&nbsp;KiB' + e;
return size+'&nbsp;B';
}
function round_to_precision(x, precision) {
let y = +x + (precision === undefined ? 0.5 : precision/2);
// Fix 1.4000000000000001 like results from rounding.
let sz = y - (y % (precision === undefined ? 1 : +precision)) + '';
if (sz.indexOf('.') == -1) return sz;
else return sz.substring(0, sz.indexOf('.')+3);
}
function print_date(date) {
let dateObj = new Date(date * 1000);
let month = dateObj.getUTCMonth() + 1;
let day = dateObj.getUTCDate();
let year = dateObj.getUTCFullYear();
let m = dateObj.getUTCMonth() + 1;
let mm;
if (m < 10) mm = '0'+m
else mm=m;
let d = dateObj.getUTCDate();
let dd;
if (d < 10) dd = '0'+d
else dd=d;
return dateObj.getUTCFullYear()+'-'+mm+'-'+dd;
}
function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, '\\$&');
let regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
function print_username(user) {
if (user == "Anonymous") return "Anonymous";
let u;
u = encodeURIComponent(user);
return '<a href="/search.php?q=user:'+u+'">'+user+'</a>';
}
function make_details() {
let json_obj = JSON.parse(Get(server + '/t.php?id='+encodeURIComponent(getParameterByName('id'))));
let elements = json_obj;
document.getElementById("tlt").innerHTML=elements['name'];
document.getElementById("name").innerHTML=elements['name'];
document.getElementById("cat").innerHTML=print_category(elements['category']);
document.getElementById("size").innerHTML=print_size(elements['size'], 1);
document.getElementById("user").innerHTML= print_username(elements['username']) + '&nbsp;' + print_status(elements['status']);
document.getElementById("ih").innerHTML=elements['info_hash'];
document.getElementById("s").innerHTML=elements['seeders'];
document.getElementById("l").innerHTML=elements['leechers'];
document.getElementById("d").innerHTML=print_download2(elements['info_hash'], elements['name'], 0);
document.getElementById("d2").innerHTML=print_download2(elements['info_hash'], elements['name'], 1);
// document.getElementById("d").innerHTML=print_download(elements['info_hash'], elements['name']);
// document.getElementById("d2").innerHTML=print_download(elements['info_hash'], elements['name']);
document.getElementById("uld").innerHTML=print_date(elements['added']);
document.getElementById("descr").innerHTML=elements['descr'];
document.getElementById("nfiles").innerHTML=elements['num_files'];
}
function make_filelist() {
let json_obj = JSON.parse(Get(server + '/f.php?id='+encodeURIComponent(getParameterByName('id'))));
let elements = json_obj;
let i=0;
for (element in elements) {
if (i == 1) {
document.write('\n<li class="alt">');
i=0;
} else {
document.write('\n<li>');
i=1;
}
document.write('<span class="file-name">' + elements[element]['name'][0] + '</span><span class="file-size">' + print_size(elements[element]['size'][0], 0) + '</span></li>\n');
}
}
function make_search() {
let cats='', lnk='category:', json_obj, i=0, cpage;
let qu = getParameterByName('q');
if (getParameterByName('cat')) cats = cats + getParameterByName('cat');
if (getParameterByName('audio')) cats=cats+',100';
if (getParameterByName('video')) cats=cats+',200';
if (getParameterByName('apps')) cats=cats+',300';
if (getParameterByName('games')) cats=cats+',400';
if (getParameterByName('porn')) cats=cats+',500';
if (getParameterByName('other')) cats=cats+',600';
if (cats[0] == ',') cats = cats.substring(1);
if (qu.substring(0,13) == 'top100:recent') {
document.getElementById("tlt").innerHTML='Recent torrents';
if (qu.substring(0,13) == 'top100:recent') {
json_obj = JSON.parse(Get(server + '/precompiled/data_top100_recent.json' ));
}
if (qu.substring(0,14) == 'top100:recent:') {
cpage = Number(get_q_part(qu, 2));
if (cpage == 0) {
json_obj = JSON.parse(Get(server + '/precompiled/data_top100_recent.json' ));
} else {
json_obj = JSON.parse(Get(server + '/precompiled/data_top100_recent_' + cpage + '.json' ));
}
}
} else if (qu.substring(0,7) == 'top100:') {
json_obj = JSON.parse(Get(server + '/precompiled/data_top100_' + qu.substring(7) + '.json' ));
document.getElementById("tlt").innerHTML='Top 100: ' + print_top100_title(qu.substring(7));
lnk='top100:';
} else if (qu.substring(0,9) == 'category:') {
json_obj = JSON.parse(Get(server + '/q.php?q=' + encodeURIComponent(qu)));
document.getElementById("tlt").innerHTML='Browse ' + print_category(qu.substring(9));
} else if (qu.substring(0,5) == 'user:') {
json_obj = JSON.parse(Get(server + '/q.php?q=' + encodeURIComponent(qu)));
document.getElementById("tlt").innerHTML='User: ' + htmlEntities(qu.substring(5));
} else {
json_obj = JSON.parse(Get(server + '/q.php?q=' + encodeURIComponent(qu) + '&cat=' + cats ));
document.getElementById("tlt").innerHTML='Results for: ' + htmlEntities(qu);
}
let elements = json_obj;
for (element in elements) {
if (i == 1) {
document.write('\n<li class="list-entry alt" id="st">\n');
i=0;
} else {
document.write('\n<li class="list-entry" id="st">\n');
i=1;
}
document.write('<span class="list-item item-type">' + print_category(elements[element]['category'], lnk) + '</span>');
document.write('<span class="list-item item-name item-title"><a href="/description.php?id='+elements[element]['id']+'">' + elements[element]['name']+'</a></span>');
document.write('<span class="list-item item-uploaded">' + print_date(elements[element]['added']) + '</span>');
document.write('<span class="item-icons">' + print_magnet(elements[element]['info_hash'], elements[element]['name']) + print_status(elements[element]['status']) + '</span>');
document.write('<span class="list-item item-size">' + print_size(elements[element]['size'], 0) + '<input type="hidden" name="size" value="' + elements[element]['size'] + '"/></span>');
document.write('<span class="list-item item-seed">' + elements[element]['seeders'] + '</span>');
document.write('<span class="list-item item-leech">' + elements[element]['leechers'] + '&nbsp;</span>');
document.write('<span class="list-item item-user">' + print_username(elements[element]['username']) + '</span>\n</li>\n');
}
document.write('</ol>\n');
// Page selector
if (qu.substring(0,5) == 'user:') {
document.write('<center>\n');
if (get_q_part(qu, 1)) print_pageselector( get_q_part(qu, 1), Number(get_q_part(qu, 2)), '/search.php?q=user:' + htmlEntities(get_q_part(qu, 1)) );
document.write('</center>\n');
}
if (qu.substring(0,13) == 'top100:recent') {
document.write('<center>\n');
print_pageselector( 'recent', Number(get_q_part(qu, 2)), '/search.php?q=top100:recent' );
document.write('</center>\n');
}
document.write('</section>\n');
}
function get_q_part(stra, part) {
// 0 = type
// 1 = query
// 2 = page
if (part == 2) {
if (stra.split(':').length == 2) return 0;
let pg=stra.split(':')[stra.split(':').length-1];
if (isNaN(pg)) return 0;
if (pg == '') return 0;
return Number(pg);
}
return stra.split(':')[part];
}
function setAll() {
document.forms['q'].elements['audio'].checked = false;
document.forms['q'].elements['video'].checked = false;
document.forms['q'].elements['apps'].checked = false;
document.forms['q'].elements['games'].checked = false;
document.forms['q'].elements['porn'].checked = false;
document.forms['q'].elements['other'].checked = false;
}
function rmAll() {
document.forms['q'].elements['all'].checked = false;
}
// Default sort order
var sort_o = new Array(10);
sort_o[1]=1;
sort_o[2]=1;
sort_o[3]=0;
sort_o[5]=0;
sort_o[6]=0;
sort_o[7]=0;
sort_o[8]=1;
function sortlist(sr) {
if (sort_o[sr] == 1) {
tinysort.defaults.order = 'asc';
sort_o[sr]=0;
} else {
tinysort.defaults.order = 'desc';
sort_o[sr]=1;
}
if (sr == 5) {
tinysort('li#st',{selector:'input',attr:'value'});
return;
}
tinysort('li#st','span:nth-child(' + sr + ')');
}
function htmlEntities(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function print_footer() {
document.write('<footer class="row flow-root flow-column align-center">\n',
'<nav>\n',
'<div>\n',
'<a href="/session/" title="Login/Upload">Login/Upload</a> |\n',
'<a href="/session/" title="Register">Register</a>\n',
'</div>\n',
'<div>\n',
'<a href="https://tpb.718it.biz" title="tor address">TOR</a> |\n',
'<a href="https://pirates-forum.org/" title="discussion forum" target="_blank">Forum</a> |\n',
'<a href="https://www.azirevpn.net/landing" target="_NEW"><b>VPN</b></a> |\n',
'<a href="https://tmp.ninja" target="_NEW"><b>FileHosting</b></a>\n',
'</div>\n',
'</nav>\n',
'<p style="overflow-wrap: break-word;">\n',
'<a href="https://bitcoin.org" target="_NEW">BTC</a>: <b>3EoJMDs79b3ztkYvj2E1D98eHnZyCSQKso</b>\n',
'<br /><a href="https://bitcoin.org" target="_NEW">BTC (Bech32)</a>: <b>bc1q9x30z7rz52c97jwc2j79w76y7l3ny54nlvd4ew</b>\n',
'<br /><a href="https://litecoin.org" target="_NEW">LTC</a>: <b>LS78aoGtfuGCZ777x3Hmr6tcoW3WaYynx9</b>\n',
'<br /><a href="https://getmonero.org" target="_NEW">XMR</a>: <b>46E5ekYrZd5UCcmNuYEX24FRjWVMgZ1ob79cRViyfvLFZjfyMhPDvbuCe54FqLQvVCgRKP4UUMMW5fy3ZhVQhD1JLLufBtu</b>\n',
'</p>\n',
'</footer>\n');
}
function print_header1() {
document.write('<section class="col-left" id="logo"><a href="/index.html"><img src="' + static_server + '/images/tpbsmall_notext.jpg" alt="The Pirate Bay"></a></section>\n',
'<section class="col-center">\n',
'<nav>\n',
'<a href="/index.html" title="Search Torrents"><strong>Search&nbsp;Torrents</strong></a> |\n',
'<a href="/browse.php" title="Browse Torrents">Browse&nbsp;Torrents</a> |\n',
'<a href="/search.php?q=top100:recent" title="Recent Torrent">Recent&nbsp;Torrents</a> |\n',
'<a href="/top.php" title="Top 100">Top&nbsp;100</a>\n',
'</nav>\n',
'<form action="/search.php">\n');
}
function print_header2() {
document.write('<input value="Pirate Search" type="submit">\n',
'<select name="cat" id="cat">\n',
'<option value="0">All</option>\n',
'<optgroup label="Audio">\n',
'<option value="101">Music</option>\n',
'<option value="102">Audio books</option>\n',
'<option value="103">Sound clips</option>\n',
'<option value="104">FLAC</option>\n',
'<option value="199">Other</option>\n',
'</optgroup>\n',
'<optgroup label="Video">\n',
'<option value="201">Movies</option>\n',
'<option value="202">Movies DVDR</option>\n',
'<option value="203">Music videos</option>\n',
'<option value="204">Movie clips</option>\n',
'<option value="205">TV shows</option>\n',
'<option value="206">Handheld</option>\n',
'<option value="207">HD - Movies</option>\n',
'<option value="208">HD - TV shows</option>\n',
'<option value="209">3D</option>\n',
'<option value="299">Other</option>\n',
'</optgroup>\n',
'<optgroup label="Applications">\n',
'<option value="301">Windows</option>\n',
'<option value="302">Mac</option>\n',
'<option value="303">UNIX</option>\n',
'<option value="304">Handheld</option>\n',
'<option value="305">IOS (iPad/iPhone)</option>\n',
'<option value="306">Android</option>\n',
'<option value="399">Other OS</option>\n',
'</optgroup>\n',
'<optgroup label="Games">\n',
'<option value="401">PC</option>\n',
'<option value="402">Mac</option>\n',
'<option value="403">PSx</option>\n',
'<option value="404">XBOX360</option>\n',
'<option value="405">Wii</option>\n',
'<option value="406">Handheld</option>\n',
'<option value="407">IOS (iPad/iPhone)</option>\n',
'<option value="408">Android</option>\n',
'<option value="499">Other</option>\n',
'</optgroup>\n',
'<optgroup label="Porn">\n',
'<option value="501">Movies</option>\n',
'<option value="502">Movies DVDR</option>\n',
'<option value="503">Pictures</option>\n',
'<option value="504">Games</option>\n',
'<option value="505">HD - Movies</option>\n',
'<option value="506">Movie clips</option>\n',
'<option value="599">Other</option>\n',
'</optgroup>\n',
'<optgroup label="Other">\n',
'<option value="601">E-books</option>\n',
'<option value="602">Comics</option>\n',
'<option value="603">Pictures</option>\n',
'<option value="604">Covers</option>\n',
'<option value="605">Physibles</option>\n',
'<option value="699">Other</option>\n',
'</optgroup>\n',
'</select>\n',
'</form>\n',
'</section>\n');
}
function mark_selected() {
let scate = document.getElementById('cat');
if (scate) {
let ct = getParameterByName('cat');
if ((Number(ct) > 99) && (Number(ct) < 700)) {
scate.value = ct;
} else {
scate.value = 0;
}
}
}
function print_search() {
document.write('<section class="col-center">\n',
'<input type="text" id="flist" onkeyup="filter_list()" placeholder="Filter names.." size=40>',
'<ol id="torrents" class="view-single">\n',
'<li class="list-header">\n',
'<span class="list-item list-header item-type"><label onclick="sortlist(1);" title="Order by Category">Category</label></span>\n',
'<span class="list-item list-header item-name"><label onclick="sortlist(2);" title="Order by Name">Name</label></span>\n',
'<span class="list-item list-header item-uploaded"><label onclick="sortlist(3);" title="Order by Date Uploaded">Uploaded</label></span>\n',
'<span class="list-item list-header item-icons">&nbsp;</span>\n',
'<span class="list-item list-header item-size"><label onclick="sortlist(5);" title="Order by Size">Size</label></span>\n',
'<span class="list-item list-header item-seed"><label onclick="sortlist(6);" title="Order by Seeders">SE</label></span>\n',
'<span class="list-item list-header item-leech"><label onclick="sortlist(7);" title="Order by Leechers">LE</label></span>\n',
'<span class="list-item list-header item-user"><label onclick="sortlist(8);" title="Order by ULed by">ULed by</label></span>\n',
'</li>\n');
if (typeof make_search !== "undefined" ) make_search();
}
function print_browse() {
document.write('<section class="col-center">\n',
'<dl class="row">\n',
'<div class="category_list">\n',
'<div>\n',
'<dt><a href="/search.php?q=category:100" title="Audio">Audio</a></dt>\n',
'<dd>\n',
'<a href="/search.php?q=category:101" title="Music">Music</a>\n',
'<a href="/search.php?q=category:102" title="Audio books">Audio books</a>\n',
'<a href="/search.php?q=category:103" title="Sound clips">Sound clips</a>\n',
'<a href="/search.php?q=category:103" title="FLAC">FLAC</a>\n',
'<a href="/search.php?q=category:199" title="Other">Other</a>\n',
'</dd>\n',
'</div>\n',
'<div>\n',
'<dt><a href="/search.php?q=category:200" title="Video">Video</a></dt>\n',
'<dd>\n',
'<a href="/search.php?q=category:201" title="Movies">Movies</a>\n',
'<a href="/search.php?q=category:202" title="Movies DVDR">Movies DVDR</a>\n',
'<a href="/search.php?q=category:203" title="Music videos">Music videos</a>\n',
'<a href="/search.php?q=category:204" title="Movie clips">Movie clips</a>\n',
'<a href="/search.php?q=category:205" title="TV shows">TV shows</a>\n',
'<a href="/search.php?q=category:206" title="Handheld">Handheld</a>\n',
'<a href="/search.php?q=category:207" title="HD - Movies">HD - Movies</a>\n',
'<a href="/search.php?q=category:208" title="HD - TV shows">HD - TV shows</a>\n',
'<a href="/search.php?q=category:209" title="3D">3D</a>\n',
'<a href="/search.php?q=category:299" title="Other">Other</a>\n',
'</dd>\n',
'</div>\n',
'<div>\n',
'<dt><a href="/search.php?q=category:300" title="Applications">Applications</a></dt>\n',
'<dd>\n',
'<a href="/search.php?q=category:301" title="Windows">Windows</a>\n',
'<a href="/search.php?q=category:302" title="Mac">Mac</a>\n',
'<a href="/search.php?q=category:303" title="UNIX">UNIX</a>\n',
'<a href="/search.php?q=category:304" title="Handheld">Handheld</a>\n',
'<a href="/search.php?q=category:305" title="IOS (iPad/iPhone)">IOS (iPad/iPhone)</a>\n',
'<a href="/search.php?q=category:306" title="Android">Android</a>\n',
'<a href="/search.php?q=category:399" title="Other OS">Other OS</a>\n',
'</dd>\n',
'</div>\n',
'</div>\n',
'<div class="category_list">\n',
'<div>\n',
'<dt><a href="/search.php?q=category:400" title="Games">Games</a></dt>\n',
'<dd>\n',
'<a href="/search.php?q=category:401" title="PC">PC</a>\n',
'<a href="/search.php?q=category:402" title="Mac">Mac</a>\n',
'<a href="/search.php?q=category:403" title="PSx">PSx</a>\n',
'<a href="/search.php?q=category:404" title="XBOX360">XBOX360</a>\n',
'<a href="/search.php?q=category:405" title="Wii">Wii</a>\n',
'<a href="/search.php?q=category:406" title="Handheld">Handheld</a>\n',
'<a href="/search.php?q=category:407" title="IOS (iPad/iPhone)">IOS (iPad/iPhone)</a>\n',
'<a href="/search.php?q=category:408" title="Android">Android</a>\n',
'<a href="/search.php?q=category:499" title="Other">Other</a>\n',
'</dd>\n',
'</div>\n',
'<div>\n',
'<dt><a href="/search.php?q=category:500" title="Porn">Porn</a></dt>\n',
'<dd>\n',
'<a href="/search.php?q=category:501" title="Movies">Movies</a>,\n',
'<a href="/search.php?q=category:502" title="Movies DVDR">Movies DVDR</a>\n',
'<a href="/search.php?q=category:503" title="Pictures">Pictures</a>\n',
'<a href="/search.php?q=category:504" title="Games">Games</a>\n',
'<a href="/search.php?q=category:505" title="HD - Movies">HD - Movies</a>\n',
'<a href="/search.php?q=category:506" title="Movie clips">Movie clips</a>\n',
'<a href="/search.php?q=category:599" title="Other">Other</a>\n',
'</dd>\n',
'</div>\n',
'<div>\n',
'<dt><a href="/search.php?q=category:600" title="Other">Other</a></dt>\n',
'<dd>\n',
'<a href="/search.php?q=category:601" title="E-books">E-books</a>\n',
'<a href="/search.php?q=category:602" title="Comics">Comics</a>\n',
'<a href="/search.php?q=category:603" title="Pictures">Pictures</a>\n',
'<a href="/search.php?q=category:604" title="Covers">Covers</a>\n',
'<span><a href="/search.php?q=category:605" title="Physibles">Physibles</a><a href=""><b>(?!)</b></a></span>\n',
'<a href="/search.php?q=category:699" title="Other">Other</a>\n',
'</dd>\n',
'</div>\n',
'</div>\n',
'</dl>\n',
'</section>\n');
}
function print_top() {
document.write('<section class="col-center">\n',
'<dl class="row">\n',
'<div class="category_list">\n',
'<b><a href="/search.php?q=top100:all">Total Top100</a></b> (<a href="/search.php?q=top100:48h">48h</a>)\n',
'</div>\n',
'</dl>\n',
'<dl class="row">\n',
'<div class="category_list">\n',
'<div>\n',
'<dt><b><a href="/search.php?q=top100:100" title="Audio">Audio</a></b> (<a href="/search.php?q=top100:48h_100">48h</a>)</dt>\n',
'<dd>\n',
'<a href="/search.php?q=top100:101" title="Music">Music</a> (<a href="/search.php?q=top100:48h_101">48h</a>)\n',
'<a href="/search.php?q=top100:102" title="Audio books">Audio books</a> (<a href="/search.php?q=top100:48h_102">48h</a>)\n',
'<a href="/search.php?q=top100:103" title="Sound clips">Sound clips</a> (<a href="/search.php?q=top100:48h_103">48h</a>)<br />\n',
'<a href="/search.php?q=top100:103" title="FLAC">FLAC</a> (<a href="/search.php?q=top100:48h_104">48h</a>)\n',
'<a href="/search.php?q=top100:199" title="Other">Other</a> (<a href="/search.php?q=top100:48h_199">48h</a>)\n',
'</dd>\n',
'</div>\n',
'<div>\n',
'<dt><b><a href="/search.php?q=top100:200" title="Video">Video</a></b> (<a href="/search.php?q=top100:48h_200">48h</a>)</dt>\n',
'<dd>\n',
'<a href="/search.php?q=top100:201" title="Movies">Movies</a> (<a href="/search.php?q=top100:48h_201">48h</a>)\n',
'<a href="/search.php?q=top100:202" title="Movies DVDR">Movies DVDR</a> (<a href="/search.php?q=top100:48h_202">48h</a>)\n',
'<a href="/search.php?q=top100:203" title="Music videos">Music videos</a> (<a href="/search.php?q=top100:48h_203">48h</a>)<br />\n',
'<a href="/search.php?q=top100:204" title="Movie clips">Movie clips</a> (<a href="/search.php?q=top100:48h_204">48h</a>)\n',
'<a href="/search.php?q=top100:205" title="TV shows">TV shows</a> (<a href="/search.php?q=top100:48h_205">48h</a>)\n',
'<a href="/search.php?q=top100:206" title="Handheld">Handheld</a> (<a href="/search.php?q=top100:48h_206">48h</a>)\n',
'<a href="/search.php?q=top100:207" title="HD - Movies">HD - Movies</a> (<a href="/search.php?q=top100:48h_207">48h</a>)<br />\n',
'<a href="/search.php?q=top100:208" title="HD - TV shows">HD - TV shows</a> (<a href="/search.php?q=top100:48h_208">48h</a>)\n',
'<a href="/search.php?q=top100:209" title="3D">3D</a> (<a href="/search.php?q=top100:48h_209">48h</a>)\n',
'<a href="/search.php?q=top100:299" title="Other">Other</a> (<a href="/search.php?q=top100:48h_299">48h</a>)\n',
'</dd>\n',
'</div>\n',
'<div>\n',
'<dt><b><a href="/search.php?q=top100:300" title="Applications">Applications</a></b> (<a href="/search.php?q=top100:48h_400">48h</a>)</dt>\n',
'<dd>\n',
'<a href="/search.php?q=top100:301" title="Windows">Windows</a> (<a href="/search.php?q=top100:48h_301">48h</a>)\n',
'<a href="/search.php?q=top100:302" title="Mac">Mac</a> (<a href="/search.php?q=top100:48h_302">48h</a>)\n',
'<a href="/search.php?q=top100:303" title="UNIX">UNIX</a> (<a href="/search.php?q=top100:48h_303">48h</a>)\n',
'<a href="/search.php?q=top100:304" title="Handheld">Handheld</a> (<a href="/search.php?q=top100:48h_304">48h</a>)<br />\n',
'<a href="/search.php?q=top100:305" title="IOS (iPad/iPhone)">IOS (iPad/iPhone)</a> (<a href="/search.php?q=top100:48h_305">48h</a>)\n',
'<a href="/search.php?q=top100:306" title="Android">Android</a> (<a href="/search.php?q=top100:48h_306">48h</a>)\n',
'<a href="/search.php?q=top100:399" title="Other OS">Other OS</a> (<a href="/search.php?q=top100:48h_399">48h</a>)\n',
'</dd>\n',
'</div>\n',
'</div>\n',
'<div class="category_list">\n',
'<div>\n',
'<dt><b><a href="/search.php?q=top100:400" title="Games">Games</a></b> (<a href="/search.php?q=top100:48h_400">48h</a>)</dt>\n',
'<dd>\n',
'<a href="/search.php?q=top100:401" title="PC">PC</a> (<a href="/search.php?q=top100:48h_401">48h</a>)\n',
'<a href="/search.php?q=top100:402" title="Mac">Mac</a> (<a href="/search.php?q=top100:48h_402">48h</a>)\n',
'<a href="/search.php?q=top100:403" title="PSx">PSx</a> (<a href="/search.php?q=top100:48h_403">48h</a>)\n',
'<a href="/search.php?q=top100:404" title="XBOX360">XBOX360</a> (<a href="/search.php?q=top100:48h_404">48h</a>)\n',
'<a href="/search.php?q=top100:405" title="Wii">Wii</a> (<a href="/search.php?q=top100:48h_405">48h</a>)<br />\n',
'<a href="/search.php?q=top100:406" title="Handheld">Handheld</a> (<a href="/search.php?q=top100:48h_406">48h</a>)\n',
'<a href="/search.php?q=top100:407" title="IOS (iPad/iPhone)">IOS (iPad/iPhone)</a> (<a href="/search.php?q=top100:48h_407">48h</a>)\n',
'<a href="/search.php?q=top100:408" title="Android">Android</a> (<a href="/search.php?q=top100:48h_408">48h</a>)\n',
'<a href="/search.php?q=top100:499" title="Other">Other</a> (<a href="/search.php?q=top100:48h_499">48h</a>)\n',
'</dd>\n',
'</div>\n',
'<div>\n',
'<dt><b><a href="/search.php?q=top100:500" title="Porn">Porn</a></b> (<a href="/search.php?q=top100:48h_500">48h</a>)</dt>\n',
'<dd>\n',
'<a href="/search.php?q=top100:501" title="Movies">Movies</a> (<a href="/search.php?q=top100:48h_501">48h</a>)\n',
'<a href="/search.php?q=top100:502" title="Movies DVDR">Movies DVDR</a> (<a href="/search.php?q=top100:48h_502">48h</a>)\n',
'<a href="/search.php?q=top100:503" title="Pictures">Pictures</a> (<a href="/search.php?q=top100:48h_503">48h</a>)\n',
'<a href="/search.php?q=top100:504" title="Games">Games</a> (<a href="/search.php?q=top100:48h_504">48h</a>)\n',
'<a href="/search.php?q=top100:505" title="HD - Movies">HD - Movies</a> (<a href="/search.php?q=top100:48h_505">48h</a>)\n',
'<a href="/search.php?q=top100:506" title="Movie clips">Movie clips</a> (<a href="/search.php?q=top100:48h_506">48h</a>)\n',
'<a href="/search.php?q=top100:599" title="Other">Other</a> (<a href="/search.php?q=top100:48h_599">48h</a>)\n',
'</dd>\n',
'</div>\n',
'<div>\n',
'<dt><b><a href="/search.php?q=top100:600" title="Other">Other</a></b> (<a href="/search.php?q=top100:48h_600">48h</a>)</dt>\n',
'<dd>\n',
'<a href="/search.php?q=top100:601" title="E-books">E-books</a> (<a href="/search.php?q=top100:48h_601">48h</a>)\n',
'<a href="/search.php?q=top100:602" title="Comics">Comics</a> (<a href="/search.php?q=top100:48h_602">48h</a>)\n',
'<a href="/search.php?q=top100:603" title="Pictures">Pictures</a> (<a href="/search.php?q=top100:48h_603">48h</a>)<br />\n',
'<a href="/search.php?q=top100:604" title="Covers">Covers</a> (<a href="/search.php?q=top100:48h_604">48h</a>)\n',
'<span><a href="/search.php?q=top100:605" title="Physibles">Physibles</a></a></span> (<a href="/search.php?q=top100:48h_605">48h</a>)\n',
'<a href="/search.php?q=top100:699" title="Other">Other</a> (<a href="/search.php?q=top100:48h_699">48h</a>)\n',
'</dd>\n',
'</div>\n',
'</div>\n',
'</dl>\n',
'</section>\n');
}
function do_pop_porn() {}
function do_pop() {}
function thepop(adConfig) {}
function print_selector_number(i, curpage, linkto) {
let before, after;
if (i == curpage) {
before = '<b>';
after = '</b>';
} else {
before = '<a href="' + linkto + ':' + i + '">';
after = '</a>';
}
document.write(before, i+1, after, ' \n');
}
function print_pageselector(username, curpage, linkto) {
let json_obj = JSON.parse(Get(server + '/q.php?q=pcnt:' + username ));
let elements = json_obj;
let pages = Number(elements)-1;
let before, after, o, i, strt, stp;
if (pages < 2) return '';
if (pages < 30) {
for (i=0; i < pages; i++) {
print_selector_number(i, curpage, linkto);
}
} else {
if (curpage-10 > 5) {
strt=curpage-10;
if (strt > pages-25) strt=pages-25;
print_selector_number(0, curpage, linkto);
print_selector_number(1, curpage, linkto);
print_selector_number(2, curpage, linkto);
document.write('... ');
} else {
strt=0;
}
if (pages-curpage-10 > 5) {
stp=curpage+11;
if (stp < 27) stp=26;
if (stp > pages) stp=pages;
} else {
if (strt == 0) {
stp=16;
} else {
stp=pages;
}
}
for (i=strt; i < stp; i++) {
print_selector_number(i, curpage, linkto);
}
if (pages-curpage-10 > 5) {
document.write('... ');
print_selector_number(pages-2, curpage, linkto);
print_selector_number(pages-1, curpage, linkto);
print_selector_number(pages, curpage, linkto);
}
}
document.write('<br />\n');
}
function filter_list() {
let input, filter, ul, li, a, i, txtValue;
input = document.getElementById('flist');
filter = input.value.toUpperCase();
li = document.getElementsByClassName('list-entry');
for (i = 0; i < li.length; i++) {
a = li[i].getElementsByTagName('span')[1];
txtValue = a.textContent || a.innerText;
if (txtValue.toUpperCase().indexOf(filter) > -1) {
li[i].style.display = '';
} else {
li[i].style.display = 'none';
}
}
}
function do_interstitial_porn() {}
function do_interstitial() {}