Files
tpbproxy/models/sql/torrent.js
T
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

222 lines
5.7 KiB
JavaScript

'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;
};