From 3fcadfe97382c718bbd235f68e4a14cf444b0ea2 Mon Sep 17 00:00:00 2001 From: Michal Pemcak Date: Fri, 7 Aug 2026 08:37:10 +0200 Subject: [PATCH] Initial commit: webshare-api Torznab + fake qBittorrent proxy for Webshare.cz, with Compose/Docker packaging, MIT license, and tests. --- .env.example | 17 + .gitignore | 9 + Dockerfile | 7 + LICENSE | 21 ++ README.md | 90 +++++ __tests__/md5crypt.test.js | 35 ++ __tests__/qbt.test.js | 658 +++++++++++++++++++++++++++++++++++++ __tests__/torrent.test.js | 55 ++++ __tests__/torznab.test.js | 73 ++++ __tests__/webshare.test.js | 173 ++++++++++ compose.yaml | 20 ++ jest.config.js | 10 + package.json | 23 ++ src/index.js | 151 +++++++++ src/md5crypt.js | 61 ++++ src/qbt.js | 401 ++++++++++++++++++++++ src/torrent.js | 97 ++++++ src/torznab.js | 58 ++++ src/webshare.js | 88 +++++ 19 files changed, 2047 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __tests__/md5crypt.test.js create mode 100644 __tests__/qbt.test.js create mode 100644 __tests__/torrent.test.js create mode 100644 __tests__/torznab.test.js create mode 100644 __tests__/webshare.test.js create mode 100644 compose.yaml create mode 100644 jest.config.js create mode 100644 package.json create mode 100644 src/index.js create mode 100644 src/md5crypt.js create mode 100644 src/qbt.js create mode 100644 src/torrent.js create mode 100644 src/torznab.js create mode 100644 src/webshare.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e8e49ec --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2098bb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +coverage/ +data/ +.env +.env.* +!.env.example +.queue-state.json +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..028dee5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8751a58 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0258c09 --- /dev/null +++ b/README.md @@ -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://: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. diff --git a/__tests__/md5crypt.test.js b/__tests__/md5crypt.test.js new file mode 100644 index 0000000..29d768d --- /dev/null +++ b/__tests__/md5crypt.test.js @@ -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}$/); + }); +}); diff --git a/__tests__/qbt.test.js b/__tests__/qbt.test.js new file mode 100644 index 0000000..12644cb --- /dev/null +++ b/__tests__/qbt.test.js @@ -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'); + }); +}); diff --git a/__tests__/torrent.test.js b/__tests__/torrent.test.js new file mode 100644 index 0000000..19bb4d9 --- /dev/null +++ b/__tests__/torrent.test.js @@ -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'); + }); +}); diff --git a/__tests__/torznab.test.js b/__tests__/torznab.test.js new file mode 100644 index 0000000..3b82546 --- /dev/null +++ b/__tests__/torznab.test.js @@ -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('')); + 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(''); + expect(result).not.toContain(''); + }); + + it('includes item title', () => { + const result = feed([{ name: 'Show.S01E01.mkv', ident: 'abc', size: 1000, votes: 5 }], baseUrl); + expect(result).toContain('Show.S01E01.mkv'); + }); + + 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: '.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(//g) || []).length).toBe(2); + }); +}); diff --git a/__tests__/webshare.test.js b/__tests__/webshare.test.js new file mode 100644 index 0000000..dfc8f8e --- /dev/null +++ b/__tests__/webshare.test.js @@ -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}`).join(''); + axios.post.mockResolvedValueOnce({ data: `${inner}` }); +} + +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: 'id1Movie.mkv100000010', + }); + 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 elements + axios.post.mockResolvedValueOnce({ + data: `OK + aA.mkv1001 + bB.mkv2002 + `, + }); + 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) => + `id${i}f${i}.mkv1000` + ).join(''); + axios.post.mockResolvedValueOnce({ data: `OK${files}` }); + 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: 'xf.mkv' }); + 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 + }); + }); +}); diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..02c6f78 --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..c80a467 --- /dev/null +++ b/jest.config.js @@ -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, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..443e1fb --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..24c44e6 --- /dev/null +++ b/src/index.js @@ -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(`${err.message}`); + } + } + + res.status(400).send('Unknown function'); +}); + +// ── /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}`)); diff --git a/src/md5crypt.js b/src/md5crypt.js new file mode 100644 index 0000000..ead1a64 --- /dev/null +++ b/src/md5crypt.js @@ -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 }; diff --git a/src/qbt.js b/src/qbt.js new file mode 100644 index 0000000..c1f4f5b --- /dev/null +++ b/src/qbt.js @@ -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 }; diff --git a/src/torrent.js b/src/torrent.js new file mode 100644 index 0000000..c5db398 --- /dev/null +++ b/src/torrent.js @@ -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 }; diff --git a/src/torznab.js b/src/torznab.js new file mode 100644 index 0000000..11ebbf7 --- /dev/null +++ b/src/torznab.js @@ -0,0 +1,58 @@ +function escapeXml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function caps() { + return ` + + + + + + + + + + + + + + + + + +`; +} + +function feed(items, baseUrl) { + const pubDate = new Date().toUTCString(); + const itemsXml = items.map(item => ` + ${escapeXml(item.name)} + ${escapeXml(item.ident)} + ${pubDate} + 5040 + + + + + + + + `).join('\n'); + + return ` + + + Webshare + Webshare.cz API (Torznab façade) + https://webshare.cz +${itemsXml} + +`; +} + +module.exports = { caps, feed }; diff --git a/src/webshare.js b/src/webshare.js new file mode 100644 index 0000000..8e9dbd5 --- /dev/null +++ b/src/webshare.js @@ -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 };