Initial commit: webshare-api

Torznab + fake qBittorrent proxy for Webshare.cz, with Compose/Docker
packaging, MIT license, and tests.
This commit is contained in:
2026-08-07 08:37:10 +02:00
commit 3fcadfe973
19 changed files with 2047 additions and 0 deletions

17
.env.example Normal file
View 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
View File

@@ -0,0 +1,9 @@
node_modules/
coverage/
data/
.env
.env.*
!.env.example
.queue-state.json
*.log
.DS_Store

7
Dockerfile Normal file
View 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
View 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
View 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.

View 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
View 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
View 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
View 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 &amp; 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('&lt;bad&gt;.mkv');
});
it('escapes " in name', () => {
const result = feed([{ name: '"quoted".mkv', ident: 'id', size: 0, votes: 0 }], baseUrl);
expect(result).toContain('&quot;quoted&quot;.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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,58 @@
function escapeXml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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
View 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 };