Stage all downloads in webshare; leave library placement to *arr.
UI grabs no longer write into Movies/. Same DOWNLOAD_PATH staging as Sonarr/Radarr so they import series and movies into the correct roots.
This commit is contained in:
@@ -20,8 +20,6 @@ WEBSHARE_PASSWORD=
|
|||||||
# DOWNLOAD_PATH=/downloads/webshare
|
# DOWNLOAD_PATH=/downloads/webshare
|
||||||
# MEDIA_ROOT=/data
|
# MEDIA_ROOT=/data
|
||||||
# MAX_CONCURRENT_DOWNLOADS=2
|
# MAX_CONCURRENT_DOWNLOADS=2
|
||||||
|
|
||||||
# UI downloads → Jellyfin Movies library (container path)
|
|
||||||
# MOVIES_PATH=/data/Movies
|
|
||||||
# DOWNLOAD_UID=1000
|
# DOWNLOAD_UID=1000
|
||||||
# DOWNLOAD_GID=1000
|
# DOWNLOAD_GID=1000
|
||||||
|
# Staging only — Sonarr/Radarr move into Shows/Movies on import.
|
||||||
|
|||||||
@@ -65,12 +65,9 @@ Environment variables (required unless noted):
|
|||||||
| `WEBSHARE_PASSWORD` | _(empty)_ | Webshare password (plain; hashed client-side). Optional with username for VIP/session downloads |
|
| `WEBSHARE_PASSWORD` | _(empty)_ | Webshare password (plain; hashed client-side). Optional with username for VIP/session downloads |
|
||||||
| `PORT` | `3001` | HTTP listen port |
|
| `PORT` | `3001` | HTTP listen port |
|
||||||
| `BASE_URL` | `http://localhost:$PORT` | Public base URL Sonarr must reach (use the Compose service hostname when linking containers) |
|
| `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 |
|
| `DOWNLOAD_PATH` | `/downloads/webshare` | Staging dir for UI + *arr (Sonarr/Radarr import from here into Shows/Movies) |
|
||||||
| `MEDIA_ROOT` | `/data` | Media root (used for path reporting) |
|
| `MEDIA_ROOT` | `/data` | Media root (path reporting) |
|
||||||
| `MOVIES_PATH` | `$MEDIA_ROOT/Movies` | UI / direct grabs write into this library tree (Jellyfin folders) |
|
|
||||||
| `DOWNLOAD_UID` / `DOWNLOAD_GID` | `1000` | chown finished files when the container runs as root |
|
| `DOWNLOAD_UID` / `DOWNLOAD_GID` | `1000` | chown finished files when the container runs as root |
|
||||||
| `MOVIES_PATH` | `$MEDIA_ROOT/Movies` | UI / direct grabs write into this library tree (Jellyfin folders) |
|
|
||||||
| `DOWNLOAD_UID` / `DOWNLOAD_GID` | `1000` | chown finished files when container runs as root |
|
|
||||||
| `MAX_CONCURRENT_DOWNLOADS` | `2` | Parallel stream downloads |
|
| `MAX_CONCURRENT_DOWNLOADS` | `2` | Parallel stream downloads |
|
||||||
| `SONARR_URL` | `http://sonarr:8989` | Optional; Sonarr base URL |
|
| `SONARR_URL` | `http://sonarr:8989` | Optional; Sonarr base URL |
|
||||||
| `SONARR_API_KEY` | _(empty)_ | Optional; enables periodic missing-episode search |
|
| `SONARR_API_KEY` | _(empty)_ | Optional; enables periodic missing-episode search |
|
||||||
|
|||||||
47
src/qbt.js
47
src/qbt.js
@@ -7,10 +7,8 @@ const multer = require('multer');
|
|||||||
const { extractWebseed, extractInfoHash } = require('./torrent');
|
const { extractWebseed, extractInfoHash } = require('./torrent');
|
||||||
|
|
||||||
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/data';
|
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/data';
|
||||||
// Staging for *arr (Sonarr/Radarr) import pipeline
|
// Single staging dir for UI + *arr. Sonarr/Radarr import into Shows/Movies.
|
||||||
const SAVE_PATH = process.env.DOWNLOAD_PATH || path.join(MEDIA_ROOT, 'webshare');
|
const SAVE_PATH = process.env.DOWNLOAD_PATH || path.join(MEDIA_ROOT, 'webshare');
|
||||||
// UI / direct movie downloads land in the Jellyfin Movies library
|
|
||||||
const MOVIES_PATH = process.env.MOVIES_PATH || path.join(MEDIA_ROOT, 'Movies');
|
|
||||||
const FILE_UID = parseInt(process.env.DOWNLOAD_UID || '1000', 10);
|
const FILE_UID = parseInt(process.env.DOWNLOAD_UID || '1000', 10);
|
||||||
const FILE_GID = parseInt(process.env.DOWNLOAD_GID || '1000', 10);
|
const FILE_GID = parseInt(process.env.DOWNLOAD_GID || '1000', 10);
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
@@ -22,27 +20,6 @@ function safeFileName(name) {
|
|||||||
.trim() || 'download.bin';
|
.trim() || 'download.bin';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a Jellyfin-friendly movie folder name from a release filename.
|
|
||||||
* e.g. "Predator 1987 1080p CZ DAB.mkv" → "Predator (1987)"
|
|
||||||
*/
|
|
||||||
function movieFolderFromName(name) {
|
|
||||||
let base = safeFileName(name).replace(/\.[^.]+$/i, '');
|
|
||||||
base = base.replace(/[\[\(][^\]\)]*[\]\)]/g, ' ');
|
|
||||||
base = base.replace(
|
|
||||||
/\b(1080p|720p|2160p|4k|uhd|hdr|bluray|blu-ray|webrip|web-dl|hdtv|bdrip|dvdrip|x264|x265|h264|h265|hevc|avc|aac|ac3|dts|truehd|atmos|remux|repack|proper|extended|directors?\.?\s*cut|theatrical|remaster(ed)?|multi|cz|cze|cestina|cesky|dab|dabing|titulky|eng|en|sk|slovak|subs?)\b/gi,
|
|
||||||
' ',
|
|
||||||
);
|
|
||||||
base = base.replace(/[._]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
||||||
const yearMatch = base.match(/\b((?:19|20)\d{2})\b/);
|
|
||||||
if (yearMatch) {
|
|
||||||
const year = yearMatch[1];
|
|
||||||
const title = base.replace(year, ' ').replace(/\s+/g, ' ').trim();
|
|
||||||
return `${title || 'Movie'} (${year})`;
|
|
||||||
}
|
|
||||||
return base || 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
function entryDir(entry) {
|
function entryDir(entry) {
|
||||||
return entry.saveDir || SAVE_PATH;
|
return entry.saveDir || SAVE_PATH;
|
||||||
}
|
}
|
||||||
@@ -67,7 +44,6 @@ function fixOwnership(filePath) {
|
|||||||
&& Number.isFinite(FILE_UID) && Number.isFinite(FILE_GID)
|
&& Number.isFinite(FILE_UID) && Number.isFinite(FILE_GID)
|
||||||
&& fs.existsSync(filePath)) {
|
&& fs.existsSync(filePath)) {
|
||||||
fs.chownSync(filePath, FILE_UID, FILE_GID);
|
fs.chownSync(filePath, FILE_UID, FILE_GID);
|
||||||
// also chown parent movie folder
|
|
||||||
try { fs.chownSync(path.dirname(filePath), FILE_UID, FILE_GID); } catch { /* ignore */ }
|
try { fs.chownSync(path.dirname(filePath), FILE_UID, FILE_GID); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
@@ -102,7 +78,6 @@ function saveState() {
|
|||||||
progress: e.progress, state: e.state, category: e.category,
|
progress: e.progress, state: e.state, category: e.category,
|
||||||
webseedUrl: e.webseedUrl,
|
webseedUrl: e.webseedUrl,
|
||||||
saveDir: e.saveDir,
|
saveDir: e.saveDir,
|
||||||
library: e.library,
|
|
||||||
}));
|
}));
|
||||||
fs.writeFileSync(STATE_FILE, JSON.stringify(data));
|
fs.writeFileSync(STATE_FILE, JSON.stringify(data));
|
||||||
} catch { }
|
} catch { }
|
||||||
@@ -147,8 +122,7 @@ function loadState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanupAfterDownload(entry) {
|
function cleanupAfterDownload(entry) {
|
||||||
// Library-bound downloads (UI → Movies/) stay on disk for Jellyfin — do not delete.
|
// After Sonarr/Radarr hardlink-import, drop the staging copy.
|
||||||
if (entry.library) return;
|
|
||||||
const fpath = entryPath(entry);
|
const fpath = entryPath(entry);
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const check = setInterval(() => {
|
const check = setInterval(() => {
|
||||||
@@ -350,7 +324,6 @@ function streamToFile(url, entry) {
|
|||||||
|
|
||||||
function register(app) {
|
function register(app) {
|
||||||
ensureDir(SAVE_PATH);
|
ensureDir(SAVE_PATH);
|
||||||
ensureDir(MOVIES_PATH);
|
|
||||||
|
|
||||||
// ── auth ──────────────────────────────────────────────────────────────
|
// ── auth ──────────────────────────────────────────────────────────────
|
||||||
app.post('/api/v2/auth/login', (req, res) => {
|
app.post('/api/v2/auth/login', (req, res) => {
|
||||||
@@ -554,35 +527,31 @@ function getQueue() {
|
|||||||
state: e.state,
|
state: e.state,
|
||||||
speed: e.speed || '',
|
speed: e.speed || '',
|
||||||
path: entryPath(e),
|
path: entryPath(e),
|
||||||
library: Boolean(e.library),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI / manual grab: write into Movies/<Title (Year)>/ for Jellyfin.
|
* UI / manual grab: same staging path as *arr (`DOWNLOAD_PATH` / webshare).
|
||||||
* *arr clients still use DOWNLOAD_PATH staging via /api/v2/torrents/add.
|
* Sonarr/Radarr own library placement (Shows vs Movies) after import.
|
||||||
*/
|
*/
|
||||||
function enqueueDirect(ident, name, size) {
|
function enqueueDirect(ident, name, size) {
|
||||||
const hash = identToHash(ident);
|
const hash = identToHash(ident);
|
||||||
if (downloads.has(hash)) return false;
|
if (downloads.has(hash)) return false;
|
||||||
const webseedUrl = `${process.env.BASE_URL || `http://localhost:${process.env.PORT || 3001}`}/stream/${ident}`;
|
const webseedUrl = `${process.env.BASE_URL || `http://localhost:${process.env.PORT || 3001}`}/stream/${ident}`;
|
||||||
const fileName = safeFileName(name || ident);
|
const fileName = safeFileName(name || ident);
|
||||||
const folder = movieFolderFromName(fileName);
|
ensureDir(SAVE_PATH);
|
||||||
const saveDir = path.join(MOVIES_PATH, folder);
|
|
||||||
ensureDir(saveDir);
|
|
||||||
const entry = {
|
const entry = {
|
||||||
hash,
|
hash,
|
||||||
name: fileName,
|
name: fileName,
|
||||||
size: size || 0,
|
size: size || 0,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
state: 'downloading',
|
state: 'downloading',
|
||||||
category: 'movies',
|
category: 'webshare',
|
||||||
speed: '',
|
speed: '',
|
||||||
saveDir,
|
saveDir: SAVE_PATH,
|
||||||
library: true,
|
|
||||||
};
|
};
|
||||||
downloads.set(hash, entry);
|
downloads.set(hash, entry);
|
||||||
console.log(`[qbt] library target: ${path.join(saveDir, fileName)}`);
|
console.log(`[qbt] stage target: ${path.join(SAVE_PATH, fileName)}`);
|
||||||
enqueue(entry, webseedUrl);
|
enqueue(entry, webseedUrl);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user