Initial commit: webshare-api
Torznab + fake qBittorrent proxy for Webshare.cz, with Compose/Docker packaging, MIT license, and tests.
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
PORT=3001
|
||||||
|
BASE_URL=http://webshare-api:3001
|
||||||
|
WEBSHARE_USERNAME=
|
||||||
|
WEBSHARE_PASSWORD=
|
||||||
|
|
||||||
|
# Host path mounted into the container (compose.yaml)
|
||||||
|
# DOWNLOAD_HOST_PATH=./data/webshare
|
||||||
|
|
||||||
|
# Optional: periodic Sonarr missing-episode search
|
||||||
|
# SONARR_URL=http://sonarr:8989
|
||||||
|
# SONARR_API_KEY=
|
||||||
|
# SONARR_SEARCH_INTERVAL_HOURS=2
|
||||||
|
|
||||||
|
# Optional download paths inside the container (fake qBittorrent)
|
||||||
|
# DOWNLOAD_PATH=/downloads/webshare
|
||||||
|
# MEDIA_ROOT=/data
|
||||||
|
# MAX_CONCURRENT_DOWNLOADS=2
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.queue-state.json
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY src/ ./src/
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Michal Pemcak
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
90
README.md
Normal file
90
README.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# webshare-api
|
||||||
|
|
||||||
|
Node.js/Express proxy that exposes [Webshare.cz](https://webshare.cz) as protocols *arr apps understand.
|
||||||
|
|
||||||
|
Official Webshare HTTP API documentation: [https://webshare.cz/apidoc/](https://webshare.cz/apidoc/)
|
||||||
|
|
||||||
|
| Role | Endpoints | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| **Torznab indexer** | `GET /api?t=caps\|search\|tvsearch\|movie` | Search → Torznab RSS/XML |
|
||||||
|
| **Fake torrent** | `GET /download/:ident` | Minimal `.torrent` whose webseed points at `/stream/:ident` |
|
||||||
|
| **Stream** | `GET /stream/:ident` | Fresh Webshare `file_link` + HTTP 302 to CDN |
|
||||||
|
| **Fake qBittorrent** | `/api/v2/*` | Download client API; streams the HTTPS file to disk |
|
||||||
|
| **Debug** | `GET /resolve/:ident` | JSON with the resolved CDN URL |
|
||||||
|
|
||||||
|
Webshare download links expire after roughly ten minutes, so the torrent never embeds a CDN URL—only a path on this service that resolves a fresh link at fetch time.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
index.js # Express app + optional Sonarr missing-episode search
|
||||||
|
webshare.js # salt/login, search, file_link
|
||||||
|
md5crypt.js # Unix $1$ MD5-crypt (Webshare password digest)
|
||||||
|
torznab.js # caps + search feed XML
|
||||||
|
torrent.js # bencode + fake torrent + info-hash
|
||||||
|
qbt.js # qBittorrent Web API façade + download queue
|
||||||
|
__tests__/
|
||||||
|
compose.yaml
|
||||||
|
Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables (required unless noted):
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `WEBSHARE_USERNAME` | — | Webshare username or email |
|
||||||
|
| `WEBSHARE_PASSWORD` | — | Webshare password (plain; hashed client-side) |
|
||||||
|
| `PORT` | `3001` | HTTP listen port |
|
||||||
|
| `BASE_URL` | `http://localhost:$PORT` | Public base URL Sonarr must reach (use the Compose service hostname when linking containers) |
|
||||||
|
| `DOWNLOAD_PATH` | `/downloads/webshare` | Where the fake qBittorrent writes files |
|
||||||
|
| `MEDIA_ROOT` | `/data` | Media root (used for path reporting) |
|
||||||
|
| `MAX_CONCURRENT_DOWNLOADS` | `2` | Parallel stream downloads |
|
||||||
|
| `SONARR_URL` | `http://sonarr:8989` | Optional; Sonarr base URL |
|
||||||
|
| `SONARR_API_KEY` | _(empty)_ | Optional; enables periodic missing-episode search |
|
||||||
|
| `SONARR_SEARCH_INTERVAL_HOURS` | `2` | Interval for that search |
|
||||||
|
| `DOWNLOAD_HOST_PATH` | `./data/webshare` | Host path mounted at `DOWNLOAD_PATH` in Compose |
|
||||||
|
|
||||||
|
Copy `.env.example` and fill in credentials. Do not commit a filled `.env`.
|
||||||
|
|
||||||
|
## Run with Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# set WEBSHARE_USERNAME and WEBSHARE_PASSWORD
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
See `compose.yaml`. Override ports, `BASE_URL`, Sonarr settings, and volume mounts via `.env` as needed.
|
||||||
|
|
||||||
|
## Run without Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
export WEBSHARE_USERNAME=...
|
||||||
|
export WEBSHARE_PASSWORD=...
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sonarr / Radarr
|
||||||
|
|
||||||
|
1. **Indexer** (Torznab): `http://<host>:3001/api`
|
||||||
|
2. **Download client** (qBittorrent): host/port of this service; any username/password (login accepts all).
|
||||||
|
|
||||||
|
When both run on the same Compose network, use the service name, e.g. `http://webshare-api:3001`.
|
||||||
|
|
||||||
|
Sonarr hands `.torrent` files to the fake qBittorrent API. This service extracts the webseed, streams the file into `DOWNLOAD_PATH`, and reports progress via `/api/v2/torrents/*`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
|
|
||||||
|
You need a valid Webshare account. This project only speaks their documented HTTP API ([apidoc](https://webshare.cz/apidoc/)). Use at your own risk.
|
||||||
35
__tests__/md5crypt.test.js
Normal file
35
__tests__/md5crypt.test.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const { passwordDigest } = require('../src/md5crypt');
|
||||||
|
|
||||||
|
describe('passwordDigest', () => {
|
||||||
|
it('returns a 40-char hex string', () => {
|
||||||
|
expect(passwordDigest('password', 'salt')).toMatch(/^[0-9a-f]{40}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic', () => {
|
||||||
|
expect(passwordDigest('mypass', 'mysalt')).toBe(passwordDigest('mypass', 'mysalt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs for different passwords', () => {
|
||||||
|
expect(passwordDigest('pass1', 'salt')).not.toBe(passwordDigest('pass2', 'salt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs for different salts', () => {
|
||||||
|
expect(passwordDigest('pass', 'salt1')).not.toBe(passwordDigest('pass', 'salt2'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips $1$ prefix from salt', () => {
|
||||||
|
expect(passwordDigest('pass', '$1$salt')).toBe(passwordDigest('pass', 'salt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates salt to 8 characters', () => {
|
||||||
|
expect(passwordDigest('pass', 'salt1234extra')).toBe(passwordDigest('pass', 'salt1234'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty password', () => {
|
||||||
|
expect(passwordDigest('', 'salt')).toMatch(/^[0-9a-f]{40}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty salt', () => {
|
||||||
|
expect(passwordDigest('pass', '')).toMatch(/^[0-9a-f]{40}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
658
__tests__/qbt.test.js
Normal file
658
__tests__/qbt.test.js
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { makeTorrent } = require('../src/torrent');
|
||||||
|
|
||||||
|
// ── shared temp dir ─────────────────────────────────────────────────────────
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qbt-test-'));
|
||||||
|
process.env.DOWNLOAD_PATH = tmpDir;
|
||||||
|
process.env.MEDIA_ROOT = tmpDir;
|
||||||
|
process.env.MAX_CONCURRENT_DOWNLOADS = '0'; // no actual downloads
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fresh qbt module + express app for each test (module-level state isolation)
|
||||||
|
function makeApp() {
|
||||||
|
let qbtModule;
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
qbtModule = require('../src/qbt');
|
||||||
|
});
|
||||||
|
const app = express();
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
app.use(express.json());
|
||||||
|
qbtModule.register(app);
|
||||||
|
return { app, qbt: qbtModule };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: clean state file between tests that check persistence
|
||||||
|
function cleanState() {
|
||||||
|
try { fs.unlinkSync(path.join(tmpDir, '.queue-state.json')); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: build a real .torrent buffer
|
||||||
|
function makeTorrentBuf(webseedUrl = 'http://webshare-api:3001/stream/testident') {
|
||||||
|
return makeTorrent({ name: 'test.mkv', size: 1000, webseedUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── auth endpoints ───────────────────────────────────────────────────────────
|
||||||
|
describe('auth', () => {
|
||||||
|
let app;
|
||||||
|
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
|
||||||
|
|
||||||
|
it('POST /api/v2/auth/login → Ok.', async () => {
|
||||||
|
const res = await request(app).post('/api/v2/auth/login').send('username=a&password=b');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toBe('Ok.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v2/auth/logout → Ok.', async () => {
|
||||||
|
const res = await request(app).get('/api/v2/auth/logout');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toBe('Ok.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── app info endpoints ───────────────────────────────────────────────────────
|
||||||
|
describe('app info', () => {
|
||||||
|
let app;
|
||||||
|
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
|
||||||
|
|
||||||
|
it('GET /api/v2/app/version → 5.0.0', async () => {
|
||||||
|
expect((await request(app).get('/api/v2/app/version')).text).toBe('5.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v2/app/webapiVersion → 2.8.3', async () => {
|
||||||
|
expect((await request(app).get('/api/v2/app/webapiVersion')).text).toBe('2.8.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v2/app/buildInfo → JSON with version fields', async () => {
|
||||||
|
const res = await request(app).get('/api/v2/app/buildInfo');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ bitness: 64, libtorrent: '2.0.10' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v2/app/preferences → JSON with save_path', async () => {
|
||||||
|
const res = await request(app).get('/api/v2/app/preferences');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.save_path).toBe(tmpDir + '/');
|
||||||
|
expect(res.body.temp_path_enabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── torrent list / properties / categories ───────────────────────────────────
|
||||||
|
describe('torrent list', () => {
|
||||||
|
let app;
|
||||||
|
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
|
||||||
|
|
||||||
|
it('GET /api/v2/torrents/info → empty array initially', async () => {
|
||||||
|
const res = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v2/torrents/properties → 404 for unknown hash', async () => {
|
||||||
|
const res = await request(app).get('/api/v2/torrents/properties?hash=nonexistent');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v2/torrents/categories → returns webshare category', async () => {
|
||||||
|
const res = await request(app).get('/api/v2/torrents/categories');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('webshare');
|
||||||
|
expect(res.body.webshare.savePath).toBe(tmpDir + '/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── add via .torrent upload ──────────────────────────────────────────────────
|
||||||
|
describe('POST /api/v2/torrents/add (.torrent upload)', () => {
|
||||||
|
let app;
|
||||||
|
beforeEach(() => { cleanState(); ({ app } = makeApp()); });
|
||||||
|
|
||||||
|
it('responds Ok. and item appears in info list', async () => {
|
||||||
|
const torrent = makeTorrentBuf();
|
||||||
|
const addRes = await request(app)
|
||||||
|
.post('/api/v2/torrents/add')
|
||||||
|
.attach('torrents', torrent, 'test.torrent');
|
||||||
|
expect(addRes.status).toBe(200);
|
||||||
|
expect(addRes.text).toBe('Ok.');
|
||||||
|
|
||||||
|
const infoRes = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(infoRes.body).toHaveLength(1);
|
||||||
|
expect(infoRes.body[0].name).toBe('test');
|
||||||
|
expect(infoRes.body[0].state).toBe('queuedDL');
|
||||||
|
expect(infoRes.body[0].eta).toBe(9999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets category from request body', async () => {
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/cattest');
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v2/torrents/add')
|
||||||
|
.field('category', 'webshare')
|
||||||
|
.attach('torrents', torrent, 'cat.torrent');
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body[0].category).toBe('webshare');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips duplicate (same webseed URL)', async () => {
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/dup');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'dup.torrent');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'dup.torrent');
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips torrent with no url-list', async () => {
|
||||||
|
// A buffer that decodes to a dict without url-list
|
||||||
|
const invalid = Buffer.from('d4:infod6:lengthi0e4:name4:teste12:piece lengthi524288e6:piecese', 'utf8');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', invalid, 'bad.torrent');
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('item has correct save_path and content_path', async () => {
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/pathtest');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'path.torrent');
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body[0].save_path).toBe(tmpDir + '/');
|
||||||
|
expect(body[0].content_path).toContain(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v2/torrents/properties returns data for added item', async () => {
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/proptest');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'prop.torrent');
|
||||||
|
const { body: items } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
const hash = items[0].hash;
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/v2/torrents/properties?hash=${hash}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.hash).toBe(hash);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── add via URL list (legacy fallback) ──────────────────────────────────────
|
||||||
|
describe('POST /api/v2/torrents/add (url list)', () => {
|
||||||
|
let app;
|
||||||
|
beforeEach(() => { cleanState(); ({ app } = makeApp()); });
|
||||||
|
|
||||||
|
it('adds item from url list', async () => {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v2/torrents/add')
|
||||||
|
.send('urls=http://webshare-api:3001/stream/urltest1');
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].name).toBe('urltest1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts ident from /download/ path', async () => {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v2/torrents/add')
|
||||||
|
.send('urls=http://webshare-api:3001/download/myident99');
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body[0].name).toBe('myident99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips duplicate URLs', async () => {
|
||||||
|
const url = 'http://webshare-api:3001/stream/dup2';
|
||||||
|
await request(app).post('/api/v2/torrents/add').send(`urls=${url}`);
|
||||||
|
await request(app).post('/api/v2/torrents/add').send(`urls=${url}`);
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── delete ───────────────────────────────────────────────────────────────────
|
||||||
|
describe('POST /api/v2/torrents/delete', () => {
|
||||||
|
let app;
|
||||||
|
beforeEach(() => { cleanState(); ({ app } = makeApp()); });
|
||||||
|
|
||||||
|
async function addAndGetHash(webseedUrl = 'http://webshare-api:3001/stream/deltest') {
|
||||||
|
const torrent = makeTorrentBuf(webseedUrl);
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 't.torrent');
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
return body[0].hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('removes item from list', async () => {
|
||||||
|
const hash = await addAndGetHash();
|
||||||
|
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=false`);
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds Ok.', async () => {
|
||||||
|
const hash = await addAndGetHash('http://webshare-api:3001/stream/delok');
|
||||||
|
const res = await request(app).post('/api/v2/torrents/delete')
|
||||||
|
.send(`hashes=${hash}&deleteFiles=false`);
|
||||||
|
expect(res.text).toBe('Ok.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteFiles=true attempts fs.unlink on the file', async () => {
|
||||||
|
const hash = await addAndGetHash('http://webshare-api:3001/stream/delfile');
|
||||||
|
// Write a fake file so unlinkSync has something to delete
|
||||||
|
const name = (await request(app).get('/api/v2/torrents/info')).body[0].name;
|
||||||
|
const fpath = path.join(tmpDir, name);
|
||||||
|
fs.writeFileSync(fpath, 'fake data');
|
||||||
|
|
||||||
|
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=true`);
|
||||||
|
|
||||||
|
expect(fs.existsSync(fpath)).toBe(false);
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteFiles=false does not delete file', async () => {
|
||||||
|
const hash = await addAndGetHash('http://webshare-api:3001/stream/nodelf');
|
||||||
|
const name = (await request(app).get('/api/v2/torrents/info')).body[0].name;
|
||||||
|
const fpath = path.join(tmpDir, name);
|
||||||
|
fs.writeFileSync(fpath, 'keep me');
|
||||||
|
|
||||||
|
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=false`);
|
||||||
|
|
||||||
|
expect(fs.existsSync(fpath)).toBe(true);
|
||||||
|
fs.unlinkSync(fpath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unknown hashes gracefully', async () => {
|
||||||
|
const res = await request(app).post('/api/v2/torrents/delete')
|
||||||
|
.send('hashes=nonexistenthash&deleteFiles=false');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toBe('Ok.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── no-op control endpoints ──────────────────────────────────────────────────
|
||||||
|
describe('no-op control endpoints', () => {
|
||||||
|
let app;
|
||||||
|
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
'pause', 'resume', 'setCategory', 'setLocation',
|
||||||
|
'rename', 'createCategory', 'editCategory', 'removeCategories',
|
||||||
|
];
|
||||||
|
for (const ep of endpoints) {
|
||||||
|
it(`POST /api/v2/torrents/${ep} → Ok.`, async () => {
|
||||||
|
const res = await request(app).post(`/api/v2/torrents/${ep}`).send('hashes=abc');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toBe('Ok.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── state persistence ────────────────────────────────────────────────────────
|
||||||
|
describe('state persistence', () => {
|
||||||
|
beforeEach(cleanState);
|
||||||
|
|
||||||
|
it('saveState writes JSON file and loadState restores queuedDL items', async () => {
|
||||||
|
const { app: app1 } = makeApp();
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/persist1');
|
||||||
|
await request(app1).post('/api/v2/torrents/add').attach('torrents', torrent, 't.torrent');
|
||||||
|
|
||||||
|
// State file should exist
|
||||||
|
const stateFile = path.join(tmpDir, '.queue-state.json');
|
||||||
|
expect(fs.existsSync(stateFile)).toBe(true);
|
||||||
|
const state = JSON.parse(fs.readFileSync(stateFile));
|
||||||
|
expect(state).toHaveLength(1);
|
||||||
|
expect(state[0].state).toBe('queuedDL');
|
||||||
|
expect(state[0].webseedUrl).toBe('http://webshare-api:3001/stream/persist1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadState re-queues downloading items and deletes partial files', async () => {
|
||||||
|
const webseedUrl = 'http://webshare-api:3001/stream/requeue1';
|
||||||
|
const partialFile = path.join(tmpDir, 'partial.mkv');
|
||||||
|
fs.writeFileSync(partialFile, 'partial data');
|
||||||
|
|
||||||
|
// Write a state file manually as if a download was in progress
|
||||||
|
const state = [{ hash: 'abc123', name: 'partial.mkv', size: 0, progress: 0.5,
|
||||||
|
state: 'downloading', category: '', webseedUrl }];
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
|
||||||
|
|
||||||
|
const { app: app2 } = makeApp();
|
||||||
|
const { body } = await request(app2).get('/api/v2/torrents/info');
|
||||||
|
// Should be re-queued
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].state).toBe('queuedDL');
|
||||||
|
expect(body[0].progress).toBe(0);
|
||||||
|
// Partial file should be deleted
|
||||||
|
expect(fs.existsSync(partialFile)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadState restores uploading items when file exists', async () => {
|
||||||
|
const fname = 'done.mkv';
|
||||||
|
fs.writeFileSync(path.join(tmpDir, fname), 'complete file');
|
||||||
|
const state = [{ hash: 'def456', name: fname, size: 100, progress: 1.0,
|
||||||
|
state: 'uploading', category: '' }];
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
|
||||||
|
|
||||||
|
const { app: app3 } = makeApp();
|
||||||
|
const { body } = await request(app3).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].state).toBe('uploading');
|
||||||
|
expect(body[0].progress).toBe(1.0);
|
||||||
|
|
||||||
|
fs.unlinkSync(path.join(tmpDir, fname));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadState re-queues uploading items when file is missing', async () => {
|
||||||
|
const webseedUrl = 'http://webshare-api:3001/stream/missingup';
|
||||||
|
const state = [{ hash: 'ghi789', name: 'missing.mkv', size: 0, progress: 1.0,
|
||||||
|
state: 'uploading', category: '', webseedUrl }];
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
|
||||||
|
|
||||||
|
const { app: app4 } = makeApp();
|
||||||
|
const { body } = await request(app4).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].state).toBe('queuedDL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadState re-queues error items', async () => {
|
||||||
|
const webseedUrl = 'http://webshare-api:3001/stream/errretry';
|
||||||
|
const state = [{ hash: 'jkl000', name: 'errored.mkv', size: 0, progress: 0,
|
||||||
|
state: 'error', category: '', webseedUrl }];
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
|
||||||
|
|
||||||
|
const { app: app5 } = makeApp();
|
||||||
|
const { body } = await request(app5).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].state).toBe('queuedDL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadState preserves other states as-is', async () => {
|
||||||
|
const state = [{ hash: 'mno111', name: 'other.mkv', size: 0, progress: 0,
|
||||||
|
state: 'stalledUP', category: '' }];
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
|
||||||
|
|
||||||
|
const { app: app6 } = makeApp();
|
||||||
|
const { body } = await request(app6).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].state).toBe('stalledUP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadState handles missing state file gracefully', () => {
|
||||||
|
expect(() => {
|
||||||
|
const { app: app7 } = makeApp();
|
||||||
|
return app7; // just needs to not throw
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadState handles corrupt state file gracefully', () => {
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), 'not valid json{{{');
|
||||||
|
expect(() => { makeApp(); }).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── cleanupStaging ───────────────────────────────────────────────────────────
|
||||||
|
describe('cleanupStaging (runs on startup)', () => {
|
||||||
|
beforeEach(cleanState);
|
||||||
|
afterEach(cleanState);
|
||||||
|
|
||||||
|
it('deletes files with nlink > 1 immediately', () => {
|
||||||
|
const fpath = path.join(tmpDir, 'hardlinked.mkv');
|
||||||
|
fs.writeFileSync(fpath, 'data');
|
||||||
|
// Create a hardlink so nlink > 1
|
||||||
|
const link = path.join(tmpDir, 'hardlinked-link.mkv');
|
||||||
|
fs.linkSync(fpath, link);
|
||||||
|
|
||||||
|
makeApp(); // triggers cleanupStaging
|
||||||
|
|
||||||
|
expect(fs.existsSync(fpath)).toBe(false);
|
||||||
|
fs.unlinkSync(link); // cleanup the link
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes files older than 2 hours that are not active downloads', () => {
|
||||||
|
const fpath = path.join(tmpDir, 'stale.mkv');
|
||||||
|
fs.writeFileSync(fpath, 'old data');
|
||||||
|
// Backdate mtime to 3 hours ago
|
||||||
|
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
||||||
|
fs.utimesSync(fpath, threeHoursAgo, threeHoursAgo);
|
||||||
|
|
||||||
|
makeApp(); // triggers cleanupStaging
|
||||||
|
|
||||||
|
expect(fs.existsSync(fpath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves files that are currently downloading', () => {
|
||||||
|
const fname = 'active-download.mkv';
|
||||||
|
const fpath = path.join(tmpDir, fname);
|
||||||
|
fs.writeFileSync(fpath, 'partial');
|
||||||
|
// Backdate to 3 hours ago — would normally be deleted
|
||||||
|
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
||||||
|
fs.utimesSync(fpath, old, old);
|
||||||
|
|
||||||
|
// Write state showing this file is actively downloading
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify([{
|
||||||
|
hash: 'activehash', name: fname, size: 0, progress: 0.5,
|
||||||
|
state: 'downloading', category: '', webseedUrl: 'http://webshare-api:3001/stream/active',
|
||||||
|
}]));
|
||||||
|
|
||||||
|
makeApp(); // triggers cleanupStaging
|
||||||
|
|
||||||
|
// File should still exist because it's in active download state
|
||||||
|
expect(fs.existsSync(fpath)).toBe(true);
|
||||||
|
fs.unlinkSync(fpath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves recent files (< 2 hours old)', () => {
|
||||||
|
const fpath = path.join(tmpDir, 'recent.mkv');
|
||||||
|
fs.writeFileSync(fpath, 'fresh data');
|
||||||
|
// mtime is just now, well under 2 hours
|
||||||
|
|
||||||
|
makeApp();
|
||||||
|
|
||||||
|
expect(fs.existsSync(fpath)).toBe(true);
|
||||||
|
fs.unlinkSync(fpath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips dotfiles (like .queue-state.json)', () => {
|
||||||
|
// State file must not be deleted even if it's old
|
||||||
|
const stateContent = JSON.stringify([]);
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), stateContent);
|
||||||
|
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
||||||
|
fs.utimesSync(path.join(tmpDir, '.queue-state.json'), old, old);
|
||||||
|
|
||||||
|
makeApp();
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, '.queue-state.json'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── download pipeline (mocked http) ─────────────────────────────────────────
|
||||||
|
describe('download pipeline', () => {
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanState();
|
||||||
|
// Allow 1 concurrent download for these tests
|
||||||
|
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks entry as uploading after successful download', async () => {
|
||||||
|
// Mock http.get to write a small file
|
||||||
|
const httpMod = require('http');
|
||||||
|
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||||
|
const res = new EventEmitter();
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.headers = { 'content-length': '10' };
|
||||||
|
res.resume = jest.fn();
|
||||||
|
res.destroy = jest.fn();
|
||||||
|
res.pipe = jest.fn((dest) => {
|
||||||
|
setImmediate(() => {
|
||||||
|
// Write actual content to the file so createWriteStream's finish fires
|
||||||
|
dest.emit('finish');
|
||||||
|
});
|
||||||
|
return dest;
|
||||||
|
});
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also mock fs.createWriteStream to avoid writing real files here
|
||||||
|
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
|
||||||
|
(() => {
|
||||||
|
const w = new EventEmitter();
|
||||||
|
w.close = (cb) => cb && cb();
|
||||||
|
return w;
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { app } = makeApp();
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/dltest');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'dl.torrent');
|
||||||
|
|
||||||
|
// Wait for async download to complete
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body[0].state).toBe('uploading');
|
||||||
|
expect(body[0].progress).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks entry as error on HTTP failure', async () => {
|
||||||
|
const httpMod = require('http');
|
||||||
|
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||||
|
const res = new EventEmitter();
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.resume = jest.fn();
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = makeApp();
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/err404');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'err.torrent');
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body[0].state).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries on stalled download (ECONNRESET)', async () => {
|
||||||
|
const httpMod = require('http');
|
||||||
|
let callCount = 0;
|
||||||
|
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||||
|
callCount++;
|
||||||
|
const res = new EventEmitter();
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.headers = { 'content-length': '10' };
|
||||||
|
res.resume = jest.fn();
|
||||||
|
res.destroy = jest.fn();
|
||||||
|
res.pipe = jest.fn((dest) => {
|
||||||
|
setImmediate(() => {
|
||||||
|
if (callCount === 1) {
|
||||||
|
res.emit('error', new Error('ECONNRESET'));
|
||||||
|
} else {
|
||||||
|
dest.emit('finish');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return dest;
|
||||||
|
});
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
|
||||||
|
(() => { const w = new EventEmitter(); w.close = (cb) => cb && cb(); return w; })()
|
||||||
|
);
|
||||||
|
jest.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const { app } = makeApp();
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/retry');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'retry.torrent');
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
|
||||||
|
expect(callCount).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows HTTP redirects', async () => {
|
||||||
|
const httpMod = require('http');
|
||||||
|
let call = 0;
|
||||||
|
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||||
|
call++;
|
||||||
|
const res = new EventEmitter();
|
||||||
|
if (call === 1) {
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.headers = { location: 'http://cdn.example.com/file.mkv' };
|
||||||
|
res.resume = jest.fn();
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
} else {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.headers = { 'content-length': '5' };
|
||||||
|
res.destroy = jest.fn();
|
||||||
|
res.pipe = jest.fn((dest) => { setImmediate(() => dest.emit('finish')); return dest; });
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
}
|
||||||
|
return { on: jest.fn() };
|
||||||
|
});
|
||||||
|
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
|
||||||
|
(() => { const w = new EventEmitter(); w.close = (cb) => cb && cb(); return w; })()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { app } = makeApp();
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/redir');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'redir.torrent');
|
||||||
|
await new Promise(r => setTimeout(r, 150));
|
||||||
|
|
||||||
|
expect(call).toBe(2); // original + redirected
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects on too many redirects', async () => {
|
||||||
|
const httpMod = require('http');
|
||||||
|
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||||
|
const res = new EventEmitter();
|
||||||
|
res.statusCode = 301;
|
||||||
|
res.headers = { location: url }; // redirect to itself
|
||||||
|
res.resume = jest.fn();
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = makeApp();
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/loop');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'loop.torrent');
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body[0].state).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates filename from Content-Disposition header', async () => {
|
||||||
|
const httpMod = require('http');
|
||||||
|
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||||
|
const res = new EventEmitter();
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.headers = {
|
||||||
|
'content-length': '10',
|
||||||
|
'content-disposition': 'attachment; filename="renamed.mkv"',
|
||||||
|
};
|
||||||
|
res.destroy = jest.fn();
|
||||||
|
res.pipe = jest.fn((dest) => { setImmediate(() => dest.emit('finish')); return dest; });
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn() };
|
||||||
|
});
|
||||||
|
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
|
||||||
|
(() => { const w = new EventEmitter(); w.close = (cb) => cb && cb(); return w; })()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { app } = makeApp();
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/rename');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'orig.torrent');
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body[0].name).toBe('renamed.mkv');
|
||||||
|
});
|
||||||
|
});
|
||||||
55
__tests__/torrent.test.js
Normal file
55
__tests__/torrent.test.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const { makeTorrent, extractWebseed } = require('../src/torrent');
|
||||||
|
|
||||||
|
describe('makeTorrent + extractWebseed round-trip', () => {
|
||||||
|
it('extracts the webseed URL back from a generated torrent', () => {
|
||||||
|
const url = 'http://example.com/stream/abc123';
|
||||||
|
const buf = makeTorrent({ name: 'test.mkv', size: 1024 * 1024, webseedUrl: url });
|
||||||
|
expect(extractWebseed(buf)).toBe(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with size 0', () => {
|
||||||
|
const url = 'http://example.com/stream/xyz';
|
||||||
|
const buf = makeTorrent({ name: 'empty.mkv', size: 0, webseedUrl: url });
|
||||||
|
expect(extractWebseed(buf)).toBe(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a Buffer', () => {
|
||||||
|
const buf = makeTorrent({ name: 'f.mkv', size: 100, webseedUrl: 'http://x.com/s/y' });
|
||||||
|
expect(Buffer.isBuffer(buf)).toBe(true);
|
||||||
|
expect(buf.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes name into the torrent', () => {
|
||||||
|
const buf = makeTorrent({ name: 'movie.mkv', size: 500, webseedUrl: 'http://x.com/s/z' });
|
||||||
|
expect(buf.toString()).toContain('movie.mkv');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractWebseed', () => {
|
||||||
|
it('returns null for empty buffer', () => {
|
||||||
|
expect(extractWebseed(Buffer.alloc(0))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-torrent data', () => {
|
||||||
|
expect(extractWebseed(Buffer.from('not a torrent'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when url-list is missing', () => {
|
||||||
|
// Bencode a dict without url-list
|
||||||
|
const buf = makeTorrent({ name: 'f.mkv', size: 0, webseedUrl: 'http://x.com/s/a' });
|
||||||
|
// Corrupt the url-list key to make it missing
|
||||||
|
const str = buf.toString('binary').replace('url-list', 'url-xxxx');
|
||||||
|
const corrupted = Buffer.from(str, 'binary');
|
||||||
|
expect(extractWebseed(corrupted)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles url-list as array', () => {
|
||||||
|
// Manually build a torrent-like bencode with url-list as list
|
||||||
|
const { makeTorrent: mt } = require('../src/torrent');
|
||||||
|
// We test via the actual makeTorrent which uses a string — round-trip test above covers it.
|
||||||
|
// Here just verify the array branch: if url-list is a Buffer (string) it still returns a string.
|
||||||
|
const url = 'http://cdn.example.com/stream/abcdef';
|
||||||
|
const buf = mt({ name: 't.mkv', size: 50, webseedUrl: url });
|
||||||
|
expect(typeof extractWebseed(buf)).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
73
__tests__/torznab.test.js
Normal file
73
__tests__/torznab.test.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const { caps, feed } = require('../src/torznab');
|
||||||
|
|
||||||
|
describe('caps()', () => {
|
||||||
|
let result;
|
||||||
|
beforeAll(() => { result = caps(); });
|
||||||
|
|
||||||
|
it('returns a string', () => expect(typeof result).toBe('string'));
|
||||||
|
it('starts with XML declaration', () => expect(result).toMatch(/^<\?xml/));
|
||||||
|
it('contains caps element', () => expect(result).toContain('<caps>'));
|
||||||
|
it('advertises tv-search', () => expect(result).toContain('tv-search'));
|
||||||
|
it('advertises movie-search', () => expect(result).toContain('movie-search'));
|
||||||
|
it('has Movies category id 2000', () => expect(result).toContain('id="2000"'));
|
||||||
|
it('has TV category id 5000', () => expect(result).toContain('id="5000"'));
|
||||||
|
it('marks registration as not available', () => expect(result).toContain('available="no"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('feed()', () => {
|
||||||
|
const baseUrl = 'http://localhost:3001';
|
||||||
|
|
||||||
|
it('returns valid XML with no items', () => {
|
||||||
|
const result = feed([], baseUrl);
|
||||||
|
expect(result).toMatch(/^<\?xml/);
|
||||||
|
expect(result).toContain('<channel>');
|
||||||
|
expect(result).not.toContain('<item>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes item title', () => {
|
||||||
|
const result = feed([{ name: 'Show.S01E01.mkv', ident: 'abc', size: 1000, votes: 5 }], baseUrl);
|
||||||
|
expect(result).toContain('<title>Show.S01E01.mkv</title>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes correct download URL in enclosure', () => {
|
||||||
|
const result = feed([{ name: 'f.mkv', ident: 'xyz789', size: 500, votes: 0 }], baseUrl);
|
||||||
|
expect(result).toContain(`${baseUrl}/download/xyz789`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes file size in enclosure', () => {
|
||||||
|
const result = feed([{ name: 'f.mkv', ident: 'id1', size: 123456, votes: 0 }], baseUrl);
|
||||||
|
expect(result).toContain('length="123456"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes & in title', () => {
|
||||||
|
const result = feed([{ name: 'Tom & Jerry.mkv', ident: 'id', size: 0, votes: 0 }], baseUrl);
|
||||||
|
expect(result).toContain('Tom & Jerry.mkv');
|
||||||
|
expect(result).not.toContain('Tom & Jerry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes < and > in name', () => {
|
||||||
|
const result = feed([{ name: '<bad>.mkv', ident: 'id', size: 0, votes: 0 }], baseUrl);
|
||||||
|
expect(result).toContain('<bad>.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes " in name', () => {
|
||||||
|
const result = feed([{ name: '"quoted".mkv', ident: 'id', size: 0, votes: 0 }], baseUrl);
|
||||||
|
expect(result).toContain('"quoted".mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes torznab:attr for category 5040', () => {
|
||||||
|
const result = feed([{ name: 'f.mkv', ident: 'id', size: 0, votes: 0 }], baseUrl);
|
||||||
|
expect(result).toContain('name="category" value="5040"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple items', () => {
|
||||||
|
const items = [
|
||||||
|
{ name: 'ep1.mkv', ident: 'a', size: 100, votes: 1 },
|
||||||
|
{ name: 'ep2.mkv', ident: 'b', size: 200, votes: 2 },
|
||||||
|
];
|
||||||
|
const result = feed(items, baseUrl);
|
||||||
|
expect(result).toContain('ep1.mkv');
|
||||||
|
expect(result).toContain('ep2.mkv');
|
||||||
|
expect((result.match(/<item>/g) || []).length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
173
__tests__/webshare.test.js
Normal file
173
__tests__/webshare.test.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
jest.mock('axios');
|
||||||
|
const axios = require('axios');
|
||||||
|
const { WebshareClient } = require('../src/webshare');
|
||||||
|
|
||||||
|
// Helper: make axios.post return a parsed XML body
|
||||||
|
function mockResponse(fields) {
|
||||||
|
const inner = Object.entries(fields).map(([k, v]) => `<${k}>${v}</${k}>`).join('');
|
||||||
|
axios.post.mockResolvedValueOnce({ data: `<response>${inner}</response>` });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WebshareClient', () => {
|
||||||
|
let client;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
client = new WebshareClient('user', 'pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ensureAuth / _login ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ensureAuth()', () => {
|
||||||
|
it('calls login and stores token', async () => {
|
||||||
|
mockResponse({ status: 'OK', salt: 'abcdefgh' });
|
||||||
|
mockResponse({ status: 'OK', token: 'tok123' });
|
||||||
|
await client.ensureAuth();
|
||||||
|
expect(client.wst).toBe('tok123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op if token already set', async () => {
|
||||||
|
client.wst = 'existing-token';
|
||||||
|
await client.ensureAuth();
|
||||||
|
expect(axios.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates concurrent calls (only one login)', async () => {
|
||||||
|
mockResponse({ status: 'OK', salt: 'salthash' });
|
||||||
|
mockResponse({ status: 'OK', token: 'tok456' });
|
||||||
|
await Promise.all([client.ensureAuth(), client.ensureAuth(), client.ensureAuth()]);
|
||||||
|
// salt + login = 2 calls, not 6
|
||||||
|
expect(axios.post).toHaveBeenCalledTimes(2);
|
||||||
|
expect(client.wst).toBe('tok456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when salt request fails', async () => {
|
||||||
|
mockResponse({ status: 'FATAL', message: 'User not found' });
|
||||||
|
await expect(client.ensureAuth()).rejects.toThrow('salt() failed: User not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when login request fails', async () => {
|
||||||
|
mockResponse({ status: 'OK', salt: 'abcdefgh' });
|
||||||
|
mockResponse({ status: 'FATAL', message: 'Wrong password' });
|
||||||
|
await expect(client.ensureAuth()).rejects.toThrow('login() failed: Wrong password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears _loginPromise after completion', async () => {
|
||||||
|
mockResponse({ status: 'OK', salt: 'abc12345' });
|
||||||
|
mockResponse({ status: 'OK', token: 'tok789' });
|
||||||
|
await client.ensureAuth();
|
||||||
|
expect(client._loginPromise).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── search ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('search()', () => {
|
||||||
|
it('returns mapped file list', async () => {
|
||||||
|
mockResponse({
|
||||||
|
status: 'OK',
|
||||||
|
file: '<ident>id1</ident><name>Movie.mkv</name><size>1000000</size><positive_votes>10</positive_votes>',
|
||||||
|
});
|
||||||
|
const results = await client.search('Movie');
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0]).toEqual({ ident: 'id1', name: 'Movie.mkv', size: 1000000, votes: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no files', async () => {
|
||||||
|
mockResponse({ status: 'OK' });
|
||||||
|
const results = await client.search('nothing');
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles array of files', async () => {
|
||||||
|
// xml2js returns an array when there are multiple <file> elements
|
||||||
|
axios.post.mockResolvedValueOnce({
|
||||||
|
data: `<response><status>OK</status>
|
||||||
|
<file><ident>a</ident><name>A.mkv</name><size>100</size><positive_votes>1</positive_votes></file>
|
||||||
|
<file><ident>b</ident><name>B.mkv</name><size>200</size><positive_votes>2</positive_votes></file>
|
||||||
|
</response>`,
|
||||||
|
});
|
||||||
|
const results = await client.search('test');
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0].ident).toBe('a');
|
||||||
|
expect(results[1].ident).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects limit parameter', async () => {
|
||||||
|
// Build 5 files
|
||||||
|
const files = Array.from({ length: 5 }, (_, i) =>
|
||||||
|
`<file><ident>id${i}</ident><name>f${i}.mkv</name><size>100</size><positive_votes>0</positive_votes></file>`
|
||||||
|
).join('');
|
||||||
|
axios.post.mockResolvedValueOnce({ data: `<response><status>OK</status>${files}</response>` });
|
||||||
|
const results = await client.search('test', { limit: 3 });
|
||||||
|
expect(results).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes wst in request when authenticated', async () => {
|
||||||
|
client.wst = 'mytoken';
|
||||||
|
mockResponse({ status: 'OK' });
|
||||||
|
await client.search('q');
|
||||||
|
const body = new URLSearchParams(axios.post.mock.calls[0][1]);
|
||||||
|
expect(body.get('wst')).toBe('mytoken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits wst when not authenticated', async () => {
|
||||||
|
mockResponse({ status: 'OK' });
|
||||||
|
await client.search('q');
|
||||||
|
const body = new URLSearchParams(axios.post.mock.calls[0][1]);
|
||||||
|
expect(body.get('wst')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to missing size/votes as 0', async () => {
|
||||||
|
mockResponse({ status: 'OK', file: '<ident>x</ident><name>f.mkv</name>' });
|
||||||
|
const [result] = await client.search('q');
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
expect(result.votes).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getFileLink ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getFileLink()', () => {
|
||||||
|
it('returns link on success', async () => {
|
||||||
|
client.wst = 'tok';
|
||||||
|
mockResponse({ status: 'OK', link: 'https://cdn.example.com/file.mkv' });
|
||||||
|
const link = await client.getFileLink('ident123');
|
||||||
|
expect(link).toBe('https://cdn.example.com/file.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-authenticates and retries when token is expired', async () => {
|
||||||
|
client.wst = 'expired-token';
|
||||||
|
// First file_link call fails (token expired)
|
||||||
|
mockResponse({ status: 'FATAL', message: 'Token invalid' });
|
||||||
|
// Re-login
|
||||||
|
mockResponse({ status: 'OK', salt: 'newsalt1' });
|
||||||
|
mockResponse({ status: 'OK', token: 'fresh-token' });
|
||||||
|
// Retry file_link succeeds
|
||||||
|
mockResponse({ status: 'OK', link: 'https://cdn.example.com/fresh.mkv' });
|
||||||
|
|
||||||
|
const link = await client.getFileLink('ident456');
|
||||||
|
expect(link).toBe('https://cdn.example.com/fresh.mkv');
|
||||||
|
expect(client.wst).toBe('fresh-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when both attempts fail', async () => {
|
||||||
|
client.wst = 'tok';
|
||||||
|
mockResponse({ status: 'FATAL', message: 'File unavailable' });
|
||||||
|
mockResponse({ status: 'OK', salt: 'salt123a' });
|
||||||
|
mockResponse({ status: 'OK', token: 'tok2' });
|
||||||
|
mockResponse({ status: 'FATAL', message: 'File unavailable' });
|
||||||
|
await expect(client.getFileLink('badident')).rejects.toThrow('file_link() failed: File unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls ensureAuth before requesting link', async () => {
|
||||||
|
// client.wst is null — must login first
|
||||||
|
mockResponse({ status: 'OK', salt: 'saltsalt' });
|
||||||
|
mockResponse({ status: 'OK', token: 'tok-new' });
|
||||||
|
mockResponse({ status: 'OK', link: 'https://cdn.example.com/x.mkv' });
|
||||||
|
const link = await client.getFileLink('identX');
|
||||||
|
expect(link).toBe('https://cdn.example.com/x.mkv');
|
||||||
|
expect(axios.post).toHaveBeenCalledTimes(3); // salt + login + file_link
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
20
compose.yaml
Normal file
20
compose.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
webshare-api:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3001}:3001"
|
||||||
|
environment:
|
||||||
|
PORT: "3001"
|
||||||
|
BASE_URL: ${BASE_URL:-http://webshare-api:3001}
|
||||||
|
WEBSHARE_USERNAME: ${WEBSHARE_USERNAME:?set WEBSHARE_USERNAME}
|
||||||
|
WEBSHARE_PASSWORD: ${WEBSHARE_PASSWORD:?set WEBSHARE_PASSWORD}
|
||||||
|
DOWNLOAD_PATH: ${DOWNLOAD_PATH:-/downloads/webshare}
|
||||||
|
MEDIA_ROOT: ${MEDIA_ROOT:-/data}
|
||||||
|
MAX_CONCURRENT_DOWNLOADS: ${MAX_CONCURRENT_DOWNLOADS:-2}
|
||||||
|
SONARR_URL: ${SONARR_URL:-http://sonarr:8989}
|
||||||
|
SONARR_API_KEY: ${SONARR_API_KEY:-}
|
||||||
|
SONARR_SEARCH_INTERVAL_HOURS: ${SONARR_SEARCH_INTERVAL_HOURS:-2}
|
||||||
|
volumes:
|
||||||
|
# Mount the download directory used by the fake qBittorrent client
|
||||||
|
- ${DOWNLOAD_HOST_PATH:-./data/webshare}:/downloads/webshare
|
||||||
|
restart: unless-stopped
|
||||||
10
jest.config.js
Normal file
10
jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['**/__tests__/**/*.test.js'],
|
||||||
|
collectCoverageFrom: ['src/**/*.js', '!src/index.js'],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: { lines: 90, branches: 80, functions: 90, statements: 90 },
|
||||||
|
},
|
||||||
|
testTimeout: 15000,
|
||||||
|
forceExit: true,
|
||||||
|
};
|
||||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "webshare-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Webshare.cz HTTP API: Torznab indexer + fake qBittorrent client + stream redirect for Sonarr/Radarr",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"test": "jest --coverage --runInBand"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"express": "^4.19.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"xml2js": "^0.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^30.4.2",
|
||||||
|
"supertest": "^7.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
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