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

222 lines
5.8 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{
// let instance = this.build(data);
// console.log('instance', instance)
await this.build(data).validate();
// console.log('validate', val);
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;
}
return await super.create({
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,
}, 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;
};