Initial commit: webshare-api
Torznab + fake qBittorrent proxy for Webshare.cz, with Compose/Docker packaging, MIT license, and tests.
This commit is contained in:
151
src/index.js
Normal file
151
src/index.js
Normal file
@@ -0,0 +1,151 @@
|
||||
const express = require('express');
|
||||
const { WebshareClient } = require('./webshare');
|
||||
const { caps, feed } = require('./torznab');
|
||||
const { makeTorrent } = require('./torrent');
|
||||
const qbt = require('./qbt');
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
|
||||
const USERNAME = process.env.WEBSHARE_USERNAME;
|
||||
const PASSWORD = process.env.WEBSHARE_PASSWORD;
|
||||
|
||||
if (!USERNAME || !PASSWORD) {
|
||||
console.error('WEBSHARE_USERNAME and WEBSHARE_PASSWORD must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new WebshareClient(USERNAME, PASSWORD);
|
||||
const app = express();
|
||||
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(express.json());
|
||||
|
||||
client.ensureAuth().catch(err => console.error('Initial auth failed:', err.message));
|
||||
|
||||
// ── Torznab indexer ───────────────────────────────────────────────────────
|
||||
app.get('/api', async (req, res) => {
|
||||
const t = req.query.t;
|
||||
res.set('Content-Type', 'application/xml; charset=utf-8');
|
||||
|
||||
if (t === 'caps') return res.send(caps());
|
||||
|
||||
if (t === 'search' || t === 'tvsearch' || t === 'movie') {
|
||||
let q = req.query.q || '';
|
||||
|
||||
if (t === 'tvsearch') {
|
||||
if (req.query.season) q += ` S${String(req.query.season).padStart(2, '0')}`;
|
||||
if (req.query.ep) q += `E${String(req.query.ep).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
q = q.trim();
|
||||
if (!q) q = 'the';
|
||||
|
||||
try {
|
||||
const results = await client.search(q, {
|
||||
limit: Math.min(parseInt(req.query.limit) || 25, 100),
|
||||
offset: parseInt(req.query.offset) || 0,
|
||||
});
|
||||
return res.send(feed(results, BASE_URL));
|
||||
} catch (err) {
|
||||
console.error('search error:', err.message);
|
||||
return res.status(500).send(`<error code="100">${err.message}</error>`);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(400).send('<error code="200">Unknown function</error>');
|
||||
});
|
||||
|
||||
// ── /download/:ident — returns a .torrent file so Sonarr can parse it ────
|
||||
// The torrent's webseed points to /stream/:ident which fetches a fresh CDN
|
||||
// link at download time (Webshare links expire in ~10 min).
|
||||
app.get('/download/:ident', async (req, res) => {
|
||||
try {
|
||||
const { ident } = req.params;
|
||||
// We need the file size for the torrent info dict; do a quick search
|
||||
// fall back to 0 if we can't get it (fake qBt skips hash checking anyway)
|
||||
let size = 0;
|
||||
let name = ident + '.mkv';
|
||||
try {
|
||||
const results = await client.search(ident, { limit: 1 });
|
||||
if (results.length && results[0].ident === ident) {
|
||||
size = results[0].size;
|
||||
name = results[0].name;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const webseedUrl = `${BASE_URL}/stream/${ident}`;
|
||||
const torrent = makeTorrent({ name, size, webseedUrl });
|
||||
|
||||
res.set('Content-Type', 'application/x-bittorrent');
|
||||
res.set('Content-Disposition', `attachment; filename="${name}.torrent"`);
|
||||
res.send(torrent);
|
||||
} catch (err) {
|
||||
console.error('torrent gen error:', err.message);
|
||||
res.status(500).send(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ── /stream/:ident — fetches a fresh CDN link and redirects ──────────────
|
||||
// Used as the webseed URL inside .torrent files so the link is always fresh.
|
||||
app.get('/stream/:ident', async (req, res) => {
|
||||
try {
|
||||
const link = await client.getFileLink(req.params.ident);
|
||||
res.redirect(302, link);
|
||||
} catch (err) {
|
||||
console.error('stream error:', err.message);
|
||||
// 410 Gone for permanent Webshare FATAL errors (file deleted/unavailable)
|
||||
// so the downloader does not retry — 500 is reserved for transient failures.
|
||||
const permanent = err.message.includes('FATAL') || err.message.toLowerCase().includes('temporarily unavailable');
|
||||
res.status(permanent ? 410 : 500).send(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ── /resolve/:ident — debug helper ───────────────────────────────────────
|
||||
app.get('/resolve/:ident', async (req, res) => {
|
||||
try {
|
||||
const link = await client.getFileLink(req.params.ident);
|
||||
res.json({ link });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Fake qBittorrent Web API ──────────────────────────────────────────────
|
||||
qbt.register(app);
|
||||
|
||||
// ── Automatic missing-episode search ─────────────────────────────────────
|
||||
// Sonarr won't find new downloads without being asked (no RSS support).
|
||||
// Every 2 hours, tell Sonarr to search for all missing monitored episodes.
|
||||
const SONARR_URL = process.env.SONARR_URL || 'http://sonarr:8989';
|
||||
const SONARR_KEY = process.env.SONARR_API_KEY || '';
|
||||
const SEARCH_INTERVAL = parseInt(process.env.SONARR_SEARCH_INTERVAL_HOURS || '2', 10) * 60 * 60 * 1000;
|
||||
|
||||
async function triggerMissingSearch() {
|
||||
if (!SONARR_KEY) return;
|
||||
try {
|
||||
const axios = require('axios');
|
||||
const headers = { 'X-Api-Key': SONARR_KEY };
|
||||
const { data: series } = await axios.get(`${SONARR_URL}/api/v3/series`, { headers, timeout: 10000 });
|
||||
const monitored = series.filter(s => s.monitored && s.statistics &&
|
||||
s.statistics.episodeCount > s.statistics.episodeFileCount);
|
||||
for (const s of monitored) {
|
||||
await axios.post(`${SONARR_URL}/api/v3/command`,
|
||||
{ name: 'MissingEpisodeSearch', seriesId: s.id },
|
||||
{ headers, timeout: 10000 });
|
||||
console.log(`[search] triggered missing search for: ${s.title}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[search] failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (SONARR_KEY) {
|
||||
// First run after 5 minutes (give Sonarr time to start), then every N hours
|
||||
setTimeout(() => {
|
||||
triggerMissingSearch();
|
||||
setInterval(triggerMissingSearch, SEARCH_INTERVAL);
|
||||
}, 5 * 60 * 1000);
|
||||
console.log(`[search] auto missing-episode search every ${SEARCH_INTERVAL / 3600000}h`);
|
||||
}
|
||||
|
||||
app.listen(PORT, () => console.log(`webshare-api listening on :${PORT} — base URL: ${BASE_URL}`));
|
||||
61
src/md5crypt.js
Normal file
61
src/md5crypt.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
const MAGIC = '$1$';
|
||||
const ITOA64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
function to64(v, n) {
|
||||
let ret = '';
|
||||
while (n-- > 0) {
|
||||
ret += ITOA64[v & 0x3f];
|
||||
v >>= 6;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
function md5(buf) {
|
||||
return crypto.createHash('md5').update(buf).digest();
|
||||
}
|
||||
|
||||
function unixMd5Crypt(pw, salt) {
|
||||
if (salt.startsWith(MAGIC)) salt = salt.slice(MAGIC.length);
|
||||
salt = salt.split('$')[0].slice(0, 8);
|
||||
|
||||
const pwBuf = Buffer.from(pw, 'utf8');
|
||||
const saltBuf = Buffer.from(salt, 'utf8');
|
||||
const magicBuf = Buffer.from(MAGIC, 'ascii');
|
||||
|
||||
let final = md5(Buffer.concat([pwBuf, saltBuf, pwBuf]));
|
||||
|
||||
let ctx = Buffer.concat([pwBuf, magicBuf, saltBuf]);
|
||||
for (let pl = pwBuf.length; pl > 0; pl -= 16)
|
||||
ctx = Buffer.concat([ctx, final.slice(0, Math.min(pl, 16))]);
|
||||
|
||||
for (let i = pwBuf.length; i > 0; i >>= 1)
|
||||
ctx = Buffer.concat([ctx, Buffer.from([(i & 1) ? 0 : pwBuf[0]])]);
|
||||
|
||||
final = md5(ctx);
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
let c = Buffer.alloc(0);
|
||||
c = Buffer.concat([c, (i & 1) ? pwBuf : final]);
|
||||
if (i % 3) c = Buffer.concat([c, saltBuf]);
|
||||
if (i % 7) c = Buffer.concat([c, pwBuf]);
|
||||
c = Buffer.concat([c, (i & 1) ? final : pwBuf]);
|
||||
final = md5(c);
|
||||
}
|
||||
|
||||
return MAGIC + salt + '$' +
|
||||
to64((final[0] << 16) | (final[6] << 8) | final[12], 4) +
|
||||
to64((final[1] << 16) | (final[7] << 8) | final[13], 4) +
|
||||
to64((final[2] << 16) | (final[8] << 8) | final[14], 4) +
|
||||
to64((final[3] << 16) | (final[9] << 8) | final[15], 4) +
|
||||
to64((final[4] << 16) | (final[10] << 8) | final[5], 4) +
|
||||
to64(final[11], 2);
|
||||
}
|
||||
|
||||
function passwordDigest(password, salt) {
|
||||
const crypted = unixMd5Crypt(password, salt);
|
||||
return crypto.createHash('sha1').update(crypted, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
module.exports = { passwordDigest };
|
||||
401
src/qbt.js
Normal file
401
src/qbt.js
Normal file
@@ -0,0 +1,401 @@
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { extractWebseed, extractInfoHash } = require('./torrent');
|
||||
|
||||
const SAVE_PATH = process.env.DOWNLOAD_PATH || '/downloads/webshare';
|
||||
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/data';
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
const MAX_CONCURRENT = parseInt(process.env.MAX_CONCURRENT_DOWNLOADS || '2', 10);
|
||||
const STATE_FILE = path.join(SAVE_PATH, '.queue-state.json');
|
||||
|
||||
// hash → download entry (persisted to disk)
|
||||
const downloads = new Map();
|
||||
let activeDownloads = 0;
|
||||
const waitingQueue = []; // { entry, webseedUrl }
|
||||
|
||||
function saveState() {
|
||||
try {
|
||||
const data = [...downloads.values()].map(e => ({
|
||||
hash: e.hash, name: e.name, size: e.size,
|
||||
progress: e.progress, state: e.state, category: e.category,
|
||||
webseedUrl: e.webseedUrl,
|
||||
}));
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(data));
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
for (const e of data) {
|
||||
if (e.state === 'downloading' || e.state === 'queuedDL') {
|
||||
// Always re-download — partial files from interrupted downloads are corrupt
|
||||
const filePath = path.join(SAVE_PATH, e.name);
|
||||
try { fs.unlinkSync(filePath); } catch { }
|
||||
if (e.webseedUrl) {
|
||||
e.progress = 0;
|
||||
downloads.set(e.hash, e);
|
||||
enqueue(e, e.webseedUrl);
|
||||
console.log(`[qbt] re-queued: ${e.name}`);
|
||||
}
|
||||
} else if (e.state === 'uploading') {
|
||||
const filePath = path.join(SAVE_PATH, e.name);
|
||||
if (fs.existsSync(filePath)) {
|
||||
// Sync size to actual file — Content-Length may have been wrong
|
||||
try { e.size = fs.statSync(filePath).size; } catch {}
|
||||
downloads.set(e.hash, e);
|
||||
console.log(`[qbt] restored uploading: ${e.name}`);
|
||||
}
|
||||
// File gone → already imported and cleaned up; drop entry.
|
||||
// Sonarr tracks hasFile=true so it won't re-search.
|
||||
} else if (e.state === 'error') {
|
||||
// Keep as error — don't auto-retry on restart; Sonarr will re-search if needed.
|
||||
downloads.set(e.hash, e);
|
||||
setTimeout(() => {
|
||||
downloads.delete(e.hash);
|
||||
saveState();
|
||||
}, 3 * 60 * 1000);
|
||||
} else {
|
||||
downloads.set(e.hash, e);
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function cleanupAfterDownload(entry) {
|
||||
const fpath = path.join(SAVE_PATH, entry.name);
|
||||
let attempts = 0;
|
||||
const check = setInterval(() => {
|
||||
attempts++;
|
||||
try {
|
||||
if (fs.statSync(fpath).nlink > 1) {
|
||||
fs.unlinkSync(fpath);
|
||||
console.log(`[cleanup] auto-deleted after import: ${entry.name}`);
|
||||
clearInterval(check);
|
||||
} else if (attempts >= 60) {
|
||||
clearInterval(check);
|
||||
}
|
||||
} catch { clearInterval(check); }
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
function dequeue() {
|
||||
if (activeDownloads >= MAX_CONCURRENT || waitingQueue.length === 0) return;
|
||||
const { entry, webseedUrl } = waitingQueue.shift();
|
||||
activeDownloads++;
|
||||
entry.state = 'downloading';
|
||||
(async () => {
|
||||
try {
|
||||
console.log(`[qbt] downloading ${entry.name} via ${webseedUrl.slice(0, 60)}…`);
|
||||
await streamToFile(webseedUrl, entry);
|
||||
// Use actual file size — Content-Length header can lie
|
||||
try { entry.size = fs.statSync(path.join(SAVE_PATH, entry.name)).size; } catch {}
|
||||
entry.progress = 1.0;
|
||||
entry.state = 'uploading';
|
||||
console.log(`[qbt] done: ${entry.name}`);
|
||||
cleanupAfterDownload(entry);
|
||||
} catch (err) {
|
||||
// Delete partial file before retry
|
||||
try { fs.unlinkSync(path.join(SAVE_PATH, entry.name)); } catch { }
|
||||
// "File temporarily unavailable" is Webshare's FATAL error — not actually retryable.
|
||||
const retryable = err.message.includes('stalled') || err.message.includes('ECONNRESET')
|
||||
|| err.message.includes('HTTP 5');
|
||||
if (retryable) {
|
||||
console.warn(`[qbt] will retry ${entry.name} in 2 min: ${err.message}`);
|
||||
entry.state = 'queuedDL';
|
||||
saveState();
|
||||
setTimeout(() => enqueue(entry, webseedUrl), 2 * 60 * 1000);
|
||||
} else {
|
||||
entry.state = 'error';
|
||||
console.error(`[qbt] failed ${entry.name}: ${err.message}`);
|
||||
// Sonarr treats qBittorrent's "error" state as an advisory warning, not
|
||||
// a failure — it never auto-blocklists/redownloads on its own. Removing
|
||||
// the entry makes the download disappear from the client's list, which
|
||||
// Sonarr does treat as failed (triggering its existing auto-redownload).
|
||||
setTimeout(() => {
|
||||
downloads.delete(entry.hash);
|
||||
saveState();
|
||||
}, 3 * 60 * 1000);
|
||||
}
|
||||
} finally {
|
||||
activeDownloads--;
|
||||
saveState();
|
||||
dequeue();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function enqueue(entry, webseedUrl) {
|
||||
entry.state = 'queuedDL';
|
||||
entry.webseedUrl = webseedUrl;
|
||||
waitingQueue.push({ entry, webseedUrl });
|
||||
console.log(`[qbt] queued (${waitingQueue.length} waiting, ${activeDownloads}/${MAX_CONCURRENT} active): ${entry.name}`);
|
||||
saveState();
|
||||
dequeue();
|
||||
}
|
||||
|
||||
function identToHash(ident) {
|
||||
return crypto.createHash('sha1').update('ws:' + ident).digest('hex');
|
||||
}
|
||||
|
||||
function streamToFile(url, entry) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const follow = (u, hops) => {
|
||||
if (hops > 10) return reject(new Error('Too many redirects'));
|
||||
const mod = u.startsWith('https') ? https : http;
|
||||
mod.get(u, res => {
|
||||
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303) {
|
||||
res.resume();
|
||||
return follow(res.headers.location, hops + 1);
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
return reject(new Error(`HTTP ${res.statusCode}`));
|
||||
}
|
||||
|
||||
entry.size = parseInt(res.headers['content-length'] || '0', 10);
|
||||
|
||||
// try to grab filename from Content-Disposition
|
||||
const cd = res.headers['content-disposition'] || '';
|
||||
const m = cd.match(/filename\*?=(?:UTF-8'')?["']?([^"';\r\n]+)/i);
|
||||
if (m) {
|
||||
const fn = decodeURIComponent(m[1].trim().replace(/^["']|["']$/g, ''));
|
||||
if (fn) entry.name = fn;
|
||||
}
|
||||
|
||||
let received = 0;
|
||||
let lastDataAt = Date.now();
|
||||
const dest = path.join(SAVE_PATH, entry.name);
|
||||
const file = fs.createWriteStream(dest);
|
||||
|
||||
// Poll every 10s — avoids setTimeout/clearTimeout on every chunk
|
||||
const stallCheck = setInterval(() => {
|
||||
if (Date.now() - lastDataAt > 60_000)
|
||||
res.destroy(new Error('Download stalled (60s no data)'));
|
||||
}, 10_000);
|
||||
|
||||
res.on('data', chunk => {
|
||||
received += chunk.length;
|
||||
lastDataAt = Date.now();
|
||||
if (entry.size > 0) entry.progress = received / entry.size;
|
||||
});
|
||||
res.pipe(file);
|
||||
const done = () => clearInterval(stallCheck);
|
||||
file.on('finish', () => { done(); file.close(resolve); });
|
||||
file.on('error', (e) => { done(); reject(e); });
|
||||
res.on('error', (e) => { done(); reject(e); });
|
||||
}).on('error', reject);
|
||||
};
|
||||
follow(url, 0);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function register(app) {
|
||||
fs.mkdirSync(SAVE_PATH, { recursive: true });
|
||||
|
||||
// ── auth ──────────────────────────────────────────────────────────────
|
||||
app.post('/api/v2/auth/login', (req, res) => {
|
||||
res.cookie('SID', 'webshare-session', { httpOnly: true });
|
||||
res.send('Ok.');
|
||||
});
|
||||
app.get('/api/v2/auth/logout', (req, res) => res.send('Ok.'));
|
||||
|
||||
// ── app info ──────────────────────────────────────────────────────────
|
||||
app.get('/api/v2/app/version', (req, res) => res.send('5.0.0'));
|
||||
app.get('/api/v2/app/webapiVersion', (req, res) => res.send('2.8.3'));
|
||||
app.get('/api/v2/app/buildInfo', (req, res) => res.json({
|
||||
bitness: 64, boost: '1.84.0', libtorrent: '2.0.10', openssl: '3.2.1', qt: '6.6.2', zlib: '1.3.1',
|
||||
}));
|
||||
app.get('/api/v2/app/preferences', (req, res) => res.json({
|
||||
save_path: SAVE_PATH + '/',
|
||||
temp_path_enabled: false,
|
||||
create_subfolder_enabled: false,
|
||||
incomplete_files_ext: false,
|
||||
}));
|
||||
|
||||
// ── torrent list ──────────────────────────────────────────────────────
|
||||
app.get('/api/v2/torrents/info', (req, res) => {
|
||||
const list = [...downloads.values()].map(d => ({
|
||||
hash: d.hash,
|
||||
name: d.name,
|
||||
state: d.state,
|
||||
progress: d.progress,
|
||||
size: d.size,
|
||||
downloaded: Math.floor(d.progress * d.size),
|
||||
save_path: SAVE_PATH + '/',
|
||||
content_path: path.join(SAVE_PATH, d.name),
|
||||
num_seeds: 0,
|
||||
num_leechs: 0,
|
||||
ratio: 0,
|
||||
eta: (d.state === 'downloading' || d.state === 'queuedDL') ? 9999 : 0,
|
||||
category: d.category || '',
|
||||
tags: '',
|
||||
}));
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
app.get('/api/v2/torrents/properties', (req, res) => {
|
||||
const d = downloads.get(req.query.hash);
|
||||
if (!d) return res.status(404).json({});
|
||||
res.json({ save_path: SAVE_PATH + '/', hash: d.hash });
|
||||
});
|
||||
|
||||
// Sonarr calls this to discover the file list inside a torrent.
|
||||
// Without it Sonarr falls back to directory-scan mode and reports
|
||||
// "No files found are eligible for import in /path/to/file.mkv".
|
||||
app.get('/api/v2/torrents/files', (req, res) => {
|
||||
const d = downloads.get(req.query.hash);
|
||||
if (!d) return res.status(404).json({});
|
||||
res.json([{
|
||||
name: d.name,
|
||||
size: d.size,
|
||||
progress: d.progress,
|
||||
priority: 1,
|
||||
is_seed: d.state === 'uploading',
|
||||
piece_range: [0, 0],
|
||||
availability: d.state === 'uploading' ? 1 : -1,
|
||||
}]);
|
||||
});
|
||||
|
||||
app.get('/api/v2/torrents/categories', (req, res) => res.json({
|
||||
webshare: { name: 'webshare', savePath: SAVE_PATH + '/' },
|
||||
}));
|
||||
|
||||
// ── add download — accepts both url list AND .torrent file upload ─────
|
||||
app.post('/api/v2/torrents/add', upload.fields([{ name: 'torrents' }]), (req, res) => {
|
||||
res.send('Ok.');
|
||||
|
||||
const body = req.body || {};
|
||||
const category = body.category || '';
|
||||
|
||||
// Case 1: Sonarr sends a .torrent file (multipart upload)
|
||||
const files = req.files && req.files['torrents'];
|
||||
if (files && files.length) {
|
||||
for (const f of files) {
|
||||
const webseedUrl = extractWebseed(f.buffer);
|
||||
const hash = extractInfoHash(f.buffer)
|
||||
|| crypto.createHash('sha1').update(f.buffer).digest('hex');
|
||||
|
||||
if (!webseedUrl) {
|
||||
// Real multi-file torrent — we can't download it.
|
||||
// Record as 'error' so Sonarr stops re-adding this release.
|
||||
if (!downloads.has(hash)) {
|
||||
const name = f.originalname.replace(/\.torrent$/i, '') || hash;
|
||||
downloads.set(hash, { hash, name, state: 'error', progress: 0, size: 0, category });
|
||||
saveState();
|
||||
console.warn(`[qbt] no url-list, recording as error: ${name}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we already have this webseed URL under any hash (e.g. old sha1(url) hash),
|
||||
// re-map it to the correct info hash so Sonarr can track it.
|
||||
const existingByUrl = [...downloads.values()]
|
||||
.find(e => e.webseedUrl === webseedUrl);
|
||||
if (existingByUrl) {
|
||||
if (existingByUrl.hash !== hash) {
|
||||
downloads.delete(existingByUrl.hash);
|
||||
existingByUrl.hash = hash;
|
||||
downloads.set(hash, existingByUrl);
|
||||
saveState();
|
||||
console.log(`[qbt] re-hashed existing entry: ${existingByUrl.name}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (downloads.has(hash)) continue;
|
||||
const name = f.originalname.replace(/\.torrent$/i, '') || hash;
|
||||
const entry = { hash, name, state: 'downloading', progress: 0, size: 0, category };
|
||||
downloads.set(hash, entry);
|
||||
console.log(`[qbt] queued via .torrent: ${name}`);
|
||||
enqueue(entry, webseedUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: url list (our old path, kept as fallback)
|
||||
const urls = (body.urls || '').trim().split(/\s+/).filter(Boolean);
|
||||
for (const url of urls) {
|
||||
const m = url.match(/\/(?:download|stream)\/([^/?&#]+)/);
|
||||
const ident = m ? m[1] : null;
|
||||
const hash = crypto.createHash('sha1').update(url).digest('hex');
|
||||
if (downloads.has(hash)) continue;
|
||||
const entry = { hash, name: (ident || hash), state: 'downloading', progress: 0, size: 0, category };
|
||||
downloads.set(hash, entry);
|
||||
console.log(`[qbt] queued via url: ${url}`);
|
||||
enqueue(entry, url);
|
||||
}
|
||||
});
|
||||
|
||||
// ── cleanup: delete staging files that have been imported or are stale ───
|
||||
// Fast path: Sonarr hardlinks → nlink > 1 → delete immediately.
|
||||
// Fallback: Sonarr copies instead of hardlinks (nlink stays 1) → delete
|
||||
// any completed file whose mtime is older than 2 hours. Never touch files
|
||||
// that are actively downloading or queued.
|
||||
function cleanupStaging() {
|
||||
let staged;
|
||||
try { staged = fs.readdirSync(SAVE_PATH); } catch { return; }
|
||||
const now = Date.now();
|
||||
const TWO_HOURS = 5 * 60 * 1000;
|
||||
|
||||
const activeFiles = new Set(
|
||||
[...downloads.values()]
|
||||
.filter(e => e.state === 'downloading' || e.state === 'queuedDL')
|
||||
.map(e => e.name)
|
||||
);
|
||||
|
||||
for (const fname of staged) {
|
||||
if (fname.startsWith('.')) continue;
|
||||
if (activeFiles.has(fname)) continue;
|
||||
const fpath = path.join(SAVE_PATH, fname);
|
||||
try {
|
||||
const st = fs.statSync(fpath);
|
||||
if (!st.isFile()) continue;
|
||||
if (st.nlink > 1) {
|
||||
fs.unlinkSync(fpath);
|
||||
console.log(`[cleanup] deleted after hardlink: ${fname}`);
|
||||
} else if (now - st.mtimeMs > TWO_HOURS) {
|
||||
fs.unlinkSync(fpath);
|
||||
console.log(`[cleanup] deleted stale staging file (>2h): ${fname}`);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Load persisted state on startup, then run cleanup
|
||||
loadState();
|
||||
cleanupStaging();
|
||||
setInterval(cleanupStaging, 15 * 60 * 1000);
|
||||
|
||||
// ── control ───────────────────────────────────────────────────────────
|
||||
app.post('/api/v2/torrents/delete', (req, res) => {
|
||||
const hashes = (req.body.hashes || '').split('|');
|
||||
const deleteFiles = req.body.deleteFiles === 'true';
|
||||
for (const h of hashes) {
|
||||
const entry = downloads.get(h);
|
||||
if (entry && deleteFiles) {
|
||||
const fpath = path.join(SAVE_PATH, entry.name);
|
||||
try { fs.unlinkSync(fpath); console.log(`[qbt] deleted file: ${entry.name}`); } catch { }
|
||||
}
|
||||
downloads.delete(h);
|
||||
}
|
||||
saveState();
|
||||
res.send('Ok.');
|
||||
});
|
||||
app.post('/api/v2/torrents/pause', (req, res) => res.send('Ok.'));
|
||||
app.post('/api/v2/torrents/resume', (req, res) => res.send('Ok.'));
|
||||
app.post('/api/v2/torrents/setCategory', (req, res) => res.send('Ok.'));
|
||||
app.post('/api/v2/torrents/setLocation', (req, res) => res.send('Ok.'));
|
||||
app.post('/api/v2/torrents/rename', (req, res) => res.send('Ok.'));
|
||||
app.post('/api/v2/torrents/createCategory', (req, res) => res.send('Ok.'));
|
||||
app.post('/api/v2/torrents/editCategory', (req, res) => res.send('Ok.'));
|
||||
app.post('/api/v2/torrents/removeCategories', (req, res) => res.send('Ok.'));
|
||||
}
|
||||
|
||||
module.exports = { register };
|
||||
97
src/torrent.js
Normal file
97
src/torrent.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// Minimal bencode encoder (Buffers for binary fields)
|
||||
function bencode(v) {
|
||||
if (Number.isInteger(v)) return Buffer.from(`i${v}e`);
|
||||
if (Buffer.isBuffer(v)) return Buffer.concat([Buffer.from(`${v.length}:`), v]);
|
||||
if (typeof v === 'string') {
|
||||
const b = Buffer.from(v, 'utf8');
|
||||
return Buffer.concat([Buffer.from(`${b.length}:`), b]);
|
||||
}
|
||||
if (Array.isArray(v))
|
||||
return Buffer.concat([Buffer.from('l'), ...v.map(bencode), Buffer.from('e')]);
|
||||
// dict — keys must be sorted for valid bencoding
|
||||
const keys = Object.keys(v).sort();
|
||||
return Buffer.concat([
|
||||
Buffer.from('d'),
|
||||
...keys.flatMap(k => [bencode(k), bencode(v[k])]),
|
||||
Buffer.from('e'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Minimal bencode decoder — returns Buffers for byte strings
|
||||
function bdecode(buf, pos = { i: 0 }) {
|
||||
const c = buf[pos.i];
|
||||
if (c === 0x69) { // 'i'
|
||||
pos.i++;
|
||||
const end = buf.indexOf(0x65, pos.i); // 'e'
|
||||
const n = parseInt(buf.slice(pos.i, end).toString(), 10);
|
||||
pos.i = end + 1;
|
||||
return n;
|
||||
}
|
||||
if (c === 0x6c) { // 'l'
|
||||
pos.i++;
|
||||
const arr = [];
|
||||
while (buf[pos.i] !== 0x65) arr.push(bdecode(buf, pos));
|
||||
pos.i++;
|
||||
return arr;
|
||||
}
|
||||
if (c === 0x64) { // 'd'
|
||||
pos.i++;
|
||||
const obj = {};
|
||||
while (buf[pos.i] !== 0x65) {
|
||||
const key = bdecode(buf, pos).toString('utf8');
|
||||
obj[key] = bdecode(buf, pos);
|
||||
}
|
||||
pos.i++;
|
||||
return obj;
|
||||
}
|
||||
// byte string: "N:..."
|
||||
const colon = buf.indexOf(0x3a, pos.i); // ':'
|
||||
const len = parseInt(buf.slice(pos.i, colon).toString(), 10);
|
||||
pos.i = colon + 1 + len;
|
||||
return buf.slice(colon + 1, pos.i);
|
||||
}
|
||||
|
||||
// Build a minimal .torrent with an HTTP webseed and zeroed piece hashes.
|
||||
// Our fake qBittorrent skips hash verification so the zeroed hashes are fine.
|
||||
function makeTorrent({ name, size, webseedUrl }) {
|
||||
const PIECE_LEN = 512 * 1024; // 512 KB
|
||||
const numPieces = Math.max(1, Math.ceil((size || 1) / PIECE_LEN));
|
||||
const pieces = Buffer.alloc(20 * numPieces, 0);
|
||||
|
||||
return bencode({
|
||||
'comment': 'Webshare direct download',
|
||||
'created by': 'webshare-api',
|
||||
'info': {
|
||||
'length': size || 0,
|
||||
'name': name,
|
||||
'piece length': PIECE_LEN,
|
||||
'pieces': pieces,
|
||||
},
|
||||
'url-list': webseedUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract the url-list value from a raw .torrent buffer
|
||||
function extractWebseed(torrentBuf) {
|
||||
try {
|
||||
const d = bdecode(torrentBuf);
|
||||
const ul = d['url-list'];
|
||||
if (!ul) return null;
|
||||
if (Buffer.isBuffer(ul)) return ul.toString('utf8');
|
||||
if (Array.isArray(ul) && ul.length) return ul[0].toString('utf8');
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Compute the real torrent info hash (SHA1 of the bencoded info dict).
|
||||
// Sonarr tracks downloads by this hash — using any other hash causes mismatch.
|
||||
function extractInfoHash(torrentBuf) {
|
||||
try {
|
||||
const crypto = require('crypto');
|
||||
const d = bdecode(torrentBuf);
|
||||
if (!d.info) return null;
|
||||
return crypto.createHash('sha1').update(bencode(d.info)).digest('hex');
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
module.exports = { makeTorrent, extractWebseed, extractInfoHash };
|
||||
58
src/torznab.js
Normal file
58
src/torznab.js
Normal file
@@ -0,0 +1,58 @@
|
||||
function escapeXml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function caps() {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<caps>
|
||||
<server version="1.0" title="Webshare" strapline="Webshare.cz" url="https://webshare.cz"/>
|
||||
<limits max="100" default="25"/>
|
||||
<registration available="no" open="no"/>
|
||||
<searching>
|
||||
<search available="yes" supportedParams="q"/>
|
||||
<tv-search available="yes" supportedParams="q,season,ep"/>
|
||||
<movie-search available="yes" supportedParams="q"/>
|
||||
<music-search available="no" supportedParams=""/>
|
||||
<book-search available="no" supportedParams=""/>
|
||||
</searching>
|
||||
<categories>
|
||||
<category id="2000" name="Movies"/>
|
||||
<category id="5000" name="TV"/>
|
||||
<category id="5040" name="TV/HD"/>
|
||||
<category id="5030" name="TV/SD"/>
|
||||
</categories>
|
||||
</caps>`;
|
||||
}
|
||||
|
||||
function feed(items, baseUrl) {
|
||||
const pubDate = new Date().toUTCString();
|
||||
const itemsXml = items.map(item => ` <item>
|
||||
<title>${escapeXml(item.name)}</title>
|
||||
<guid isPermaLink="false">${escapeXml(item.ident)}</guid>
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
<category>5040</category>
|
||||
<enclosure url="${escapeXml(baseUrl + '/download/' + item.ident)}" length="${item.size}" type="application/x-bittorrent"/>
|
||||
<torznab:attr name="category" value="5040"/>
|
||||
<torznab:attr name="size" value="${item.size}"/>
|
||||
<torznab:attr name="seeders" value="1"/>
|
||||
<torznab:attr name="peers" value="1"/>
|
||||
<torznab:attr name="downloadvolumefactor" value="0"/>
|
||||
<torznab:attr name="uploadvolumefactor" value="1"/>
|
||||
</item>`).join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed">
|
||||
<channel>
|
||||
<title>Webshare</title>
|
||||
<description>Webshare.cz API (Torznab façade)</description>
|
||||
<link>https://webshare.cz</link>
|
||||
${itemsXml}
|
||||
</channel>
|
||||
</rss>`;
|
||||
}
|
||||
|
||||
module.exports = { caps, feed };
|
||||
88
src/webshare.js
Normal file
88
src/webshare.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const axios = require('axios');
|
||||
const { parseStringPromise } = require('xml2js');
|
||||
const { passwordDigest } = require('./md5crypt');
|
||||
|
||||
const API = 'https://webshare.cz/api';
|
||||
|
||||
async function post(endpoint, data) {
|
||||
const params = new URLSearchParams(data);
|
||||
const res = await axios.post(`${API}/${endpoint}/`, params.toString(), {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'text/xml' },
|
||||
timeout: 15000,
|
||||
});
|
||||
const parsed = await parseStringPromise(res.data, { explicitArray: false, trim: true });
|
||||
return parsed.response;
|
||||
}
|
||||
|
||||
class WebshareClient {
|
||||
constructor(username, password) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.wst = null;
|
||||
this._loginPromise = null;
|
||||
}
|
||||
|
||||
async _login() {
|
||||
const r = await post('salt', { username_or_email: this.username });
|
||||
if (r.status !== 'OK') throw new Error(`salt() failed: ${r.message}`);
|
||||
const digest = passwordDigest(this.password, r.salt);
|
||||
|
||||
const r2 = await post('login', {
|
||||
username_or_email: this.username,
|
||||
password: digest,
|
||||
keep_logged_in: '1',
|
||||
});
|
||||
if (r2.status !== 'OK') throw new Error(`login() failed: ${r2.message}`);
|
||||
this.wst = r2.token;
|
||||
console.log('Webshare: authenticated');
|
||||
}
|
||||
|
||||
async ensureAuth() {
|
||||
if (this.wst) return;
|
||||
if (!this._loginPromise) this._loginPromise = this._login().finally(() => { this._loginPromise = null; });
|
||||
await this._loginPromise;
|
||||
}
|
||||
|
||||
async search(query, { limit = 30, offset = 0, sort = 'rating' } = {}) {
|
||||
const data = { what: query, category: 'video', sort, limit, offset };
|
||||
if (this.wst) data.wst = this.wst;
|
||||
const r = await post('search', data);
|
||||
if (!r.file) return [];
|
||||
const files = Array.isArray(r.file) ? r.file : [r.file];
|
||||
return files.map(f => ({
|
||||
ident: f.ident,
|
||||
name: f.name,
|
||||
size: parseInt(f.size || '0', 10),
|
||||
votes: parseInt(f.positive_votes || '0', 10),
|
||||
})).slice(0, limit);
|
||||
}
|
||||
|
||||
async getFileLink(ident) {
|
||||
await this.ensureAuth();
|
||||
|
||||
const _request = async () => post('file_link', {
|
||||
ident,
|
||||
download_type: 'video_stream',
|
||||
device_uuid: 'webshare-api',
|
||||
device_vendor: 'Linux',
|
||||
device_model: 'WebshareApi',
|
||||
device_res_x: '1920',
|
||||
device_res_y: '1080',
|
||||
force_https: '1',
|
||||
wst: this.wst,
|
||||
});
|
||||
|
||||
let r = await _request();
|
||||
if (r.status !== 'OK') {
|
||||
// token expired — re-auth and retry once
|
||||
this.wst = null;
|
||||
await this.ensureAuth();
|
||||
r = await _request();
|
||||
}
|
||||
if (r.status === 'FATAL') throw new Error(`file_link() FATAL: ${r.message}`);
|
||||
if (r.status !== 'OK') throw new Error(`file_link() failed: ${r.message}`);
|
||||
return r.link;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { WebshareClient };
|
||||
Reference in New Issue
Block a user