Compare commits
14 Commits
cd-ripping
...
frontend-n
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c9b9964fe | |||
| 3d5e464392 | |||
| 5b41f728c5 | |||
| 7948dd298c | |||
| ee6603ffad | |||
| 93ed0e6eb2 | |||
| 778fabb2e5 | |||
| 5d8796404c | |||
| a2b9f3625c | |||
| 1e30f00b45 | |||
| 519b7a30ef | |||
| 3ae883326a | |||
| 46f415d778 | |||
| d473f296ff |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -79,5 +79,12 @@ Thumbs.db
|
|||||||
# ----------------------------
|
# ----------------------------
|
||||||
# Scripts
|
# Scripts
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
deploy-ripster.sh
|
/scripts/
|
||||||
build-handbrake-nvdec.sh
|
/deploy-ripster.sh
|
||||||
|
/setup.sh
|
||||||
|
/install.sh
|
||||||
|
/install-dev.sh
|
||||||
|
/build-handbrake-nvdec.sh
|
||||||
|
/gitea_setup.sh
|
||||||
|
/gitea_install.sh
|
||||||
|
/release.sh
|
||||||
|
|||||||
97
README.md
97
README.md
@@ -4,6 +4,13 @@ Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping mit Ma
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Statushinweis: CD-Ripping (experimentell)
|
||||||
|
|
||||||
|
- Die grundlegende CD-Ripping-Funktion im ersten Durchgang funktioniert.
|
||||||
|
- Das Frontend ist dafür noch nicht vollständig angepasst.
|
||||||
|
- Funktionen wie Restart, bestimmte Ansichten und Folge-Workflows können aktuell eingeschränkt sein oder fehlschlagen.
|
||||||
|
- CD-Ripping wird weiterentwickelt und sollte derzeit als experimentell betrachtet werden.
|
||||||
|
|
||||||
## Was Ripster kann
|
## Was Ripster kann
|
||||||
|
|
||||||
- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket)
|
- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket)
|
||||||
@@ -24,21 +31,17 @@ Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping mit Ma
|
|||||||
- Frontend: React, Vite, PrimeReact
|
- Frontend: React, Vite, PrimeReact
|
||||||
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`
|
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
- Ausführliche Dokumentation: https://mboehmlaender.github.io/ripster/
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- **Produktion (empfohlen mit `install.sh`)**:
|
- Debian 11/12 oder Ubuntu 22.04/24.04
|
||||||
- unterstütztes Debian/Ubuntu-System
|
|
||||||
- root-Rechte + Internetzugang
|
- root-Rechte + Internetzugang
|
||||||
- optisches Laufwerk (oder gemountete Quelle)
|
- optisches Laufwerk (oder gemountete Quelle)
|
||||||
- `install.sh` installiert Node.js und die benötigten Tools automatisch
|
|
||||||
- **Entwicklung (`./start.sh`, `npm run dev`)**:
|
|
||||||
- Node.js `>= 20.19.0` (siehe [.nvmrc](.nvmrc))
|
|
||||||
- installierte CLI-Tools im `PATH`:
|
|
||||||
- `makemkvcon`
|
|
||||||
- `HandBrakeCLI`
|
|
||||||
- `mediainfo`
|
|
||||||
|
|
||||||
## Schnellstart (Produktion)
|
## Schnellstart (`install.sh`)
|
||||||
|
|
||||||
Auf Debian 11/12 oder Ubuntu 22.04/24.04 (root erforderlich):
|
Auf Debian 11/12 oder Ubuntu 22.04/24.04 (root erforderlich):
|
||||||
|
|
||||||
@@ -47,65 +50,37 @@ wget -qO install.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main
|
|||||||
sudo bash install.sh
|
sudo bash install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Skript fragt interaktiv, ob HandBrake als Standard-Version (apt) oder mit GPU/NVDEC-Unterstützung (gebündeltes Binary) installiert werden soll.
|
Alternativ direkt per Pipe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` übernimmt u. a.:
|
||||||
|
|
||||||
|
- Node.js 20 (falls nötig)
|
||||||
|
- Basistools inkl. `mediainfo`
|
||||||
|
- CD-Ripping-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`)
|
||||||
|
- MakeMKV
|
||||||
|
- HandBrake CLI (Auswahl Standard/CPU oder GPU/NVDEC-Binary für HW-Encoding)
|
||||||
|
- nginx
|
||||||
|
- Repository-Checkout, npm-Install, Frontend-Build, systemd-Service (`ripster-backend`)
|
||||||
|
|
||||||
Danach ist Ripster unter `http://<Server-IP>` erreichbar.
|
Danach ist Ripster unter `http://<Server-IP>` erreichbar.
|
||||||
|
|
||||||
Wichtige Optionen:
|
Wichtige Optionen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash install.sh --branch dev # anderen Branch installieren
|
sudo bash install.sh --branch dev # Branch wählen (Default im Skript: dev)
|
||||||
|
sudo bash install.sh --dir /opt/ripster # Installationspfad
|
||||||
|
sudo bash install.sh --user ripster # Service-User
|
||||||
|
sudo bash install.sh --port 3001 # Backend-Port
|
||||||
|
sudo bash install.sh --host 192.168.1.10 # Host/IP für nginx/CORS
|
||||||
sudo bash install.sh --no-makemkv # MakeMKV überspringen
|
sudo bash install.sh --no-makemkv # MakeMKV überspringen
|
||||||
|
sudo bash install.sh --no-handbrake # HandBrake überspringen
|
||||||
|
sudo bash install.sh --no-nginx # nginx-Setup überspringen
|
||||||
sudo bash install.sh --reinstall # Update (Daten bleiben erhalten)
|
sudo bash install.sh --reinstall # Update (Daten bleiben erhalten)
|
||||||
```
|
sudo bash install.sh --help # Hilfe anzeigen
|
||||||
|
|
||||||
## Entwicklungsumgebung
|
|
||||||
|
|
||||||
Für lokale Entwicklung mit Hot-Reload:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./start.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
`start.sh` erledigt:
|
|
||||||
|
|
||||||
1. Node-Version prüfen/umschalten (`nvm`/`npx node@20` Fallback)
|
|
||||||
2. Dependencies installieren (Root, Backend, Frontend)
|
|
||||||
3. Dev-Umgebung starten (`backend` + `frontend`)
|
|
||||||
|
|
||||||
Danach:
|
|
||||||
|
|
||||||
- Frontend: `http://localhost:5173`
|
|
||||||
- Backend API: `http://localhost:3001/api`
|
|
||||||
|
|
||||||
Stoppen: laufenden Prozess mit `Ctrl+C` im Terminal beenden.
|
|
||||||
|
|
||||||
## Manueller Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm --prefix backend install
|
|
||||||
npm --prefix frontend install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Einzeln starten:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev:backend
|
|
||||||
npm run dev:frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend Build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build:frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
Backend (ohne Dev-Mode):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|||||||
@@ -3,3 +3,9 @@ DB_PATH=./data/ripster.db
|
|||||||
CORS_ORIGIN=http://localhost:5173
|
CORS_ORIGIN=http://localhost:5173
|
||||||
LOG_DIR=./logs
|
LOG_DIR=./logs
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Standard-Ausgabepfade (Fallback wenn in den Einstellungen kein Pfad gesetzt)
|
||||||
|
# Leer lassen = relativ zum data/-Verzeichnis der DB (data/output/raw etc.)
|
||||||
|
DEFAULT_RAW_DIR=
|
||||||
|
DEFAULT_MOVIE_DIR=
|
||||||
|
DEFAULT_CD_DIR=
|
||||||
|
|||||||
@@ -3,11 +3,25 @@ const path = require('path');
|
|||||||
const rootDir = path.resolve(__dirname, '..');
|
const rootDir = path.resolve(__dirname, '..');
|
||||||
const rawDbPath = process.env.DB_PATH || path.join(rootDir, 'data', 'ripster.db');
|
const rawDbPath = process.env.DB_PATH || path.join(rootDir, 'data', 'ripster.db');
|
||||||
const rawLogDir = process.env.LOG_DIR || path.join(rootDir, 'logs');
|
const rawLogDir = process.env.LOG_DIR || path.join(rootDir, 'logs');
|
||||||
|
const resolvedDbPath = path.isAbsolute(rawDbPath) ? rawDbPath : path.resolve(rootDir, rawDbPath);
|
||||||
|
const dataDir = path.dirname(resolvedDbPath);
|
||||||
|
|
||||||
|
function resolveOutputPath(envValue, ...subParts) {
|
||||||
|
const raw = String(envValue || '').trim();
|
||||||
|
if (raw) {
|
||||||
|
return path.isAbsolute(raw) ? raw : path.resolve(rootDir, raw);
|
||||||
|
}
|
||||||
|
return path.join(dataDir, ...subParts);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
port: process.env.PORT ? Number(process.env.PORT) : 3001,
|
port: process.env.PORT ? Number(process.env.PORT) : 3001,
|
||||||
dbPath: path.isAbsolute(rawDbPath) ? rawDbPath : path.resolve(rootDir, rawDbPath),
|
dbPath: resolvedDbPath,
|
||||||
|
dataDir,
|
||||||
corsOrigin: process.env.CORS_ORIGIN || '*',
|
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||||||
logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir),
|
logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir),
|
||||||
logLevel: process.env.LOG_LEVEL || 'info'
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
|
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
|
||||||
|
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
||||||
|
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd')
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,25 +38,11 @@ const LEGACY_PROFILE_SETTING_MIGRATIONS = [
|
|||||||
profileKeys: ['output_extension_bluray', 'output_extension_dvd']
|
profileKeys: ['output_extension_bluray', 'output_extension_dvd']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
legacyKey: 'filename_template',
|
legacyKey: 'output_template',
|
||||||
profileKeys: ['filename_template_bluray', 'filename_template_dvd']
|
profileKeys: ['output_template_bluray', 'output_template_dvd']
|
||||||
},
|
|
||||||
{
|
|
||||||
legacyKey: 'output_folder_template',
|
|
||||||
profileKeys: ['output_folder_template_bluray', 'output_folder_template_dvd']
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const INSTALL_PATH_SETTING_DEFAULTS = [
|
const INSTALL_PATH_SETTING_DEFAULTS = [
|
||||||
{
|
|
||||||
key: 'raw_dir',
|
|
||||||
pathParts: ['output', 'raw'],
|
|
||||||
legacyDefaults: ['data/output/raw', './data/output/raw']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'movie_dir',
|
|
||||||
pathParts: ['output', 'movies'],
|
|
||||||
legacyDefaults: ['data/output/movies', './data/output/movies']
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'log_dir',
|
key: 'log_dir',
|
||||||
pathParts: ['logs'],
|
pathParts: ['logs'],
|
||||||
@@ -540,6 +526,7 @@ async function openAndPrepareDatabase() {
|
|||||||
await seedFromSchemaFile(dbInstance);
|
await seedFromSchemaFile(dbInstance);
|
||||||
await syncInstallPathSettingDefaults(dbInstance);
|
await syncInstallPathSettingDefaults(dbInstance);
|
||||||
await migrateLegacyProfiledToolSettings(dbInstance);
|
await migrateLegacyProfiledToolSettings(dbInstance);
|
||||||
|
await migrateOutputTemplates(dbInstance);
|
||||||
await removeDeprecatedSettings(dbInstance);
|
await removeDeprecatedSettings(dbInstance);
|
||||||
await migrateSettingsSchemaMetadata(dbInstance);
|
await migrateSettingsSchemaMetadata(dbInstance);
|
||||||
await ensurePipelineStateRow(dbInstance);
|
await ensurePipelineStateRow(dbInstance);
|
||||||
@@ -736,6 +723,49 @@ async function ensurePipelineStateRow(db) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateOutputTemplates(db) {
|
||||||
|
// Combine legacy filename_template_X + output_folder_template_X into output_template_X.
|
||||||
|
// Only sets the new key if it has no user value yet (preserves any existing value).
|
||||||
|
// The last "/" in the combined template separates folder from filename.
|
||||||
|
for (const profile of ['bluray', 'dvd']) {
|
||||||
|
const newKey = `output_template_${profile}`;
|
||||||
|
const filenameKey = `filename_template_${profile}`;
|
||||||
|
const folderKey = `output_folder_template_${profile}`;
|
||||||
|
|
||||||
|
const existing = await db.get(
|
||||||
|
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
|
||||||
|
[newKey]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
continue; // already set, don't overwrite
|
||||||
|
}
|
||||||
|
|
||||||
|
const filenameRow = await db.get(
|
||||||
|
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
|
||||||
|
[filenameKey]
|
||||||
|
);
|
||||||
|
const folderRow = await db.get(
|
||||||
|
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
|
||||||
|
[folderKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filenameVal = filenameRow ? String(filenameRow.value || '').trim() : '';
|
||||||
|
const folderVal = folderRow ? String(folderRow.value || '').trim() : '';
|
||||||
|
|
||||||
|
if (!filenameVal) {
|
||||||
|
continue; // nothing to migrate
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = folderVal ? `${folderVal}/${filenameVal}` : `${filenameVal}/${filenameVal}`;
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO settings_values (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[newKey, combined]
|
||||||
|
);
|
||||||
|
logger.info('migrate:output-template-combined', { profile, combined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeDeprecatedSettings(db) {
|
async function removeDeprecatedSettings(db) {
|
||||||
const deprecatedKeys = [
|
const deprecatedKeys = [
|
||||||
'pushover_notify_disc_detected',
|
'pushover_notify_disc_detected',
|
||||||
@@ -748,14 +778,31 @@ async function removeDeprecatedSettings(db) {
|
|||||||
'output_extension',
|
'output_extension',
|
||||||
'filename_template',
|
'filename_template',
|
||||||
'output_folder_template',
|
'output_folder_template',
|
||||||
'makemkv_backup_mode'
|
'makemkv_backup_mode',
|
||||||
|
'raw_dir',
|
||||||
|
'movie_dir',
|
||||||
|
'raw_dir_other',
|
||||||
|
'raw_dir_other_owner',
|
||||||
|
'movie_dir_other',
|
||||||
|
'movie_dir_other_owner',
|
||||||
|
'filename_template_bluray',
|
||||||
|
'filename_template_dvd',
|
||||||
|
'output_folder_template_bluray',
|
||||||
|
'output_folder_template_dvd'
|
||||||
];
|
];
|
||||||
for (const key of deprecatedKeys) {
|
for (const key of deprecatedKeys) {
|
||||||
const result = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
||||||
if (result?.changes > 0) {
|
const valuesResult = await db.run('DELETE FROM settings_values WHERE key = ?', [key]);
|
||||||
|
if (schemaResult?.changes > 0 || valuesResult?.changes > 0) {
|
||||||
logger.info('migrate:remove-deprecated-setting', { key });
|
logger.info('migrate:remove-deprecated-setting', { key });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset raw_dir_cd if it still holds the old hardcoded absolute path from a prior install
|
||||||
|
await db.run(
|
||||||
|
`UPDATE settings_values SET value = NULL, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = 'raw_dir_cd' AND value = '/opt/ripster/backend/data/output/cd'`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aktualisiert settings_schema-Metadaten (required, description, validation_json)
|
// Aktualisiert settings_schema-Metadaten (required, description, validation_json)
|
||||||
@@ -775,6 +822,13 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Settings, die von einer Kategorie in eine andere verschoben werden
|
||||||
|
const SETTINGS_CATEGORY_MOVES = [
|
||||||
|
{ key: 'cd_output_template', category: 'Pfade' },
|
||||||
|
{ key: 'output_template_bluray', category: 'Pfade' },
|
||||||
|
{ key: 'output_template_dvd', category: 'Pfade' }
|
||||||
|
];
|
||||||
|
|
||||||
async function migrateSettingsSchemaMetadata(db) {
|
async function migrateSettingsSchemaMetadata(db) {
|
||||||
for (const update of SETTINGS_SCHEMA_METADATA_UPDATES) {
|
for (const update of SETTINGS_SCHEMA_METADATA_UPDATES) {
|
||||||
const result = await db.run(
|
const result = await db.run(
|
||||||
@@ -791,6 +845,16 @@ async function migrateSettingsSchemaMetadata(db) {
|
|||||||
logger.info('migrate:settings-schema-metadata', { key: update.key });
|
logger.info('migrate:settings-schema-metadata', { key: update.key });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const move of SETTINGS_CATEGORY_MOVES) {
|
||||||
|
const result = await db.run(
|
||||||
|
`UPDATE settings_schema SET category = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = ? AND category != ?`,
|
||||||
|
[move.category, move.key, move.category]
|
||||||
|
);
|
||||||
|
if (result?.changes > 0) {
|
||||||
|
logger.info('migrate:settings-schema-category-moved', { key: move.key, category: move.category });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDb() {
|
async function getDb() {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const diskDetectionService = require('./services/diskDetectionService');
|
|||||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||||
const logger = require('./services/logger').child('BOOT');
|
const logger = require('./services/logger').child('BOOT');
|
||||||
const { errorToMeta } = require('./utils/errorMeta');
|
const { errorToMeta } = require('./utils/errorMeta');
|
||||||
|
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
logger.info('backend:start:init');
|
logger.info('backend:start:init');
|
||||||
@@ -40,6 +41,7 @@ async function start() {
|
|||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
app.use('/api/crons', cronRoutes);
|
app.use('/api/crons', cronRoutes);
|
||||||
app.use('/api/runtime', runtimeRoutes);
|
app.use('/api/runtime', runtimeRoutes);
|
||||||
|
app.use('/api/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true }));
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
@@ -72,6 +74,8 @@ async function start() {
|
|||||||
|
|
||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
logger.info('backend:listening', { port });
|
logger.info('backend:listening', { port });
|
||||||
|
// Bestehende Job-Bilder im Hintergrund migrieren (blockiert nicht den Start)
|
||||||
|
migrateExistingThumbnails().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
const shutdown = () => {
|
const shutdown = () => {
|
||||||
|
|||||||
@@ -112,19 +112,38 @@ router.post(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id/delete-preview',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const includeRelated = ['1', 'true', 'yes'].includes(String(req.query.includeRelated || '1').toLowerCase());
|
||||||
|
|
||||||
|
logger.info('get:delete-preview', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
id,
|
||||||
|
includeRelated
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = await historyService.getJobDeletePreview(id, { includeRelated });
|
||||||
|
res.json({ preview });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/delete',
|
'/:id/delete',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const target = String(req.body?.target || 'none');
|
const target = String(req.body?.target || 'none');
|
||||||
|
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||||
|
|
||||||
logger.warn('post:delete-job', {
|
logger.warn('post:delete-job', {
|
||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
id,
|
id,
|
||||||
target
|
target,
|
||||||
|
includeRelated
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await historyService.deleteJob(id, target);
|
const result = await historyService.deleteJob(id, target, { includeRelated });
|
||||||
const uiReset = await pipelineService.resetFrontendState('history_delete');
|
const uiReset = await pipelineService.resetFrontendState('history_delete');
|
||||||
res.json({ ...result, uiReset });
|
res.json({ ...result, uiReset });
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -99,8 +99,28 @@ router.post(
|
|||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
const ripConfig = req.body || {};
|
const ripConfig = req.body || {};
|
||||||
logger.info('post:cd:start', { reqId: req.reqId, jobId, format: ripConfig.format });
|
logger.info('post:cd:start', {
|
||||||
const result = await pipelineService.startCdRip(jobId, ripConfig);
|
reqId: req.reqId,
|
||||||
|
jobId,
|
||||||
|
format: ripConfig.format,
|
||||||
|
selectedPreEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPreEncodeScriptIds)
|
||||||
|
? ripConfig.selectedPreEncodeScriptIds.length
|
||||||
|
: 0,
|
||||||
|
selectedPostEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPostEncodeScriptIds)
|
||||||
|
? ripConfig.selectedPostEncodeScriptIds.length
|
||||||
|
: 0,
|
||||||
|
selectedPreEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPreEncodeChainIds)
|
||||||
|
? ripConfig.selectedPreEncodeChainIds.length
|
||||||
|
: 0,
|
||||||
|
selectedPostEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPostEncodeChainIds)
|
||||||
|
? ripConfig.selectedPostEncodeChainIds.length
|
||||||
|
: 0
|
||||||
|
});
|
||||||
|
const result = await pipelineService.enqueueOrStartCdAction(
|
||||||
|
jobId,
|
||||||
|
ripConfig,
|
||||||
|
() => pipelineService.startCdRip(jobId, ripConfig)
|
||||||
|
);
|
||||||
res.json({ result });
|
res.json({ result });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ router.get(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/effective-paths',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.debug('get:settings:effective-paths', { reqId: req.reqId });
|
||||||
|
const paths = await settingsService.getEffectivePaths();
|
||||||
|
res.json(paths);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/handbrake-presets',
|
'/handbrake-presets',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -431,6 +431,16 @@ async function ripAndEncode(options) {
|
|||||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||||
const ripArgs = ['-d', devicePath, String(track.position), wavFile];
|
const ripArgs = ['-d', devicePath, String(track.position), wavFile];
|
||||||
|
|
||||||
|
onProgress && onProgress({
|
||||||
|
phase: 'rip',
|
||||||
|
trackEvent: 'start',
|
||||||
|
trackIndex: i + 1,
|
||||||
|
trackTotal: tracksToRip.length,
|
||||||
|
trackPosition: track.position,
|
||||||
|
trackPercent: 0,
|
||||||
|
percent: (i / tracksToRip.length) * 50
|
||||||
|
});
|
||||||
|
|
||||||
log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
|
log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
|
||||||
log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`);
|
log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`);
|
||||||
|
|
||||||
@@ -445,9 +455,11 @@ async function ripAndEncode(options) {
|
|||||||
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
|
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
|
||||||
onProgress && onProgress({
|
onProgress && onProgress({
|
||||||
phase: 'rip',
|
phase: 'rip',
|
||||||
|
trackEvent: 'progress',
|
||||||
trackIndex: i + 1,
|
trackIndex: i + 1,
|
||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
|
trackPercent: parsed.percent,
|
||||||
percent: overallPercent
|
percent: overallPercent
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -467,9 +479,11 @@ async function ripAndEncode(options) {
|
|||||||
|
|
||||||
onProgress && onProgress({
|
onProgress && onProgress({
|
||||||
phase: 'rip',
|
phase: 'rip',
|
||||||
|
trackEvent: 'complete',
|
||||||
trackIndex: i + 1,
|
trackIndex: i + 1,
|
||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
|
trackPercent: 100,
|
||||||
percent: ((i + 1) / tracksToRip.length) * 50
|
percent: ((i + 1) / tracksToRip.length) * 50
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -484,14 +498,25 @@ async function ripAndEncode(options) {
|
|||||||
const track = tracksToRip[i];
|
const track = tracksToRip[i];
|
||||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||||
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
|
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
|
||||||
|
onProgress && onProgress({
|
||||||
|
phase: 'encode',
|
||||||
|
trackEvent: 'start',
|
||||||
|
trackIndex: i + 1,
|
||||||
|
trackTotal: tracksToRip.length,
|
||||||
|
trackPosition: track.position,
|
||||||
|
trackPercent: 0,
|
||||||
|
percent: 50 + ((i / tracksToRip.length) * 50)
|
||||||
|
});
|
||||||
ensureDir(path.dirname(outFile));
|
ensureDir(path.dirname(outFile));
|
||||||
log('info', `Promptkette [Move ${i + 1}/${tracksToRip.length}]: mv ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
log('info', `Promptkette [Move ${i + 1}/${tracksToRip.length}]: mv ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
||||||
fs.renameSync(wavFile, outFile);
|
fs.renameSync(wavFile, outFile);
|
||||||
onProgress && onProgress({
|
onProgress && onProgress({
|
||||||
phase: 'encode',
|
phase: 'encode',
|
||||||
|
trackEvent: 'complete',
|
||||||
trackIndex: i + 1,
|
trackIndex: i + 1,
|
||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
|
trackPercent: 100,
|
||||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
||||||
});
|
});
|
||||||
log('info', `WAV für Track ${track.position} gespeichert.`);
|
log('info', `WAV für Track ${track.position} gespeichert.`);
|
||||||
@@ -511,6 +536,16 @@ async function ripAndEncode(options) {
|
|||||||
const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate);
|
const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate);
|
||||||
ensureDir(path.dirname(outFile));
|
ensureDir(path.dirname(outFile));
|
||||||
|
|
||||||
|
onProgress && onProgress({
|
||||||
|
phase: 'encode',
|
||||||
|
trackEvent: 'start',
|
||||||
|
trackIndex: i + 1,
|
||||||
|
trackTotal: tracksToRip.length,
|
||||||
|
trackPosition: track.position,
|
||||||
|
trackPercent: 0,
|
||||||
|
percent: 50 + ((i / tracksToRip.length) * 50)
|
||||||
|
});
|
||||||
|
|
||||||
log('info', `Encodiere Track ${track.position} → ${outFilename} …`);
|
log('info', `Encodiere Track ${track.position} → ${outFilename} …`);
|
||||||
|
|
||||||
const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile);
|
const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile);
|
||||||
@@ -536,18 +571,13 @@ async function ripAndEncode(options) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up WAV after encode
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(wavFile);
|
|
||||||
} catch (_error) {
|
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress && onProgress({
|
onProgress && onProgress({
|
||||||
phase: 'encode',
|
phase: 'encode',
|
||||||
|
trackEvent: 'complete',
|
||||||
trackIndex: i + 1,
|
trackIndex: i + 1,
|
||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
|
trackPercent: 100,
|
||||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,38 @@ const { parseToc } = require('./cdRipService');
|
|||||||
const { errorToMeta } = require('../utils/errorMeta');
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
const DEFAULT_POLL_INTERVAL_MS = 4000;
|
||||||
|
const MIN_POLL_INTERVAL_MS = 1000;
|
||||||
|
const MAX_POLL_INTERVAL_MS = 60000;
|
||||||
|
|
||||||
|
function toBoolean(value, fallback = false) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPollIntervalMs(rawValue) {
|
||||||
|
const parsed = Number(rawValue);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return DEFAULT_POLL_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
const clamped = Math.max(MIN_POLL_INTERVAL_MS, Math.min(MAX_POLL_INTERVAL_MS, Math.trunc(parsed)));
|
||||||
|
return clamped || DEFAULT_POLL_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
function flattenDevices(nodes, acc = []) {
|
function flattenDevices(nodes, acc = []) {
|
||||||
for (const node of nodes || []) {
|
for (const node of nodes || []) {
|
||||||
@@ -56,9 +88,6 @@ function normalizeMediaProfile(rawValue) {
|
|||||||
if (value === 'cd' || value === 'audio_cd') {
|
if (value === 'cd' || value === 'audio_cd') {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
if (value === 'disc' || value === 'other' || value === 'sonstiges') {
|
|
||||||
return 'other';
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,18 +217,24 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.timer = setTimeout(async () => {
|
this.timer = setTimeout(async () => {
|
||||||
let nextDelay = 4000;
|
let nextDelay = DEFAULT_POLL_INTERVAL_MS;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const map = await settingsService.getSettingsMap();
|
const map = await settingsService.getSettingsMap();
|
||||||
nextDelay = Number(map.disc_poll_interval_ms || 4000);
|
nextDelay = clampPollIntervalMs(map.disc_poll_interval_ms);
|
||||||
|
const autoDetectionEnabled = toBoolean(map.disc_auto_detection_enabled, true);
|
||||||
logger.debug('poll:tick', {
|
logger.debug('poll:tick', {
|
||||||
driveMode: map.drive_mode,
|
driveMode: map.drive_mode,
|
||||||
driveDevice: map.drive_device,
|
driveDevice: map.drive_device,
|
||||||
nextDelay
|
nextDelay,
|
||||||
|
autoDetectionEnabled
|
||||||
});
|
});
|
||||||
|
if (autoDetectionEnabled) {
|
||||||
const detected = await this.detectDisc(map);
|
const detected = await this.detectDisc(map);
|
||||||
this.applyDetectionResult(detected, { forceInsertEvent: false });
|
this.applyDetectionResult(detected, { forceInsertEvent: false });
|
||||||
|
} else {
|
||||||
|
logger.debug('poll:skip:auto-detection-disabled', { nextDelay });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('poll:error', { error: errorToMeta(error) });
|
logger.error('poll:error', { error: errorToMeta(error) });
|
||||||
this.emit('error', error);
|
this.emit('error', error);
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ const RELEVANT_SETTINGS_KEYS = new Set([
|
|||||||
'hardware_monitoring_enabled',
|
'hardware_monitoring_enabled',
|
||||||
'hardware_monitoring_interval_ms',
|
'hardware_monitoring_interval_ms',
|
||||||
'raw_dir',
|
'raw_dir',
|
||||||
|
'raw_dir_bluray',
|
||||||
|
'raw_dir_dvd',
|
||||||
|
'raw_dir_cd',
|
||||||
'movie_dir',
|
'movie_dir',
|
||||||
|
'movie_dir_bluray',
|
||||||
|
'movie_dir_dvd',
|
||||||
'log_dir'
|
'log_dir'
|
||||||
]);
|
]);
|
||||||
const MONITORED_PATH_DEFINITIONS = [
|
|
||||||
{ key: 'raw_dir', label: 'RAW-Verzeichnis' },
|
|
||||||
{ key: 'movie_dir', label: 'Movie-Verzeichnis' },
|
|
||||||
{ key: 'log_dir', label: 'Log-Verzeichnis' }
|
|
||||||
];
|
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -53,6 +53,10 @@ function toBoolean(value) {
|
|||||||
return Boolean(normalized);
|
return Boolean(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePathSetting(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
function clampIntervalMs(rawValue) {
|
function clampIntervalMs(rawValue) {
|
||||||
const parsed = Number(rawValue);
|
const parsed = Number(rawValue);
|
||||||
if (!Number.isFinite(parsed)) {
|
if (!Number.isFinite(parsed)) {
|
||||||
@@ -392,10 +396,43 @@ class HardwareMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildMonitoredPaths(settingsMap = {}) {
|
buildMonitoredPaths(settingsMap = {}) {
|
||||||
return MONITORED_PATH_DEFINITIONS.map((definition) => ({
|
const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
||||||
...definition,
|
const bluray = settingsService.resolveEffectiveToolSettings(sourceMap, 'bluray');
|
||||||
path: String(settingsMap?.[definition.key] || '').trim()
|
const dvd = settingsService.resolveEffectiveToolSettings(sourceMap, 'dvd');
|
||||||
}));
|
const cd = settingsService.resolveEffectiveToolSettings(sourceMap, 'cd');
|
||||||
|
const blurayRawPath = normalizePathSetting(bluray?.raw_dir);
|
||||||
|
const dvdRawPath = normalizePathSetting(dvd?.raw_dir);
|
||||||
|
const cdRawPath = normalizePathSetting(cd?.raw_dir);
|
||||||
|
const blurayMoviePath = normalizePathSetting(bluray?.movie_dir);
|
||||||
|
const dvdMoviePath = normalizePathSetting(dvd?.movie_dir);
|
||||||
|
const monitoredPaths = [];
|
||||||
|
|
||||||
|
const addPath = (key, label, monitoredPath) => {
|
||||||
|
monitoredPaths.push({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
path: normalizePathSetting(monitoredPath)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (blurayRawPath && dvdRawPath && blurayRawPath !== dvdRawPath) {
|
||||||
|
addPath('raw_dir_bluray', 'RAW-Verzeichnis (Blu-ray)', blurayRawPath);
|
||||||
|
addPath('raw_dir_dvd', 'RAW-Verzeichnis (DVD)', dvdRawPath);
|
||||||
|
} else {
|
||||||
|
addPath('raw_dir', 'RAW-Verzeichnis', blurayRawPath || dvdRawPath || sourceMap.raw_dir);
|
||||||
|
}
|
||||||
|
addPath('raw_dir_cd', 'CD-Verzeichnis', cdRawPath || sourceMap.raw_dir_cd);
|
||||||
|
|
||||||
|
if (blurayMoviePath && dvdMoviePath && blurayMoviePath !== dvdMoviePath) {
|
||||||
|
addPath('movie_dir_bluray', 'Movie-Verzeichnis (Blu-ray)', blurayMoviePath);
|
||||||
|
addPath('movie_dir_dvd', 'Movie-Verzeichnis (DVD)', dvdMoviePath);
|
||||||
|
} else {
|
||||||
|
addPath('movie_dir', 'Movie-Verzeichnis', blurayMoviePath || dvdMoviePath || sourceMap.movie_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
addPath('log_dir', 'Log-Verzeichnis', sourceMap.log_dir);
|
||||||
|
|
||||||
|
return monitoredPaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
pathsSignature(paths = []) {
|
pathsSignature(paths = []) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,38 @@ const path = require('path');
|
|||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const logger = require('./logger').child('SCRIPTS');
|
const logger = require('./logger').child('SCRIPTS');
|
||||||
|
const settingsService = require('./settingsService');
|
||||||
const runtimeActivityService = require('./runtimeActivityService');
|
const runtimeActivityService = require('./runtimeActivityService');
|
||||||
const { errorToMeta } = require('../utils/errorMeta');
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
const SCRIPT_NAME_MAX_LENGTH = 120;
|
const SCRIPT_NAME_MAX_LENGTH = 120;
|
||||||
const SCRIPT_BODY_MAX_LENGTH = 200000;
|
const SCRIPT_BODY_MAX_LENGTH = 200000;
|
||||||
const SCRIPT_TEST_TIMEOUT_MS = 120000;
|
const SCRIPT_TEST_TIMEOUT_SETTING_KEY = 'script_test_timeout_ms';
|
||||||
|
const DEFAULT_SCRIPT_TEST_TIMEOUT_MS = 0;
|
||||||
|
const SCRIPT_TEST_TIMEOUT_MS = (() => {
|
||||||
|
const parsed = Number(process.env.RIPSTER_SCRIPT_TEST_TIMEOUT_MS);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return Math.max(0, Math.trunc(parsed));
|
||||||
|
}
|
||||||
|
return DEFAULT_SCRIPT_TEST_TIMEOUT_MS;
|
||||||
|
})();
|
||||||
const SCRIPT_OUTPUT_MAX_CHARS = 150000;
|
const SCRIPT_OUTPUT_MAX_CHARS = 150000;
|
||||||
|
|
||||||
|
function normalizeScriptTestTimeoutMs(rawValue, fallbackMs = SCRIPT_TEST_TIMEOUT_MS) {
|
||||||
|
const parsed = Number(rawValue);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return Math.max(0, Math.trunc(parsed));
|
||||||
|
}
|
||||||
|
if (fallbackMs === null || fallbackMs === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsedFallback = Number(fallbackMs);
|
||||||
|
if (Number.isFinite(parsedFallback)) {
|
||||||
|
return Math.max(0, Math.trunc(parsedFallback));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeScriptId(rawValue) {
|
function normalizeScriptId(rawValue) {
|
||||||
const value = Number(rawValue);
|
const value = Number(rawValue);
|
||||||
if (!Number.isFinite(value) || value <= 0) {
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
@@ -184,6 +208,7 @@ function killChildProcessTree(child, signal = 'SIGTERM') {
|
|||||||
|
|
||||||
function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd(), onChild = null }) {
|
function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd(), onChild = null }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const effectiveTimeoutMs = normalizeScriptTestTimeoutMs(timeoutMs, SCRIPT_TEST_TIMEOUT_MS);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let ended = false;
|
let ended = false;
|
||||||
const child = spawn(cmd, args, {
|
const child = spawn(cmd, args, {
|
||||||
@@ -206,7 +231,9 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
|
|||||||
let stderrTruncated = false;
|
let stderrTruncated = false;
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
let timeout = null;
|
||||||
|
if (effectiveTimeoutMs > 0) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
killChildProcessTree(child, 'SIGTERM');
|
killChildProcessTree(child, 'SIGTERM');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -214,7 +241,8 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
|
|||||||
killChildProcessTree(child, 'SIGKILL');
|
killChildProcessTree(child, 'SIGKILL');
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}, Math.max(1000, Number(timeoutMs || SCRIPT_TEST_TIMEOUT_MS)));
|
}, effectiveTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
const onData = (streamName, chunk) => {
|
const onData = (streamName, chunk) => {
|
||||||
if (streamName === 'stdout') {
|
if (streamName === 'stdout') {
|
||||||
@@ -233,13 +261,17 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
|
|||||||
|
|
||||||
child.on('error', (error) => {
|
child.on('error', (error) => {
|
||||||
ended = true;
|
ended = true;
|
||||||
|
if (timeout) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', (code, signal) => {
|
child.on('close', (code, signal) => {
|
||||||
ended = true;
|
ended = true;
|
||||||
|
if (timeout) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
const endedAt = Date.now();
|
const endedAt = Date.now();
|
||||||
resolve({
|
resolve({
|
||||||
code: Number.isFinite(Number(code)) ? Number(code) : null,
|
code: Number.isFinite(Number(code)) ? Number(code) : null,
|
||||||
@@ -255,6 +287,23 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveScriptTestTimeoutMs(options = {}) {
|
||||||
|
const timeoutFromOptions = normalizeScriptTestTimeoutMs(options?.timeoutMs, null);
|
||||||
|
if (timeoutFromOptions !== null) {
|
||||||
|
return timeoutFromOptions;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const settingsMap = await settingsService.getSettingsMap();
|
||||||
|
return normalizeScriptTestTimeoutMs(
|
||||||
|
settingsMap?.[SCRIPT_TEST_TIMEOUT_SETTING_KEY],
|
||||||
|
SCRIPT_TEST_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('script:test-timeout:settings-read-failed', { error: errorToMeta(error) });
|
||||||
|
return SCRIPT_TEST_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ScriptService {
|
class ScriptService {
|
||||||
async listScripts() {
|
async listScripts() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
@@ -506,8 +555,7 @@ class ScriptService {
|
|||||||
|
|
||||||
async testScript(scriptId, options = {}) {
|
async testScript(scriptId, options = {}) {
|
||||||
const script = await this.getScriptById(scriptId);
|
const script = await this.getScriptById(scriptId);
|
||||||
const timeoutMs = Number(options?.timeoutMs);
|
const effectiveTimeoutMs = await resolveScriptTestTimeoutMs(options);
|
||||||
const effectiveTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SCRIPT_TEST_TIMEOUT_MS;
|
|
||||||
const prepared = await this.createExecutableScriptFile(script, {
|
const prepared = await this.createExecutableScriptFile(script, {
|
||||||
source: 'settings_test',
|
source: 'settings_test',
|
||||||
mode: 'test'
|
mode: 'test'
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const {
|
|||||||
const { splitArgs } = require('../utils/commandLine');
|
const { splitArgs } = require('../utils/commandLine');
|
||||||
const { setLogRootDir } = require('./logPathService');
|
const { setLogRootDir } = require('./logPathService');
|
||||||
|
|
||||||
|
const { defaultRawDir: DEFAULT_RAW_DIR, defaultMovieDir: DEFAULT_MOVIE_DIR, defaultCdDir: DEFAULT_CD_DIR } = require('../config');
|
||||||
|
|
||||||
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
|
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
|
||||||
const HANDBRAKE_PRESET_LIST_TIMEOUT_MS = 30000;
|
const HANDBRAKE_PRESET_LIST_TIMEOUT_MS = 30000;
|
||||||
const SETTINGS_CACHE_TTL_MS = 15000;
|
const SETTINGS_CACHE_TTL_MS = 15000;
|
||||||
@@ -36,29 +38,25 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s
|
|||||||
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||||
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||||
const LOG_DIR_SETTING_KEY = 'log_dir';
|
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'other', 'cd'];
|
const MEDIA_PROFILES = ['bluray', 'dvd', 'cd'];
|
||||||
const PROFILED_SETTINGS = {
|
const PROFILED_SETTINGS = {
|
||||||
raw_dir: {
|
raw_dir: {
|
||||||
bluray: 'raw_dir_bluray',
|
bluray: 'raw_dir_bluray',
|
||||||
dvd: 'raw_dir_dvd',
|
dvd: 'raw_dir_dvd',
|
||||||
other: 'raw_dir_other',
|
|
||||||
cd: 'raw_dir_cd'
|
cd: 'raw_dir_cd'
|
||||||
},
|
},
|
||||||
raw_dir_owner: {
|
raw_dir_owner: {
|
||||||
bluray: 'raw_dir_bluray_owner',
|
bluray: 'raw_dir_bluray_owner',
|
||||||
dvd: 'raw_dir_dvd_owner',
|
dvd: 'raw_dir_dvd_owner',
|
||||||
other: 'raw_dir_other_owner',
|
|
||||||
cd: 'raw_dir_cd_owner'
|
cd: 'raw_dir_cd_owner'
|
||||||
},
|
},
|
||||||
movie_dir: {
|
movie_dir: {
|
||||||
bluray: 'movie_dir_bluray',
|
bluray: 'movie_dir_bluray',
|
||||||
dvd: 'movie_dir_dvd',
|
dvd: 'movie_dir_dvd'
|
||||||
other: 'movie_dir_other'
|
|
||||||
},
|
},
|
||||||
movie_dir_owner: {
|
movie_dir_owner: {
|
||||||
bluray: 'movie_dir_bluray_owner',
|
bluray: 'movie_dir_bluray_owner',
|
||||||
dvd: 'movie_dir_dvd_owner',
|
dvd: 'movie_dir_dvd_owner'
|
||||||
other: 'movie_dir_other_owner'
|
|
||||||
},
|
},
|
||||||
mediainfo_extra_args: {
|
mediainfo_extra_args: {
|
||||||
bluray: 'mediainfo_extra_args_bluray',
|
bluray: 'mediainfo_extra_args_bluray',
|
||||||
@@ -88,13 +86,9 @@ const PROFILED_SETTINGS = {
|
|||||||
bluray: 'output_extension_bluray',
|
bluray: 'output_extension_bluray',
|
||||||
dvd: 'output_extension_dvd'
|
dvd: 'output_extension_dvd'
|
||||||
},
|
},
|
||||||
filename_template: {
|
output_template: {
|
||||||
bluray: 'filename_template_bluray',
|
bluray: 'output_template_bluray',
|
||||||
dvd: 'filename_template_dvd'
|
dvd: 'output_template_dvd'
|
||||||
},
|
|
||||||
output_folder_template: {
|
|
||||||
bluray: 'output_folder_template_bluray',
|
|
||||||
dvd: 'output_folder_template_dvd'
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
||||||
@@ -373,8 +367,8 @@ function normalizeMediaProfileValue(value) {
|
|||||||
) {
|
) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
|
if (raw === 'cd' || raw === 'audio_cd') {
|
||||||
return 'other';
|
return 'cd';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -387,9 +381,6 @@ function resolveProfileFallbackOrder(profile) {
|
|||||||
if (normalized === 'dvd') {
|
if (normalized === 'dvd') {
|
||||||
return ['dvd', 'bluray'];
|
return ['dvd', 'bluray'];
|
||||||
}
|
}
|
||||||
if (normalized === 'other') {
|
|
||||||
return ['dvd', 'bluray'];
|
|
||||||
}
|
|
||||||
return ['dvd', 'bluray'];
|
return ['dvd', 'bluray'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,6 +685,14 @@ class SettingsService {
|
|||||||
if (hasUsableProfileSpecificValue(selectedProfileValue)) {
|
if (hasUsableProfileSpecificValue(selectedProfileValue)) {
|
||||||
resolvedValue = selectedProfileValue;
|
resolvedValue = selectedProfileValue;
|
||||||
}
|
}
|
||||||
|
// Fallback to hardcoded install defaults when no setting value is configured
|
||||||
|
if (!hasUsableProfileSpecificValue(resolvedValue)) {
|
||||||
|
if (legacyKey === 'raw_dir') {
|
||||||
|
resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_RAW_DIR;
|
||||||
|
} else if (legacyKey === 'movie_dir') {
|
||||||
|
resolvedValue = DEFAULT_MOVIE_DIR;
|
||||||
|
}
|
||||||
|
}
|
||||||
effective[legacyKey] = resolvedValue;
|
effective[legacyKey] = resolvedValue;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -718,6 +717,23 @@ class SettingsService {
|
|||||||
return this.resolveEffectiveToolSettings(map, mediaProfile);
|
return this.resolveEffectiveToolSettings(map, mediaProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getEffectivePaths() {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
const bluray = this.resolveEffectiveToolSettings(map, 'bluray');
|
||||||
|
const dvd = this.resolveEffectiveToolSettings(map, 'dvd');
|
||||||
|
const cd = this.resolveEffectiveToolSettings(map, 'cd');
|
||||||
|
return {
|
||||||
|
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
|
||||||
|
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
||||||
|
cd: { raw: cd.raw_dir },
|
||||||
|
defaults: {
|
||||||
|
raw: DEFAULT_RAW_DIR,
|
||||||
|
movies: DEFAULT_MOVIE_DIR,
|
||||||
|
cd: DEFAULT_CD_DIR
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async fetchFlatSettingsFromDb() {
|
async fetchFlatSettingsFromDb() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.all(
|
const rows = await db.all(
|
||||||
@@ -1308,27 +1324,10 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolveSourceArg(map, deviceInfo = null) {
|
resolveSourceArg(map, deviceInfo = null) {
|
||||||
const mode = map.drive_mode;
|
// Single-drive setup: always use MakeMKV's first logical disc device.
|
||||||
if (mode === 'explicit') {
|
void map;
|
||||||
const device = map.drive_device;
|
void deviceInfo;
|
||||||
if (!device) {
|
return 'disc:0';
|
||||||
throw new Error('drive_device ist leer, obwohl drive_mode=explicit gesetzt ist.');
|
|
||||||
}
|
|
||||||
return `dev:${device}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const devicePath = String(deviceInfo?.path || '').trim();
|
|
||||||
if (devicePath) {
|
|
||||||
// Prefer stable Linux device path over MakeMKV disc index mapping.
|
|
||||||
// MakeMKV drive indices (disc:N) do not reliably match /dev/srN numbering.
|
|
||||||
return `dev:${devicePath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deviceInfo && deviceInfo.index !== undefined && deviceInfo.index !== null) {
|
|
||||||
return `disc:${deviceInfo.index}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `disc:${map.makemkv_source_index ?? 0}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadHandBrakePresetOptionsFromCli(map = {}) {
|
async loadHandBrakePresetOptionsFromCli(map = {}) {
|
||||||
@@ -1475,4 +1474,8 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new SettingsService();
|
const settingsServiceInstance = new SettingsService();
|
||||||
|
settingsServiceInstance.DEFAULT_RAW_DIR = DEFAULT_RAW_DIR;
|
||||||
|
settingsServiceInstance.DEFAULT_MOVIE_DIR = DEFAULT_MOVIE_DIR;
|
||||||
|
settingsServiceInstance.DEFAULT_CD_DIR = DEFAULT_CD_DIR;
|
||||||
|
module.exports = settingsServiceInstance;
|
||||||
|
|||||||
239
backend/src/services/thumbnailService.js
Normal file
239
backend/src/services/thumbnailService.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { dataDir } = require('../config');
|
||||||
|
const { getDb } = require('../db/database');
|
||||||
|
const logger = require('./logger').child('THUMBNAIL');
|
||||||
|
|
||||||
|
const THUMBNAILS_DIR = path.join(dataDir, 'thumbnails');
|
||||||
|
const CACHE_DIR = path.join(THUMBNAILS_DIR, 'cache');
|
||||||
|
const MAX_REDIRECTS = 5;
|
||||||
|
|
||||||
|
function ensureDirs() {
|
||||||
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheFilePath(jobId) {
|
||||||
|
return path.join(CACHE_DIR, `job-${jobId}.jpg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistentFilePath(jobId) {
|
||||||
|
return path.join(THUMBNAILS_DIR, `job-${jobId}.jpg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localUrl(jobId) {
|
||||||
|
return `/api/thumbnails/job-${jobId}.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalUrl(url) {
|
||||||
|
return typeof url === 'string' && url.startsWith('/api/thumbnails/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (redirectsLeft <= 0) {
|
||||||
|
return reject(new Error('Zu viele Weiterleitungen beim Bild-Download'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = url.startsWith('https') ? https : http;
|
||||||
|
const file = fs.createWriteStream(destPath);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
try { file.destroy(); } catch (_) {}
|
||||||
|
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
proto.get(url, { timeout: 15000 }, (res) => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
res.resume();
|
||||||
|
file.close(() => {
|
||||||
|
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
|
||||||
|
downloadImage(res.headers.location, destPath, redirectsLeft - 1).then(resolve).catch(reject);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
cleanup();
|
||||||
|
return reject(new Error(`HTTP ${res.statusCode} beim Bild-Download`));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.pipe(file);
|
||||||
|
file.on('finish', () => file.close(() => resolve()));
|
||||||
|
file.on('error', (err) => { cleanup(); reject(err); });
|
||||||
|
}).on('error', (err) => {
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
}).on('timeout', function () {
|
||||||
|
this.destroy();
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('Timeout beim Bild-Download'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt das Bild einer extern-URL in den Cache herunter.
|
||||||
|
* Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start).
|
||||||
|
* @returns {Promise<string|null>} lokaler Pfad oder null
|
||||||
|
*/
|
||||||
|
async function cacheJobThumbnail(jobId, posterUrl) {
|
||||||
|
if (!posterUrl || isLocalUrl(posterUrl)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureDirs();
|
||||||
|
const dest = cacheFilePath(jobId);
|
||||||
|
await downloadImage(posterUrl, dest);
|
||||||
|
logger.info('thumbnail:cached', { jobId, posterUrl, dest });
|
||||||
|
return dest;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('thumbnail:cache:failed', { jobId, posterUrl, error: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt das gecachte Bild in den persistenten Ordner.
|
||||||
|
* Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden.
|
||||||
|
* Wird nach erfolgreichem Rip aufgerufen.
|
||||||
|
* @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null
|
||||||
|
*/
|
||||||
|
function promoteJobThumbnail(jobId) {
|
||||||
|
try {
|
||||||
|
ensureDirs();
|
||||||
|
const src = cacheFilePath(jobId);
|
||||||
|
const dest = persistentFilePath(jobId);
|
||||||
|
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
fs.renameSync(src, dest);
|
||||||
|
logger.info('thumbnail:promoted', { jobId, dest });
|
||||||
|
return localUrl(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls kein Cache vorhanden, aber persistente Datei schon existiert
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
return localUrl(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('thumbnail:promote:no-source', { jobId });
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('thumbnail:promote:failed', { jobId, error: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den Pfad zum persistenten Thumbnail-Ordner zurück (für Static-Serving).
|
||||||
|
*/
|
||||||
|
function getThumbnailsDir() {
|
||||||
|
return THUMBNAILS_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopiert das persistente Thumbnail von sourceJobId zu targetJobId.
|
||||||
|
* Wird bei Rip-Neustart genutzt, damit der neue Job ein eigenes Bild hat
|
||||||
|
* und nicht auf die Datei des alten Jobs angewiesen ist.
|
||||||
|
* @returns {string|null} neue lokale URL oder null
|
||||||
|
*/
|
||||||
|
function copyThumbnail(sourceJobId, targetJobId) {
|
||||||
|
try {
|
||||||
|
const src = persistentFilePath(sourceJobId);
|
||||||
|
if (!fs.existsSync(src)) return null;
|
||||||
|
ensureDirs();
|
||||||
|
const dest = persistentFilePath(targetJobId);
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
logger.info('thumbnail:copied', { sourceJobId, targetJobId });
|
||||||
|
return localUrl(targetJobId);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('thumbnail:copy:failed', { sourceJobId, targetJobId, error: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht Cache- und persistente Thumbnail-Datei eines Jobs.
|
||||||
|
* Wird beim Löschen eines Jobs aufgerufen.
|
||||||
|
*/
|
||||||
|
function deleteThumbnail(jobId) {
|
||||||
|
for (const filePath of [persistentFilePath(jobId), cacheFilePath(jobId)]) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('thumbnail:delete:failed', { jobId, filePath, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migriert bestehende Jobs: lädt alle externen poster_url-Bilder herunter
|
||||||
|
* und speichert sie lokal. Läuft beim Start im Hintergrund, sequenziell
|
||||||
|
* mit kurzem Delay um externe Server nicht zu überlasten.
|
||||||
|
*/
|
||||||
|
async function migrateExistingThumbnails() {
|
||||||
|
try {
|
||||||
|
ensureDirs();
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// Alle abgeschlossenen Jobs mit externer poster_url, die noch kein lokales Bild haben
|
||||||
|
const jobs = await db.all(
|
||||||
|
`SELECT id, poster_url FROM jobs
|
||||||
|
WHERE rip_successful = 1
|
||||||
|
AND poster_url IS NOT NULL
|
||||||
|
AND poster_url != ''
|
||||||
|
AND poster_url NOT LIKE '/api/thumbnails/%'
|
||||||
|
ORDER BY id ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!jobs.length) {
|
||||||
|
logger.info('thumbnail:migrate:nothing-to-do');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('thumbnail:migrate:start', { count: jobs.length });
|
||||||
|
let succeeded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
// Persistente Datei bereits vorhanden? Dann nur DB aktualisieren.
|
||||||
|
const dest = persistentFilePath(job.id);
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
|
||||||
|
succeeded++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadImage(job.poster_url, dest);
|
||||||
|
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
|
||||||
|
logger.info('thumbnail:migrate:ok', { jobId: job.id });
|
||||||
|
succeeded++;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('thumbnail:migrate:failed', { jobId: job.id, url: job.poster_url, error: err.message });
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurze Pause zwischen Downloads (externe Server schonen)
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('thumbnail:migrate:done', { succeeded, failed, total: jobs.length });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('thumbnail:migrate:error', { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cacheJobThumbnail,
|
||||||
|
promoteJobThumbnail,
|
||||||
|
copyThumbnail,
|
||||||
|
deleteThumbnail,
|
||||||
|
getThumbnailsDir,
|
||||||
|
migrateExistingThumbnails,
|
||||||
|
isLocalUrl
|
||||||
|
};
|
||||||
118
db/schema.sql
118
db/schema.sql
@@ -24,6 +24,7 @@ CREATE TABLE settings_values (
|
|||||||
|
|
||||||
CREATE TABLE jobs (
|
CREATE TABLE jobs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
parent_job_id INTEGER,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
year INTEGER,
|
year INTEGER,
|
||||||
imdb_id TEXT,
|
imdb_id TEXT,
|
||||||
@@ -47,11 +48,29 @@ CREATE TABLE jobs (
|
|||||||
encode_input_path TEXT,
|
encode_input_path TEXT,
|
||||||
encode_review_confirmed INTEGER DEFAULT 0,
|
encode_review_confirmed INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_jobs_status ON jobs(status);
|
CREATE INDEX idx_jobs_status ON jobs(status);
|
||||||
CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC);
|
CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC);
|
||||||
|
CREATE INDEX idx_jobs_parent_job_id ON jobs(parent_job_id);
|
||||||
|
|
||||||
|
CREATE TABLE job_lineage_artifacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
job_id INTEGER NOT NULL,
|
||||||
|
source_job_id INTEGER,
|
||||||
|
media_type TEXT,
|
||||||
|
raw_path TEXT,
|
||||||
|
output_path TEXT,
|
||||||
|
reason TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_job_lineage_artifacts_job_id ON job_lineage_artifacts(job_id);
|
||||||
|
CREATE INDEX idx_job_lineage_artifacts_source_job_id ON job_lineage_artifacts(source_job_id);
|
||||||
|
|
||||||
CREATE TABLE scripts (
|
CREATE TABLE scripts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -151,29 +170,21 @@ CREATE INDEX idx_user_presets_media_type ON user_presets(media_type);
|
|||||||
|
|
||||||
-- Pfade – Eigentümer für alternative Verzeichnisse (inline in DynamicSettingsForm gerendert)
|
-- Pfade – Eigentümer für alternative Verzeichnisse (inline in DynamicSettingsForm gerendert)
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015);
|
VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_owner', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_owner', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025);
|
VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('raw_dir_other_owner', 'Pfade', 'Eigentümer Raw-Ordner (Sonstiges)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1035);
|
VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_other_owner', NULL);
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
|
||||||
VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115);
|
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_owner', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_owner', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125);
|
VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_owner', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_owner', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
|
||||||
VALUES ('movie_dir_other_owner', 'Pfade', 'Eigentümer Film-Ordner (Sonstiges)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135);
|
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_other_owner', NULL);
|
|
||||||
|
|
||||||
-- Laufwerk
|
-- Laufwerk
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('drive_mode', 'Laufwerk', 'Laufwerksmodus', 'select', 1, 'Auto-Discovery oder explizites Device.', 'auto', '[{"label":"Auto Discovery","value":"auto"},{"label":"Explizites Device","value":"explicit"}]', '{}', 10);
|
VALUES ('drive_mode', 'Laufwerk', 'Laufwerksmodus', 'select', 1, 'Auto-Discovery oder explizites Device.', 'auto', '[{"label":"Auto Discovery","value":"auto"},{"label":"Explizites Device","value":"explicit"}]', '{}', 10);
|
||||||
@@ -187,43 +198,31 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
VALUES ('makemkv_source_index', 'Laufwerk', 'MakeMKV Source Index', 'number', 1, 'Disc Index im Auto-Modus.', '0', '[]', '{"min":0,"max":20}', 30);
|
VALUES ('makemkv_source_index', 'Laufwerk', 'MakeMKV Source Index', 'number', 1, 'Disc Index im Auto-Modus.', '0', '[]', '{"min":0,"max":20}', 30);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_source_index', '0');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_source_index', '0');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('disc_auto_detection_enabled', 'Laufwerk', 'Automatische Disk-Erkennung', 'boolean', 1, 'Wenn deaktiviert, findet keine automatische Laufwerksprüfung statt. Neue Disks werden nur per "Laufwerk neu lesen" erkannt.', 'true', '[]', '{}', 35);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('disc_auto_detection_enabled', 'true');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('disc_poll_interval_ms', 'Laufwerk', 'Polling Intervall (ms)', 'number', 1, 'Intervall für Disk-Erkennung.', '4000', '[]', '{"min":1000,"max":60000}', 40);
|
VALUES ('disc_poll_interval_ms', 'Laufwerk', 'Polling Intervall (ms)', 'number', 1, 'Intervall für Disk-Erkennung.', '4000', '[]', '{"min":1000,"max":60000}', 40);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('disc_poll_interval_ms', '4000');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('disc_poll_interval_ms', '4000');
|
||||||
|
|
||||||
-- Pfade
|
-- Pfade
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('raw_dir', 'Pfade', 'Raw Ausgabeordner', 'path', 1, 'Zwischenablage für MakeMKV Rip.', 'data/output/raw', '[]', '{"minLength":1}', 100);
|
VALUES ('raw_dir_bluray', 'Pfade', 'Raw-Ordner (Blu-ray)', 'path', 0, 'RAW-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 101);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir', 'data/output/raw');
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
|
||||||
VALUES ('raw_dir_bluray', 'Pfade', 'Raw Ausgabeordner (Blu-ray)', 'path', 0, 'Optionaler RAW-Zielpfad nur für Blu-ray. Leer = Fallback auf "Raw Ausgabeordner".', NULL, '[]', '{}', 101);
|
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('raw_dir_dvd', 'Pfade', 'Raw Ausgabeordner (DVD)', 'path', 0, 'Optionaler RAW-Zielpfad nur für DVD. Leer = Fallback auf "Raw Ausgabeordner".', NULL, '[]', '{}', 102);
|
VALUES ('raw_dir_dvd', 'Pfade', 'Raw-Ordner (DVD)', 'path', 0, 'RAW-Zielpfad für DVD. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 102);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('raw_dir_other', 'Pfade', 'Raw Ausgabeordner (Sonstiges)', 'path', 0, 'Optionaler RAW-Zielpfad nur für Sonstiges. Leer = Fallback auf "Raw Ausgabeordner".', NULL, '[]', '{}', 103);
|
VALUES ('movie_dir_bluray', 'Pfade', 'Film-Ordner (Blu-ray)', 'path', 0, 'Encode-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 111);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_other', NULL);
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
|
||||||
VALUES ('movie_dir', 'Pfade', 'Film Ausgabeordner', 'path', 1, 'Finale HandBrake Ausgabe.', 'data/output/movies', '[]', '{"minLength":1}', 110);
|
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir', 'data/output/movies');
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
|
||||||
VALUES ('movie_dir_bluray', 'Pfade', 'Film Ausgabeordner (Blu-ray)', 'path', 0, 'Optionaler Encode-Zielpfad nur für Blu-ray. Leer = Fallback auf "Film Ausgabeordner".', NULL, '[]', '{}', 111);
|
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('movie_dir_dvd', 'Pfade', 'Film Ausgabeordner (DVD)', 'path', 0, 'Optionaler Encode-Zielpfad nur für DVD. Leer = Fallback auf "Film Ausgabeordner".', NULL, '[]', '{}', 112);
|
VALUES ('movie_dir_dvd', 'Pfade', 'Film-Ordner (DVD)', 'path', 0, 'Encode-Zielpfad für DVD. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 112);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
|
||||||
VALUES ('movie_dir_other', 'Pfade', 'Film Ausgabeordner (Sonstiges)', 'path', 0, 'Optionaler Encode-Zielpfad nur für Sonstiges. Leer = Fallback auf "Film Ausgabeordner".', NULL, '[]', '{}', 113);
|
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_other', NULL);
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('log_dir', 'Pfade', 'Log Ordner', 'path', 1, 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', 'data/logs', '[]', '{"minLength":1}', 120);
|
VALUES ('log_dir', 'Pfade', 'Log Ordner', 'path', 1, 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', 'data/logs', '[]', '{"minLength":1}', 120);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('log_dir', 'data/logs');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('log_dir', 'data/logs');
|
||||||
@@ -263,9 +262,28 @@ VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart:
|
|||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Parallele Jobs', 'number', 1, 'Maximale Anzahl parallel laufender Jobs. Weitere Starts landen in der Queue.', '1', '[]', '{"min":1,"max":12}', 225);
|
VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('pipeline_max_parallel_cd_encodes', 'Tools', 'Max. parallele Audio CD Jobs', 'number', 1, 'Maximale Anzahl parallel laufender Audio CD Jobs (Rip + Encode als Einheit). Gilt zusätzlich zum Gesamtlimit.', '2', '[]', '{"min":1,"max":12}', 226);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_cd_encodes', '2');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('pipeline_max_total_encodes', 'Tools', 'Max. Encodes gesamt (medienunabhängig)', 'number', 1, 'Gesamtlimit für alle parallel laufenden Encode-Jobs (Film + Audio CD). Dieses Limit hat Vorrang vor den Einzellimits.', '3', '[]', '{"min":1,"max":24}', 227);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_total_encodes', '3');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('pipeline_cd_bypasses_queue', 'Tools', 'Audio CDs: Queue-Reihenfolge überspringen', 'boolean', 1, 'Wenn aktiv, können Audio CD Jobs unabhängig von Film-Jobs starten (überspringen die Film-Queue-Reihenfolge). Einzellimits und Gesamtlimit gelten weiterhin.', 'false', '[]', '{}', 228);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_cd_bypasses_queue', 'false');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('script_test_timeout_ms', 'Tools', 'Script-Test Timeout (ms)', 'number', 1, 'Timeout fuer Script-Tests in den Settings. 0 = kein Timeout.', '0', '[]', '{"min":0,"max":86400000}', 229);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('script_test_timeout_ms', '0');
|
||||||
|
|
||||||
|
-- Migration: Label für bestehende Installationen aktualisieren
|
||||||
|
UPDATE settings_schema SET label = 'Max. parallele Film/Video Encodes', description = 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.' WHERE key = 'pipeline_max_parallel_jobs' AND label = 'Parallele Jobs';
|
||||||
|
|
||||||
-- Tools – Blu-ray
|
-- Tools – Blu-ray
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('mediainfo_extra_args_bluray', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (Blu-ray).', NULL, '[]', '{}', 300);
|
VALUES ('mediainfo_extra_args_bluray', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (Blu-ray).', NULL, '[]', '{}', 300);
|
||||||
@@ -296,12 +314,8 @@ VALUES ('output_extension_bluray', 'Tools', 'Ausgabeformat', 'select', 1, 'Datei
|
|||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_bluray', 'mkv');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_bluray', 'mkv');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('filename_template_bluray', 'Tools', 'Dateiname Template', 'string', 1, 'Verfügbare Tokens: ${title}, ${year}, ${imdbId} (Blu-ray).', '${title} (${year})', '[]', '{"minLength":1}', 335);
|
VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('filename_template_bluray', '${title} (${year})');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
|
||||||
VALUES ('output_folder_template_bluray', 'Tools', 'Ordnername Template', 'string', 0, 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (Blu-ray).', NULL, '[]', '{}', 340);
|
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_folder_template_bluray', NULL);
|
|
||||||
|
|
||||||
-- Tools – DVD
|
-- Tools – DVD
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
@@ -310,7 +324,7 @@ INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_extra_args
|
|||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('makemkv_rip_mode_dvd', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner.', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"Backup","value":"backup"}]', '{}', 505);
|
VALUES ('makemkv_rip_mode_dvd', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner.', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"Backup","value":"backup"}]', '{}', 505);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_dvd', 'mkv');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_dvd', 'backup');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('makemkv_analyze_extra_args_dvd', 'Tools', 'MakeMKV Analyze Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Analyze (DVD).', NULL, '[]', '{}', 510);
|
VALUES ('makemkv_analyze_extra_args_dvd', 'Tools', 'MakeMKV Analyze Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Analyze (DVD).', NULL, '[]', '{}', 510);
|
||||||
@@ -333,12 +347,8 @@ VALUES ('output_extension_dvd', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiend
|
|||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_dvd', 'mkv');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_dvd', 'mkv');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('filename_template_dvd', 'Tools', 'Dateiname Template', 'string', 1, 'Verfügbare Tokens: ${title}, ${year}, ${imdbId} (DVD).', '${title} (${year})', '[]', '{"minLength":1}', 535);
|
VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 535);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('filename_template_dvd', '${title} (${year})');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
|
||||||
VALUES ('output_folder_template_dvd', 'Tools', 'Ordnername Template', 'string', 0, 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (DVD).', NULL, '[]', '{}', 540);
|
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_folder_template_dvd', NULL);
|
|
||||||
|
|
||||||
-- Tools – CD
|
-- Tools – CD
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
@@ -348,11 +358,11 @@ INSERT OR IGNORE INTO settings_values (key, value) VALUES ('cdparanoia_command',
|
|||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES (
|
VALUES (
|
||||||
'cd_output_template',
|
'cd_output_template',
|
||||||
'Tools',
|
'Pfade',
|
||||||
'CD Output Template',
|
'CD Output Template',
|
||||||
'string',
|
'string',
|
||||||
1,
|
1,
|
||||||
'Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich. Die Endung wird über das gewählte Ausgabeformat gesetzt.',
|
'Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.',
|
||||||
'{artist} - {album} ({year})/{trackNr} {artist} - {title}',
|
'{artist} - {album} ({year})/{trackNr} {artist} - {title}',
|
||||||
'[]',
|
'[]',
|
||||||
'{"minLength":1}',
|
'{"minLength":1}',
|
||||||
@@ -363,11 +373,11 @@ VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} -
|
|||||||
|
|
||||||
-- Pfade – CD
|
-- Pfade – CD
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('raw_dir_cd', 'Pfade', 'CD Ausgabeordner', 'path', 0, 'Optionaler Ausgabeordner für geripppte CD-Dateien. Leer = Fallback auf "Raw Ausgabeordner".', '/opt/ripster/backend/data/output/cd', '[]', '{}', 104);
|
VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für CD-Rips. Enthält die WAV-Rohdaten (RAW) sowie den encodierten Audio-Output. Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', '/opt/ripster/backend/data/output/cd');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045);
|
VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL);
|
||||||
|
|
||||||
-- Metadaten
|
-- Metadaten
|
||||||
@@ -384,6 +394,10 @@ VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean',
|
|||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true');
|
||||||
|
|
||||||
-- Benachrichtigungen
|
-- Benachrichtigungen
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ui_expert_mode', 'false');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500);
|
VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_enabled', 'false');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_enabled', 'false');
|
||||||
|
|||||||
@@ -34,17 +34,33 @@ function App() {
|
|||||||
if (message.type === 'PIPELINE_PROGRESS') {
|
if (message.type === 'PIPELINE_PROGRESS') {
|
||||||
const payload = message.payload;
|
const payload = message.payload;
|
||||||
const progressJobId = payload?.activeJobId;
|
const progressJobId = payload?.activeJobId;
|
||||||
|
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
|
||||||
|
? payload.contextPatch
|
||||||
|
: null;
|
||||||
setPipeline((prev) => {
|
setPipeline((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
// Update per-job progress map so concurrent jobs don't overwrite each other.
|
// Update per-job progress map so concurrent jobs don't overwrite each other.
|
||||||
if (progressJobId != null) {
|
if (progressJobId != null) {
|
||||||
|
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
|
||||||
|
const mergedJobContext = contextPatch
|
||||||
|
? {
|
||||||
|
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
|
||||||
|
? previousJobProgress.context
|
||||||
|
: {}),
|
||||||
|
...contextPatch
|
||||||
|
}
|
||||||
|
: (previousJobProgress?.context && typeof previousJobProgress.context === 'object'
|
||||||
|
? previousJobProgress.context
|
||||||
|
: undefined);
|
||||||
next.jobProgress = {
|
next.jobProgress = {
|
||||||
...(prev?.jobProgress || {}),
|
...(prev?.jobProgress || {}),
|
||||||
[progressJobId]: {
|
[progressJobId]: {
|
||||||
|
...previousJobProgress,
|
||||||
state: payload.state,
|
state: payload.state,
|
||||||
progress: payload.progress,
|
progress: payload.progress,
|
||||||
eta: payload.eta,
|
eta: payload.eta,
|
||||||
statusText: payload.statusText
|
statusText: payload.statusText,
|
||||||
|
...(mergedJobContext !== undefined ? { context: mergedJobContext } : {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -54,6 +70,12 @@ function App() {
|
|||||||
next.progress = payload.progress ?? prev?.progress;
|
next.progress = payload.progress ?? prev?.progress;
|
||||||
next.eta = payload.eta ?? prev?.eta;
|
next.eta = payload.eta ?? prev?.eta;
|
||||||
next.statusText = payload.statusText ?? prev?.statusText;
|
next.statusText = payload.statusText ?? prev?.statusText;
|
||||||
|
if (contextPatch) {
|
||||||
|
next.context = {
|
||||||
|
...(prev?.context && typeof prev.context === 'object' ? prev.context : {}),
|
||||||
|
...contextPatch
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,6 +116,12 @@ export const api = {
|
|||||||
forceRefresh: options.forceRefresh
|
forceRefresh: options.forceRefresh
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
getEffectivePaths(options = {}) {
|
||||||
|
return requestCachedGet('/settings/effective-paths', {
|
||||||
|
ttlMs: 30 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
|
},
|
||||||
getHandBrakePresets(options = {}) {
|
getHandBrakePresets(options = {}) {
|
||||||
return requestCachedGet('/settings/handbrake-presets', {
|
return requestCachedGet('/settings/handbrake-presets', {
|
||||||
ttlMs: 10 * 60 * 1000,
|
ttlMs: 10 * 60 * 1000,
|
||||||
@@ -437,10 +443,17 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history']);
|
afterMutationInvalidate(['/history']);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
async deleteJobEntry(jobId, target = 'none') {
|
getJobDeletePreview(jobId, options = {}) {
|
||||||
|
const includeRelated = options?.includeRelated !== false;
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set('includeRelated', includeRelated ? '1' : '0');
|
||||||
|
return request(`/history/${jobId}/delete-preview?${query.toString()}`);
|
||||||
|
},
|
||||||
|
async deleteJobEntry(jobId, target = 'none', options = {}) {
|
||||||
|
const includeRelated = Boolean(options?.includeRelated);
|
||||||
const result = await request(`/history/${jobId}/delete`, {
|
const result = await request(`/history/${jobId}/delete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ target })
|
body: JSON.stringify({ target, includeRelated })
|
||||||
});
|
});
|
||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { TabView, TabPanel } from 'primereact/tabview';
|
import { TabView, TabPanel } from 'primereact/tabview';
|
||||||
import { InputText } from 'primereact/inputtext';
|
import { InputText } from 'primereact/inputtext';
|
||||||
import { InputNumber } from 'primereact/inputnumber';
|
import { InputNumber } from 'primereact/inputnumber';
|
||||||
@@ -20,7 +20,8 @@ const GENERAL_TOOL_KEYS = new Set([
|
|||||||
'makemkv_min_length_minutes',
|
'makemkv_min_length_minutes',
|
||||||
'mediainfo_command',
|
'mediainfo_command',
|
||||||
'handbrake_command',
|
'handbrake_command',
|
||||||
'handbrake_restart_delete_incomplete_output'
|
'handbrake_restart_delete_incomplete_output',
|
||||||
|
'script_test_timeout_ms'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const HANDBRAKE_PRESET_SETTING_KEYS = new Set([
|
const HANDBRAKE_PRESET_SETTING_KEYS = new Set([
|
||||||
@@ -29,6 +30,78 @@ const HANDBRAKE_PRESET_SETTING_KEYS = new Set([
|
|||||||
'handbrake_preset_dvd'
|
'handbrake_preset_dvd'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const NOTIFICATION_EVENT_TOGGLE_KEYS = new Set([
|
||||||
|
'pushover_notify_metadata_ready',
|
||||||
|
'pushover_notify_rip_started',
|
||||||
|
'pushover_notify_encoding_started',
|
||||||
|
'pushover_notify_job_finished',
|
||||||
|
'pushover_notify_job_error',
|
||||||
|
'pushover_notify_job_cancelled',
|
||||||
|
'pushover_notify_reencode_started',
|
||||||
|
'pushover_notify_reencode_finished'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PUSHOVER_ENABLED_SETTING_KEY = 'pushover_enabled';
|
||||||
|
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
|
||||||
|
const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
|
||||||
|
'drive_device',
|
||||||
|
'makemkv_rip_mode',
|
||||||
|
'makemkv_rip_mode_bluray',
|
||||||
|
'makemkv_rip_mode_dvd',
|
||||||
|
'makemkv_backup_mode'
|
||||||
|
]);
|
||||||
|
const EXPERT_ONLY_SETTING_KEYS = new Set([
|
||||||
|
'pushover_device',
|
||||||
|
'pushover_priority',
|
||||||
|
'pushover_timeout_ms',
|
||||||
|
'makemkv_source_index',
|
||||||
|
'disc_poll_interval_ms',
|
||||||
|
'hardware_monitoring_interval_ms',
|
||||||
|
'makemkv_command',
|
||||||
|
'mediainfo_command',
|
||||||
|
'handbrake_command',
|
||||||
|
'mediainfo_extra_args_bluray',
|
||||||
|
'mediainfo_extra_args_dvd',
|
||||||
|
'makemkv_analyze_extra_args_bluray',
|
||||||
|
'makemkv_analyze_extra_args_dvd',
|
||||||
|
'makemkv_rip_extra_args_bluray',
|
||||||
|
'makemkv_rip_extra_args_dvd',
|
||||||
|
'cdparanoia_command'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function toBoolean(value) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
const normalized = normalizeText(value);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHideSettingByExpertMode(settingKey, expertModeEnabled) {
|
||||||
|
const key = normalizeSettingKey(settingKey);
|
||||||
|
if (!key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ALWAYS_HIDDEN_SETTING_KEYS.has(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key === EXPERT_MODE_SETTING_KEY) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !expertModeEnabled && EXPERT_ONLY_SETTING_KEYS.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSettingsByVisibility(settings, expertModeEnabled) {
|
||||||
|
const list = Array.isArray(settings) ? settings : [];
|
||||||
|
return list.filter((setting) => !shouldHideSettingByExpertMode(setting?.key, expertModeEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
function buildToolSections(settings) {
|
function buildToolSections(settings) {
|
||||||
const list = Array.isArray(settings) ? settings : [];
|
const list = Array.isArray(settings) ? settings : [];
|
||||||
const generalBucket = {
|
const generalBucket = {
|
||||||
@@ -84,6 +157,12 @@ function buildToolSections(settings) {
|
|||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path keys per medium — _owner keys are rendered inline
|
||||||
|
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
|
||||||
|
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
|
||||||
|
const CD_PATH_KEYS = ['raw_dir_cd', 'cd_output_template'];
|
||||||
|
const LOG_PATH_KEYS = ['log_dir'];
|
||||||
|
|
||||||
function buildSectionsForCategory(categoryName, settings) {
|
function buildSectionsForCategory(categoryName, settings) {
|
||||||
const list = Array.isArray(settings) ? settings : [];
|
const list = Array.isArray(settings) ? settings : [];
|
||||||
const normalizedCategory = normalizeText(categoryName);
|
const normalizedCategory = normalizeText(categoryName);
|
||||||
@@ -108,89 +187,46 @@ function isHandBrakePresetSetting(setting) {
|
|||||||
return HANDBRAKE_PRESET_SETTING_KEYS.has(key);
|
return HANDBRAKE_PRESET_SETTING_KEYS.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DynamicSettingsForm({
|
function isNotificationEventToggleSetting(setting) {
|
||||||
categories,
|
return setting?.type === 'boolean' && NOTIFICATION_EVENT_TOGGLE_KEYS.has(normalizeSettingKey(setting?.key));
|
||||||
values,
|
}
|
||||||
errors,
|
|
||||||
dirtyKeys,
|
function SettingField({
|
||||||
onChange
|
setting,
|
||||||
|
value,
|
||||||
|
error,
|
||||||
|
dirty,
|
||||||
|
ownerSetting,
|
||||||
|
ownerValue,
|
||||||
|
ownerError,
|
||||||
|
ownerDirty,
|
||||||
|
onChange,
|
||||||
|
variant = 'default'
|
||||||
}) {
|
}) {
|
||||||
const safeCategories = Array.isArray(categories) ? categories : [];
|
const ownerKey = ownerSetting?.key;
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (safeCategories.length === 0) {
|
|
||||||
setActiveIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (activeIndex < 0 || activeIndex >= safeCategories.length) {
|
|
||||||
setActiveIndex(0);
|
|
||||||
}
|
|
||||||
}, [activeIndex, safeCategories.length]);
|
|
||||||
|
|
||||||
if (safeCategories.length === 0) {
|
|
||||||
return <p>Keine Kategorien vorhanden.</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TabView
|
|
||||||
className="settings-tabview"
|
|
||||||
activeIndex={activeIndex}
|
|
||||||
onTabChange={(event) => setActiveIndex(Number(event.index || 0))}
|
|
||||||
scrollable
|
|
||||||
>
|
|
||||||
{safeCategories.map((category, categoryIndex) => (
|
|
||||||
<TabPanel
|
|
||||||
key={`${category.category || 'category'}-${categoryIndex}`}
|
|
||||||
header={category.category || `Kategorie ${categoryIndex + 1}`}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
|
|
||||||
const grouped = sections.length > 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="settings-sections">
|
|
||||||
{sections.map((section) => (
|
|
||||||
<section
|
|
||||||
key={`${category?.category || 'category'}-${section.id}`}
|
|
||||||
className={`settings-section${grouped ? ' grouped' : ''}`}
|
|
||||||
>
|
|
||||||
{section.title ? (
|
|
||||||
<div className="settings-section-head">
|
|
||||||
<h4>{section.title}</h4>
|
|
||||||
{section.description ? <small>{section.description}</small> : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{(() => {
|
|
||||||
const ownerKeySet = new Set(
|
|
||||||
(section.settings || [])
|
|
||||||
.filter((s) => String(s.key || '').endsWith('_owner'))
|
|
||||||
.map((s) => s.key)
|
|
||||||
);
|
|
||||||
const settingsByKey = new Map(
|
|
||||||
(section.settings || []).map((s) => [s.key, s])
|
|
||||||
);
|
|
||||||
const visibleSettings = (section.settings || []).filter(
|
|
||||||
(s) => !ownerKeySet.has(s.key)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="settings-grid">
|
|
||||||
{visibleSettings.map((setting) => {
|
|
||||||
const value = values?.[setting.key];
|
|
||||||
const error = errors?.[setting.key] || null;
|
|
||||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
|
||||||
|
|
||||||
const ownerKey = `${setting.key}_owner`;
|
|
||||||
const ownerSetting = settingsByKey.get(ownerKey) || null;
|
|
||||||
const pathHasValue = Boolean(String(value ?? '').trim());
|
const pathHasValue = Boolean(String(value ?? '').trim());
|
||||||
|
const isNotificationToggleBox = variant === 'notification-toggle' && setting?.type === 'boolean';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={setting.key} className="setting-row">
|
<div className={`setting-row${isNotificationToggleBox ? ' notification-toggle-box' : ''}`}>
|
||||||
|
{isNotificationToggleBox ? (
|
||||||
|
<div className="notification-toggle-head">
|
||||||
<label htmlFor={setting.key}>
|
<label htmlFor={setting.key}>
|
||||||
{setting.label}
|
{setting.label}
|
||||||
{setting.required && <span className="required">*</span>}
|
{setting.required && <span className="required">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
<InputSwitch
|
||||||
|
id={setting.key}
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label htmlFor={setting.key}>
|
||||||
|
{setting.label}
|
||||||
|
{setting.required && <span className="required">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
{setting.type === 'string' || setting.type === 'path' ? (
|
{setting.type === 'string' || setting.type === 'path' ? (
|
||||||
<InputText
|
<InputText
|
||||||
@@ -210,7 +246,7 @@ export default function DynamicSettingsForm({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{setting.type === 'boolean' ? (
|
{setting.type === 'boolean' && !isNotificationToggleBox ? (
|
||||||
<InputSwitch
|
<InputSwitch
|
||||||
id={setting.key}
|
id={setting.key}
|
||||||
checked={Boolean(value)}
|
checked={Boolean(value)}
|
||||||
@@ -230,7 +266,7 @@ export default function DynamicSettingsForm({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<small>{setting.description || ''}</small>
|
<small className="setting-description">{setting.description || ''}</small>
|
||||||
{isHandBrakePresetSetting(setting) ? (
|
{isHandBrakePresetSetting(setting) ? (
|
||||||
<small>
|
<small>
|
||||||
Preset-Erklärung:{' '}
|
Preset-Erklärung:{' '}
|
||||||
@@ -260,17 +296,17 @@ export default function DynamicSettingsForm({
|
|||||||
</label>
|
</label>
|
||||||
<InputText
|
<InputText
|
||||||
id={ownerKey}
|
id={ownerKey}
|
||||||
value={values?.[ownerKey] ?? ''}
|
value={ownerValue ?? ''}
|
||||||
placeholder="z.B. michael:ripster"
|
placeholder="z.B. michael:ripster"
|
||||||
disabled={!pathHasValue}
|
disabled={!pathHasValue}
|
||||||
onChange={(event) => onChange?.(ownerKey, event.target.value)}
|
onChange={(event) => onChange?.(ownerKey, event.target.value)}
|
||||||
/>
|
/>
|
||||||
{errors?.[ownerKey] ? (
|
{ownerError ? (
|
||||||
<small className="error-text">{errors[ownerKey]}</small>
|
<small className="error-text">{ownerError}</small>
|
||||||
) : (
|
) : (
|
||||||
<Tag
|
<Tag
|
||||||
value={dirtyKeys?.has?.(ownerKey) ? 'Ungespeichert' : 'Gespeichert'}
|
value={ownerDirty ? 'Ungespeichert' : 'Gespeichert'}
|
||||||
severity={dirtyKeys?.has?.(ownerKey) ? 'warning' : 'success'}
|
severity={ownerDirty ? 'warning' : 'success'}
|
||||||
className="saved-tag"
|
className="saved-tag"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -278,8 +314,362 @@ export default function DynamicSettingsForm({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PathMediumCard({ title, pathSettings, settingsByKey, values, errors, dirtyKeys, onChange }) {
|
||||||
|
// Filter out _owner keys since they're rendered inline
|
||||||
|
const visibleSettings = pathSettings.filter(
|
||||||
|
(s) => !String(s?.key || '').endsWith('_owner')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visibleSettings.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="path-medium-card">
|
||||||
|
<div className="path-medium-card-header">
|
||||||
|
<h4>{title}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="settings-grid">
|
||||||
|
{visibleSettings.map((setting) => {
|
||||||
|
const value = values?.[setting.key];
|
||||||
|
const error = errors?.[setting.key] || null;
|
||||||
|
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||||
|
const ownerKey = `${setting.key}_owner`;
|
||||||
|
const ownerSetting = settingsByKey.get(ownerKey) || null;
|
||||||
|
const ownerValue = values?.[ownerKey];
|
||||||
|
const ownerError = errors?.[ownerKey] || null;
|
||||||
|
const ownerDirty = Boolean(dirtyKeys?.has?.(ownerKey));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingField
|
||||||
|
key={setting.key}
|
||||||
|
setting={setting}
|
||||||
|
value={value}
|
||||||
|
error={error}
|
||||||
|
dirty={dirty}
|
||||||
|
ownerSetting={ownerSetting}
|
||||||
|
ownerValue={ownerValue}
|
||||||
|
ownerError={ownerError}
|
||||||
|
ownerDirty={ownerDirty}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effectivePaths }) {
|
||||||
|
const list = Array.isArray(settings) ? settings : [];
|
||||||
|
const settingsByKey = new Map(list.map((s) => [s.key, s]));
|
||||||
|
|
||||||
|
const bluraySettings = list.filter((s) => BLURAY_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && BLURAY_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||||
|
const dvdSettings = list.filter((s) => DVD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DVD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||||
|
const cdSettings = list.filter((s) => CD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && CD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||||
|
const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
|
||||||
|
|
||||||
|
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
|
||||||
|
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
|
||||||
|
const defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd';
|
||||||
|
|
||||||
|
const ep = effectivePaths || {};
|
||||||
|
const blurayRaw = ep.bluray?.raw || defaultRaw;
|
||||||
|
const blurayMovies = ep.bluray?.movies || defaultMovies;
|
||||||
|
const dvdRaw = ep.dvd?.raw || defaultRaw;
|
||||||
|
const dvdMovies = ep.dvd?.movies || defaultMovies;
|
||||||
|
const cdOutput = ep.cd?.raw || defaultCd;
|
||||||
|
|
||||||
|
const isDefault = (path, def) => path === def;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="path-category-tab">
|
||||||
|
{/* Effektive Pfade Übersicht */}
|
||||||
|
<div className="path-overview-card">
|
||||||
|
<div className="path-overview-header">
|
||||||
|
<h4>Effektive Pfade</h4>
|
||||||
|
<small>Zeigt die tatsächlich verwendeten Pfade entsprechend der aktuellen Konfiguration.</small>
|
||||||
|
</div>
|
||||||
|
<table className="path-overview-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Medium</th>
|
||||||
|
<th>RAW-Ordner</th>
|
||||||
|
<th>Film-Ordner</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Blu-ray</strong></td>
|
||||||
|
<td>
|
||||||
|
<code>{blurayRaw}</code>
|
||||||
|
{isDefault(blurayRaw, defaultRaw) && <span className="path-default-badge">Standard</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>{blurayMovies}</code>
|
||||||
|
{isDefault(blurayMovies, defaultMovies) && <span className="path-default-badge">Standard</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>DVD</strong></td>
|
||||||
|
<td>
|
||||||
|
<code>{dvdRaw}</code>
|
||||||
|
{isDefault(dvdRaw, defaultRaw) && <span className="path-default-badge">Standard</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>{dvdMovies}</code>
|
||||||
|
{isDefault(dvdMovies, defaultMovies) && <span className="path-default-badge">Standard</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>CD / Audio</strong></td>
|
||||||
|
<td colSpan={2}>
|
||||||
|
<code>{cdOutput}</code>
|
||||||
|
{isDefault(cdOutput, defaultCd) && <span className="path-default-badge">Standard</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Medium-Karten */}
|
||||||
|
<div className="path-medium-cards">
|
||||||
|
<PathMediumCard
|
||||||
|
title="Blu-ray"
|
||||||
|
pathSettings={bluraySettings}
|
||||||
|
settingsByKey={settingsByKey}
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
dirtyKeys={dirtyKeys}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<PathMediumCard
|
||||||
|
title="DVD"
|
||||||
|
pathSettings={dvdSettings}
|
||||||
|
settingsByKey={settingsByKey}
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
dirtyKeys={dirtyKeys}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<PathMediumCard
|
||||||
|
title="CD / Audio"
|
||||||
|
pathSettings={cdSettings}
|
||||||
|
settingsByKey={settingsByKey}
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
dirtyKeys={dirtyKeys}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log-Ordner */}
|
||||||
|
{logSettings.length > 0 && (
|
||||||
|
<div className="path-medium-card">
|
||||||
|
<div className="path-medium-card-header">
|
||||||
|
<h4>Logs</h4>
|
||||||
|
</div>
|
||||||
|
<div className="settings-grid">
|
||||||
|
{logSettings.map((setting) => {
|
||||||
|
const value = values?.[setting.key];
|
||||||
|
const error = errors?.[setting.key] || null;
|
||||||
|
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||||
|
return (
|
||||||
|
<SettingField
|
||||||
|
key={setting.key}
|
||||||
|
setting={setting}
|
||||||
|
value={value}
|
||||||
|
error={error}
|
||||||
|
dirty={dirty}
|
||||||
|
ownerSetting={null}
|
||||||
|
ownerValue={null}
|
||||||
|
ownerError={null}
|
||||||
|
ownerDirty={false}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicSettingsForm({
|
||||||
|
categories,
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
dirtyKeys,
|
||||||
|
onChange,
|
||||||
|
effectivePaths
|
||||||
|
}) {
|
||||||
|
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||||
|
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
|
||||||
|
const visibleCategories = safeCategories
|
||||||
|
.map((category) => ({
|
||||||
|
...category,
|
||||||
|
settings: filterSettingsByVisibility(category?.settings, expertModeEnabled)
|
||||||
|
}))
|
||||||
|
.filter((category) => Array.isArray(category?.settings) && category.settings.length > 0);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const rootRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visibleCategories.length === 0) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeIndex < 0 || activeIndex >= visibleCategories.length) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
}
|
||||||
|
}, [activeIndex, visibleCategories.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const syncToggleHeights = () => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const grids = root.querySelectorAll('.notification-toggle-grid');
|
||||||
|
for (const grid of grids) {
|
||||||
|
const cards = Array.from(grid.querySelectorAll('.notification-toggle-box'));
|
||||||
|
if (cards.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const card of cards) {
|
||||||
|
card.style.minHeight = '0px';
|
||||||
|
}
|
||||||
|
const maxHeight = cards.reduce((acc, card) => Math.max(acc, Number(card.offsetHeight || 0)), 0);
|
||||||
|
if (maxHeight <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const card of cards) {
|
||||||
|
card.style.minHeight = `${maxHeight}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const frameId = window.requestAnimationFrame(syncToggleHeights);
|
||||||
|
window.addEventListener('resize', syncToggleHeights);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
window.removeEventListener('resize', syncToggleHeights);
|
||||||
|
};
|
||||||
|
}, [activeIndex, visibleCategories, values]);
|
||||||
|
|
||||||
|
if (visibleCategories.length === 0) {
|
||||||
|
return <p>Keine Kategorien vorhanden.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dynamic-settings-form" ref={rootRef}>
|
||||||
|
<TabView
|
||||||
|
className="settings-tabview"
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
onTabChange={(event) => setActiveIndex(Number(event.index || 0))}
|
||||||
|
scrollable
|
||||||
|
>
|
||||||
|
{visibleCategories.map((category, categoryIndex) => (
|
||||||
|
<TabPanel
|
||||||
|
key={`${category.category || 'category'}-${categoryIndex}`}
|
||||||
|
header={category.category || `Kategorie ${categoryIndex + 1}`}
|
||||||
|
>
|
||||||
|
{normalizeText(category?.category) === 'pfade' ? (
|
||||||
|
<PathCategoryTab
|
||||||
|
settings={category?.settings || []}
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
dirtyKeys={dirtyKeys}
|
||||||
|
onChange={onChange}
|
||||||
|
effectivePaths={effectivePaths}
|
||||||
|
/>
|
||||||
|
) : (() => {
|
||||||
|
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
|
||||||
|
const grouped = sections.length > 1;
|
||||||
|
const isNotificationCategory = normalizeText(category?.category) === 'benachrichtigungen';
|
||||||
|
const pushoverEnabled = toBoolean(values?.[PUSHOVER_ENABLED_SETTING_KEY]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-sections">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<section
|
||||||
|
key={`${category?.category || 'category'}-${section.id}`}
|
||||||
|
className={`settings-section${grouped ? ' grouped' : ''}`}
|
||||||
|
>
|
||||||
|
{section.title ? (
|
||||||
|
<div className="settings-section-head">
|
||||||
|
<h4>{section.title}</h4>
|
||||||
|
{section.description ? <small>{section.description}</small> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{(() => {
|
||||||
|
const ownerKeySet = new Set(
|
||||||
|
(section.settings || [])
|
||||||
|
.filter((s) => String(s.key || '').endsWith('_owner'))
|
||||||
|
.map((s) => s.key)
|
||||||
|
);
|
||||||
|
const settingsByKey = new Map(
|
||||||
|
(section.settings || []).map((s) => [s.key, s])
|
||||||
|
);
|
||||||
|
const baseSettings = (section.settings || []).filter(
|
||||||
|
(s) => !ownerKeySet.has(s.key)
|
||||||
|
);
|
||||||
|
const notificationToggleSettings = isNotificationCategory
|
||||||
|
? baseSettings.filter((setting) => isNotificationEventToggleSetting(setting))
|
||||||
|
: [];
|
||||||
|
const notificationToggleKeys = new Set(
|
||||||
|
notificationToggleSettings.map((setting) => normalizeSettingKey(setting?.key))
|
||||||
|
);
|
||||||
|
const regularSettings = baseSettings.filter(
|
||||||
|
(setting) => !notificationToggleKeys.has(normalizeSettingKey(setting?.key))
|
||||||
|
);
|
||||||
|
const renderSetting = (setting, variant = 'default') => {
|
||||||
|
const value = values?.[setting.key];
|
||||||
|
const error = errors?.[setting.key] || null;
|
||||||
|
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||||
|
|
||||||
|
const ownerKey = `${setting.key}_owner`;
|
||||||
|
const ownerSetting = settingsByKey.get(ownerKey) || null;
|
||||||
|
const ownerValue = values?.[ownerKey];
|
||||||
|
const ownerError = errors?.[ownerKey] || null;
|
||||||
|
const ownerDirty = Boolean(dirtyKeys?.has?.(ownerKey));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingField
|
||||||
|
key={setting.key}
|
||||||
|
setting={setting}
|
||||||
|
value={value}
|
||||||
|
error={error}
|
||||||
|
dirty={dirty}
|
||||||
|
ownerSetting={ownerSetting}
|
||||||
|
ownerValue={ownerValue}
|
||||||
|
ownerError={ownerError}
|
||||||
|
ownerDirty={ownerDirty}
|
||||||
|
onChange={onChange}
|
||||||
|
variant={variant}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{regularSettings.length > 0 ? (
|
||||||
|
<div className="settings-grid">
|
||||||
|
{regularSettings.map((setting) => renderSetting(setting))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{pushoverEnabled && notificationToggleSettings.length > 0 ? (
|
||||||
|
<div className="notification-toggle-grid">
|
||||||
|
{notificationToggleSettings.map((setting) => renderSetting(setting, 'notification-toggle'))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</section>
|
</section>
|
||||||
@@ -290,5 +680,6 @@ export default function DynamicSettingsForm({
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
))}
|
))}
|
||||||
</TabView>
|
</TabView>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import discIndicatorIcon from '../assets/media-disc.svg';
|
|||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
import { getStatusLabel } from '../utils/statusPresentation';
|
import { getStatusLabel } from '../utils/statusPresentation';
|
||||||
|
|
||||||
|
const CD_FORMAT_LABELS = {
|
||||||
|
flac: 'FLAC',
|
||||||
|
wav: 'WAV',
|
||||||
|
mp3: 'MP3',
|
||||||
|
opus: 'Opus',
|
||||||
|
ogg: 'Ogg Vorbis'
|
||||||
|
};
|
||||||
|
|
||||||
function JsonView({ title, value }) {
|
function JsonView({ title, value }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -19,7 +27,6 @@ function ScriptResultRow({ result }) {
|
|||||||
const status = String(result?.status || '').toUpperCase();
|
const status = String(result?.status || '').toUpperCase();
|
||||||
const isSuccess = status === 'SUCCESS';
|
const isSuccess = status === 'SUCCESS';
|
||||||
const isError = status === 'ERROR';
|
const isError = status === 'ERROR';
|
||||||
const isSkipped = status.startsWith('SKIPPED');
|
|
||||||
const icon = isSuccess ? 'pi-check-circle' : isError ? 'pi-times-circle' : 'pi-minus-circle';
|
const icon = isSuccess ? 'pi-check-circle' : isError ? 'pi-times-circle' : 'pi-minus-circle';
|
||||||
const tone = isSuccess ? 'success' : isError ? 'danger' : 'warning';
|
const tone = isSuccess ? 'success' : isError ? 'danger' : 'warning';
|
||||||
return (
|
return (
|
||||||
@@ -74,6 +81,29 @@ function normalizeIdList(values) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePositiveInteger(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDurationSeconds(totalSeconds) {
|
||||||
|
const parsed = Number(totalSeconds);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rounded = Math.max(0, Math.trunc(parsed));
|
||||||
|
const hours = Math.floor(rounded / 3600);
|
||||||
|
const minutes = Math.floor((rounded % 3600) / 60);
|
||||||
|
const seconds = rounded % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
function shellQuote(value) {
|
function shellQuote(value) {
|
||||||
const raw = String(value ?? '');
|
const raw = String(value ?? '');
|
||||||
if (raw.length === 0) {
|
if (raw.length === 0) {
|
||||||
@@ -176,12 +206,13 @@ function buildConfiguredScriptAndChainSelection(job) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveMediaType(job) {
|
function resolveMediaType(job) {
|
||||||
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
job?.mediaType,
|
job?.mediaType,
|
||||||
job?.media_type,
|
job?.media_type,
|
||||||
job?.mediaProfile,
|
job?.mediaProfile,
|
||||||
job?.media_profile,
|
job?.media_profile,
|
||||||
job?.encodePlan?.mediaProfile,
|
encodePlan?.mediaProfile,
|
||||||
job?.makemkvInfo?.analyzeContext?.mediaProfile,
|
job?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||||
job?.makemkvInfo?.mediaProfile,
|
job?.makemkvInfo?.mediaProfile,
|
||||||
job?.mediainfoInfo?.mediaProfile
|
job?.mediainfoInfo?.mediaProfile
|
||||||
@@ -197,10 +228,86 @@ function resolveMediaType(job) {
|
|||||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
|
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
|
||||||
|
if (statusCandidates.some((v) => String(v || '').trim().toUpperCase().startsWith('CD_'))) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
|
||||||
|
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
|
||||||
|
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
||||||
|
return 'cd';
|
||||||
}
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCdDetails(job) {
|
||||||
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
||||||
|
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
||||||
|
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||||
|
? makemkvInfo.selectedMetadata
|
||||||
|
: {};
|
||||||
|
const tracksSource = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0
|
||||||
|
? makemkvInfo.tracks
|
||||||
|
: (Array.isArray(encodePlan?.tracks) ? encodePlan.tracks : []);
|
||||||
|
const tracks = tracksSource
|
||||||
|
.map((track) => {
|
||||||
|
const position = normalizePositiveInteger(track?.position);
|
||||||
|
if (!position) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { ...track, position, selected: track?.selected !== false };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
const selectedTracksFromPlan = Array.isArray(encodePlan?.selectedTracks)
|
||||||
|
? encodePlan.selectedTracks.map((v) => normalizePositiveInteger(v)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const selectedTrackPositions = selectedTracksFromPlan.length > 0
|
||||||
|
? selectedTracksFromPlan
|
||||||
|
: tracks.filter((t) => t.selected !== false).map((t) => t.position);
|
||||||
|
const fallbackArtist = tracks.map((t) => String(t?.artist || '').trim()).find(Boolean) || null;
|
||||||
|
const fallbackAlbum = tracks.map((t) => String(t?.album || '').trim()).find(Boolean) || null;
|
||||||
|
const totalDurationSec = tracks.reduce((sum, t) => {
|
||||||
|
const ms = Number(t?.durationMs);
|
||||||
|
const sec = Number(t?.durationSec);
|
||||||
|
if (Number.isFinite(ms) && ms > 0) {
|
||||||
|
return sum + ms / 1000;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(sec) && sec > 0) {
|
||||||
|
return sum + sec;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
const format = String(encodePlan?.format || '').trim().toLowerCase();
|
||||||
|
const mbId = String(
|
||||||
|
selectedMetadata?.mbId
|
||||||
|
|| selectedMetadata?.musicBrainzId
|
||||||
|
|| selectedMetadata?.musicbrainzId
|
||||||
|
|| selectedMetadata?.mbid
|
||||||
|
|| ''
|
||||||
|
).trim() || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
artist: String(selectedMetadata?.artist || '').trim() || fallbackArtist || null,
|
||||||
|
album: String(selectedMetadata?.album || '').trim() || fallbackAlbum || null,
|
||||||
|
trackCount: tracks.length,
|
||||||
|
selectedTrackCount: selectedTrackPositions.length,
|
||||||
|
format,
|
||||||
|
formatLabel: format ? (CD_FORMAT_LABELS[format] || format.toUpperCase()) : null,
|
||||||
|
totalDurationLabel: formatDurationSeconds(totalDurationSec),
|
||||||
|
mbId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function statusBadgeMeta(status, queued = false) {
|
function statusBadgeMeta(status, queued = false) {
|
||||||
const normalized = String(status || '').trim().toUpperCase();
|
const normalized = String(status || '').trim().toUpperCase();
|
||||||
const label = getStatusLabel(normalized, { queued });
|
const label = getStatusLabel(normalized, { queued });
|
||||||
@@ -276,6 +383,7 @@ export default function JobDetailDialog({
|
|||||||
onRestartEncode,
|
onRestartEncode,
|
||||||
onRestartReview,
|
onRestartReview,
|
||||||
onReencode,
|
onReencode,
|
||||||
|
onRetry,
|
||||||
onDeleteFiles,
|
onDeleteFiles,
|
||||||
onDeleteEntry,
|
onDeleteEntry,
|
||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
@@ -315,15 +423,22 @@ export default function JobDetailDialog({
|
|||||||
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
||||||
const logTruncated = Boolean(logMeta?.truncated);
|
const logTruncated = Boolean(logMeta?.truncated);
|
||||||
const mediaType = resolveMediaType(job);
|
const mediaType = resolveMediaType(job);
|
||||||
|
const isCd = mediaType === 'cd';
|
||||||
|
const cdDetails = isCd ? resolveCdDetails(job) : null;
|
||||||
|
const canRetry = isCd && !running && typeof onRetry === 'function';
|
||||||
const mediaTypeLabel = mediaType === 'bluray'
|
const mediaTypeLabel = mediaType === 'bluray'
|
||||||
? 'Blu-ray'
|
? 'Blu-ray'
|
||||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
: mediaType === 'dvd'
|
||||||
|
? 'DVD'
|
||||||
|
: isCd
|
||||||
|
? 'Audio CD'
|
||||||
|
: 'Sonstiges Medium';
|
||||||
const mediaTypeIcon = mediaType === 'bluray'
|
const mediaTypeIcon = mediaType === 'bluray'
|
||||||
? blurayIndicatorIcon
|
? blurayIndicatorIcon
|
||||||
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
|
: mediaType === 'dvd'
|
||||||
const mediaTypeAlt = mediaType === 'bluray'
|
? discIndicatorIcon
|
||||||
? 'Blu-ray'
|
: otherIndicatorIcon;
|
||||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
const mediaTypeAlt = mediaTypeLabel;
|
||||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
||||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||||
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
|
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
|
||||||
@@ -364,10 +479,59 @@ export default function JobDetailDialog({
|
|||||||
{job.poster_url && job.poster_url !== 'N/A' ? (
|
{job.poster_url && job.poster_url !== 'N/A' ? (
|
||||||
<img src={job.poster_url} alt={job.title || 'Poster'} className="poster-large" />
|
<img src={job.poster_url} alt={job.title || 'Poster'} className="poster-large" />
|
||||||
) : (
|
) : (
|
||||||
<div className="poster-large poster-fallback">Kein Poster</div>
|
<div className="poster-large poster-fallback">{isCd ? 'Kein Cover' : 'Kein Poster'}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="job-film-info-grid">
|
<div className="job-film-info-grid">
|
||||||
|
{isCd ? (
|
||||||
|
<section className="job-meta-block job-meta-block-film">
|
||||||
|
<h4>Musik-Infos</h4>
|
||||||
|
<div className="job-meta-list">
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Album:</strong>
|
||||||
|
<span>{job.title || job.detected_title || cdDetails?.album || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Interpret:</strong>
|
||||||
|
<span>{cdDetails?.artist || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Jahr:</strong>
|
||||||
|
<span>{job.year || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Tracks:</strong>
|
||||||
|
<span>
|
||||||
|
{cdDetails?.trackCount > 0
|
||||||
|
? (cdDetails.selectedTrackCount > 0 && cdDetails.selectedTrackCount !== cdDetails.trackCount
|
||||||
|
? `${cdDetails.selectedTrackCount}/${cdDetails.trackCount}`
|
||||||
|
: String(cdDetails.trackCount))
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Format:</strong>
|
||||||
|
<span>{cdDetails?.formatLabel || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Gesamtdauer:</strong>
|
||||||
|
<span>{cdDetails?.totalDurationLabel || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>MusicBrainz ID:</strong>
|
||||||
|
<span>{cdDetails?.mbId || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Medium:</strong>
|
||||||
|
<span className="job-step-cell">
|
||||||
|
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
|
||||||
|
<span>{mediaTypeLabel}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<section className="job-meta-block job-meta-block-film">
|
<section className="job-meta-block job-meta-block-film">
|
||||||
<h4>Film-Infos</h4>
|
<h4>Film-Infos</h4>
|
||||||
<div className="job-meta-list">
|
<div className="job-meta-list">
|
||||||
@@ -426,6 +590,8 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -449,33 +615,43 @@ export default function JobDetailDialog({
|
|||||||
<strong>Ende:</strong> {job.end_time || '-'}
|
<strong>Ende:</strong> {job.end_time || '-'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>RAW Pfad:</strong> {job.raw_path || '-'}
|
<strong>{isCd ? 'WAV Pfad:' : 'RAW Pfad:'}</strong> {job.raw_path || '-'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Output:</strong> {job.output_path || '-'}
|
<strong>Output:</strong> {job.output_path || '-'}
|
||||||
</div>
|
</div>
|
||||||
|
{!isCd ? (
|
||||||
<div>
|
<div>
|
||||||
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
|
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Movie Datei vorhanden:</strong> <BoolState value={job.outputStatus?.exists} />
|
<strong>{isCd ? 'Audio-Dateien vorhanden:' : 'Movie Datei vorhanden:'}</strong> <BoolState value={job.outputStatus?.exists} />
|
||||||
</div>
|
</div>
|
||||||
|
{isCd ? (
|
||||||
|
<div>
|
||||||
|
<strong>Rip erfolgreich:</strong> <BoolState value={job?.ripSuccessful} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<strong>Backup erfolgreich:</strong> <BoolState value={job?.backupSuccess} />
|
<strong>Backup erfolgreich:</strong> <BoolState value={job?.backupSuccess} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
|
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="job-meta-col-span-2">
|
<div className="job-meta-col-span-2">
|
||||||
<strong>Letzter Fehler:</strong> {job.error_message || '-'}
|
<strong>Letzter Fehler:</strong> {job.error_message || '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{hasConfiguredSelection || encodePlanUserPreset ? (
|
{!isCd && (hasConfiguredSelection || encodePlanUserPreset) ? (
|
||||||
<section className="job-meta-block job-meta-block-full">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>Hinterlegte Encode-Auswahl</h4>
|
<h4>Hinterlegte Encode-Auswahl</h4>
|
||||||
<div className="job-configured-selection-grid">
|
<div className="job-configured-selection-grid">
|
||||||
@@ -501,7 +677,7 @@ export default function JobDetailDialog({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{executedHandBrakeCommand ? (
|
{!isCd && executedHandBrakeCommand ? (
|
||||||
<section className="job-meta-block job-meta-block-full">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>Ausgeführter Encode-Befehl</h4>
|
<h4>Ausgeführter Encode-Befehl</h4>
|
||||||
<div className="handbrake-command-preview">
|
<div className="handbrake-command-preview">
|
||||||
@@ -511,7 +687,7 @@ export default function JobDetailDialog({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
|
{!isCd && (job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
|
||||||
<section className="job-meta-block job-meta-block-full">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>Skripte</h4>
|
<h4>Skripte</h4>
|
||||||
<div className="script-results-grid">
|
<div className="script-results-grid">
|
||||||
@@ -522,14 +698,14 @@ export default function JobDetailDialog({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="job-json-grid">
|
<div className="job-json-grid">
|
||||||
<JsonView title="OMDb Info" value={job.omdbInfo} />
|
{!isCd ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
|
||||||
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />
|
<JsonView title={isCd ? 'cdparanoia Info' : 'MakeMKV Info'} value={job.makemkvInfo} />
|
||||||
<JsonView title="Mediainfo Info" value={job.mediainfoInfo} />
|
{!isCd ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
|
||||||
<JsonView title="Encode Plan" value={job.encodePlan} />
|
<JsonView title={isCd ? 'Rip-Plan' : 'Encode Plan'} value={job.encodePlan} />
|
||||||
<JsonView title="HandBrake Info" value={job.handbrakeInfo} />
|
{!isCd ? <JsonView title="HandBrake Info" value={job.handbrakeInfo} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{job.encodePlan ? (
|
{!isCd && job.encodePlan ? (
|
||||||
<>
|
<>
|
||||||
<h4>Mediainfo-Prüfung (Auswertung)</h4>
|
<h4>Mediainfo-Prüfung (Auswertung)</h4>
|
||||||
<MediaInfoReviewPanel
|
<MediaInfoReviewPanel
|
||||||
@@ -562,6 +738,7 @@ export default function JobDetailDialog({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{!isCd ? (
|
||||||
<Button
|
<Button
|
||||||
label="OMDb neu zuordnen"
|
label="OMDb neu zuordnen"
|
||||||
icon="pi pi-search"
|
icon="pi pi-search"
|
||||||
@@ -571,7 +748,8 @@ export default function JobDetailDialog({
|
|||||||
loading={omdbAssignBusy}
|
loading={omdbAssignBusy}
|
||||||
disabled={running || typeof onAssignOmdb !== 'function'}
|
disabled={running || typeof onAssignOmdb !== 'function'}
|
||||||
/>
|
/>
|
||||||
{canResumeReady ? (
|
) : null}
|
||||||
|
{!isCd && canResumeReady ? (
|
||||||
<Button
|
<Button
|
||||||
label="Im Dashboard öffnen"
|
label="Im Dashboard öffnen"
|
||||||
icon="pi pi-window-maximize"
|
icon="pi pi-window-maximize"
|
||||||
@@ -582,7 +760,7 @@ export default function JobDetailDialog({
|
|||||||
loading={actionBusy}
|
loading={actionBusy}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{typeof onRestartEncode === 'function' ? (
|
{!isCd && typeof onRestartEncode === 'function' ? (
|
||||||
<Button
|
<Button
|
||||||
label="Encode neu starten"
|
label="Encode neu starten"
|
||||||
icon="pi pi-play"
|
icon="pi pi-play"
|
||||||
@@ -593,7 +771,7 @@ export default function JobDetailDialog({
|
|||||||
disabled={!canRestartEncode}
|
disabled={!canRestartEncode}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{typeof onRestartReview === 'function' ? (
|
{!isCd && typeof onRestartReview === 'function' ? (
|
||||||
<Button
|
<Button
|
||||||
label="Review neu starten"
|
label="Review neu starten"
|
||||||
icon="pi pi-refresh"
|
icon="pi pi-refresh"
|
||||||
@@ -605,6 +783,7 @@ export default function JobDetailDialog({
|
|||||||
disabled={!canRestartReview}
|
disabled={!canRestartReview}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!isCd ? (
|
||||||
<Button
|
<Button
|
||||||
label="RAW neu encodieren"
|
label="RAW neu encodieren"
|
||||||
icon="pi pi-cog"
|
icon="pi pi-cog"
|
||||||
@@ -614,6 +793,7 @@ export default function JobDetailDialog({
|
|||||||
loading={reencodeBusy}
|
loading={reencodeBusy}
|
||||||
disabled={!canReencode || typeof onReencode !== 'function'}
|
disabled={!canReencode || typeof onReencode !== 'function'}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
label="RAW löschen"
|
label="RAW löschen"
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
@@ -625,7 +805,7 @@ export default function JobDetailDialog({
|
|||||||
disabled={!job.rawStatus?.exists || typeof onDeleteFiles !== 'function'}
|
disabled={!job.rawStatus?.exists || typeof onDeleteFiles !== 'function'}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Movie löschen"
|
label={isCd ? 'Audio löschen' : 'Movie löschen'}
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
severity="warning"
|
severity="warning"
|
||||||
outlined
|
outlined
|
||||||
|
|||||||
@@ -243,8 +243,23 @@ function renderTemplate(template, values) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOutputPathPreview(settings, metadata, fallbackJobId = null) {
|
function resolveProfiledSetting(settings, key, mediaProfile) {
|
||||||
const movieDir = String(settings?.movie_dir || '').trim();
|
const profileKey = mediaProfile ? `${key}_${mediaProfile}` : null;
|
||||||
|
if (profileKey && settings?.[profileKey] != null && settings[profileKey] !== '') {
|
||||||
|
return settings[profileKey];
|
||||||
|
}
|
||||||
|
const fallbackProfiles = mediaProfile === 'bluray' ? ['dvd'] : ['bluray'];
|
||||||
|
for (const fb of fallbackProfiles) {
|
||||||
|
const fbKey = `${key}_${fb}`;
|
||||||
|
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
|
||||||
|
return settings[fbKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return settings?.[key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId = null) {
|
||||||
|
const movieDir = String(resolveProfiledSetting(settings, 'movie_dir', mediaProfile) || '').trim();
|
||||||
if (!movieDir) {
|
if (!movieDir) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -252,13 +267,26 @@ function buildOutputPathPreview(settings, metadata, fallbackJobId = null) {
|
|||||||
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
||||||
const year = metadata?.year || new Date().getFullYear();
|
const year = metadata?.year || new Date().getFullYear();
|
||||||
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
||||||
const fileTemplate = settings?.filename_template || '${title} (${year})';
|
const DEFAULT_TEMPLATE = '${title} (${year})/${title} (${year})';
|
||||||
const folderTemplate = String(settings?.output_folder_template || '').trim() || fileTemplate;
|
const rawTemplate = resolveProfiledSetting(settings, 'output_template', mediaProfile);
|
||||||
const folderName = sanitizeFileName(renderTemplate(folderTemplate, { title, year, imdbId }));
|
const template = String(rawTemplate || DEFAULT_TEMPLATE).trim() || DEFAULT_TEMPLATE;
|
||||||
const baseName = sanitizeFileName(renderTemplate(fileTemplate, { title, year, imdbId }));
|
const rendered = renderTemplate(template, { title, year, imdbId });
|
||||||
const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv';
|
const segments = rendered
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/\/+/g, '/')
|
||||||
|
.replace(/^\/+|\/+$/g, '')
|
||||||
|
.split('/')
|
||||||
|
.map((seg) => sanitizeFileName(seg))
|
||||||
|
.filter(Boolean);
|
||||||
|
const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
|
||||||
|
const folderParts = segments.slice(0, -1);
|
||||||
|
const rawExt = resolveProfiledSetting(settings, 'output_extension', mediaProfile);
|
||||||
|
const ext = String(rawExt || 'mkv').trim() || 'mkv';
|
||||||
const root = movieDir.replace(/\/+$/g, '');
|
const root = movieDir.replace(/\/+$/g, '');
|
||||||
return `${root}/${folderName}/${baseName}.${ext}`;
|
if (folderParts.length > 0) {
|
||||||
|
return `${root}/${folderParts.join('/')}/${baseName}.${ext}`;
|
||||||
|
}
|
||||||
|
return `${root}/${baseName}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PipelineStatusCard({
|
export default function PipelineStatusCard({
|
||||||
@@ -515,8 +543,8 @@ export default function PipelineStatusCard({
|
|||||||
|
|
||||||
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION';
|
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION';
|
||||||
const commandOutputPath = useMemo(
|
const commandOutputPath = useMemo(
|
||||||
() => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId),
|
() => buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId),
|
||||||
[settingsMap, selectedMetadata, retryJobId]
|
[settingsMap, jobMediaProfile, selectedMetadata, retryJobId]
|
||||||
);
|
);
|
||||||
const presetDisplayValue = useMemo(() => {
|
const presetDisplayValue = useMemo(() => {
|
||||||
const preset = String(mediaInfoReview?.selectors?.preset || '').trim();
|
const preset = String(mediaInfoReview?.selectors?.preset || '').trim();
|
||||||
|
|||||||
@@ -220,7 +220,11 @@ function normalizeQueue(queue) {
|
|||||||
const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : [];
|
const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : [];
|
||||||
return {
|
return {
|
||||||
maxParallelJobs: Number(payload.maxParallelJobs || 1),
|
maxParallelJobs: Number(payload.maxParallelJobs || 1),
|
||||||
|
maxParallelCdEncodes: Number(payload.maxParallelCdEncodes || 2),
|
||||||
|
maxTotalEncodes: Number(payload.maxTotalEncodes || 3),
|
||||||
|
cdBypassesQueue: Boolean(payload.cdBypassesQueue),
|
||||||
runningCount: Number(payload.runningCount || runningJobs.length || 0),
|
runningCount: Number(payload.runningCount || runningJobs.length || 0),
|
||||||
|
runningCdCount: Number(payload.runningCdCount || 0),
|
||||||
runningJobs,
|
runningJobs,
|
||||||
queuedJobs,
|
queuedJobs,
|
||||||
queuedCount: Number(payload.queuedCount || queuedJobs.length || 0),
|
queuedCount: Number(payload.queuedCount || queuedJobs.length || 0),
|
||||||
@@ -348,12 +352,13 @@ function getAnalyzeContext(job) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveMediaType(job) {
|
function resolveMediaType(job) {
|
||||||
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
job?.mediaType,
|
job?.mediaType,
|
||||||
job?.media_type,
|
job?.media_type,
|
||||||
job?.mediaProfile,
|
job?.mediaProfile,
|
||||||
job?.media_profile,
|
job?.media_profile,
|
||||||
job?.encodePlan?.mediaProfile,
|
encodePlan?.mediaProfile,
|
||||||
job?.makemkvInfo?.analyzeContext?.mediaProfile,
|
job?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||||
job?.makemkvInfo?.mediaProfile,
|
job?.makemkvInfo?.mediaProfile,
|
||||||
job?.mediainfoInfo?.mediaProfile
|
job?.mediainfoInfo?.mediaProfile
|
||||||
@@ -373,6 +378,25 @@ function resolveMediaType(job) {
|
|||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const statusCandidates = [
|
||||||
|
job?.status,
|
||||||
|
job?.last_state,
|
||||||
|
job?.makemkvInfo?.lastState
|
||||||
|
];
|
||||||
|
if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
|
||||||
|
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
|
||||||
|
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +449,84 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||||
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
||||||
const analyzeContext = getAnalyzeContext(job);
|
const analyzeContext = getAnalyzeContext(job);
|
||||||
|
const normalizePlanIdList = (values) => {
|
||||||
|
const list = Array.isArray(values) ? values : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const output = [];
|
||||||
|
for (const value of list) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = Math.trunc(parsed);
|
||||||
|
const key = String(id);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
output.push(id);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
const buildNamedSelection = (ids, entries, fallbackLabel) => {
|
||||||
|
const source = Array.isArray(entries) ? entries : [];
|
||||||
|
const namesById = new Map(
|
||||||
|
source
|
||||||
|
.map((entry) => {
|
||||||
|
const id = Number(entry?.id ?? entry?.scriptId ?? entry?.chainId);
|
||||||
|
const normalized = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null;
|
||||||
|
const name = String(entry?.name || entry?.scriptName || entry?.chainName || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [normalized, name || null];
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
return ids.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: namesById.get(id) || `${fallbackLabel} #${id}`
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const planPreScriptIds = normalizePlanIdList([
|
||||||
|
...(Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : []),
|
||||||
|
...(Array.isArray(encodePlan?.preEncodeScripts) ? encodePlan.preEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
|
||||||
|
]);
|
||||||
|
const planPostScriptIds = normalizePlanIdList([
|
||||||
|
...(Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : []),
|
||||||
|
...(Array.isArray(encodePlan?.postEncodeScripts) ? encodePlan.postEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
|
||||||
|
]);
|
||||||
|
const planPreChainIds = normalizePlanIdList([
|
||||||
|
...(Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []),
|
||||||
|
...(Array.isArray(encodePlan?.preEncodeChains) ? encodePlan.preEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
|
||||||
|
]);
|
||||||
|
const planPostChainIds = normalizePlanIdList([
|
||||||
|
...(Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []),
|
||||||
|
...(Array.isArray(encodePlan?.postEncodeChains) ? encodePlan.postEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
|
||||||
|
]);
|
||||||
|
const cdRipConfig = encodePlan && typeof encodePlan === 'object'
|
||||||
|
? {
|
||||||
|
format: String(encodePlan?.format || '').trim().toLowerCase() || null,
|
||||||
|
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
|
||||||
|
? encodePlan.formatOptions
|
||||||
|
: {},
|
||||||
|
selectedTracks: Array.isArray(encodePlan?.selectedTracks)
|
||||||
|
? encodePlan.selectedTracks
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0)
|
||||||
|
.map((value) => Math.trunc(value))
|
||||||
|
: [],
|
||||||
|
preEncodeScriptIds: planPreScriptIds,
|
||||||
|
postEncodeScriptIds: planPostScriptIds,
|
||||||
|
preEncodeChainIds: planPreChainIds,
|
||||||
|
postEncodeChainIds: planPostChainIds,
|
||||||
|
preEncodeScripts: buildNamedSelection(planPreScriptIds, encodePlan?.preEncodeScripts, 'Skript'),
|
||||||
|
postEncodeScripts: buildNamedSelection(planPostScriptIds, encodePlan?.postEncodeScripts, 'Skript'),
|
||||||
|
preEncodeChains: buildNamedSelection(planPreChainIds, encodePlan?.preEncodeChains, 'Kette'),
|
||||||
|
postEncodeChains: buildNamedSelection(planPostChainIds, encodePlan?.postEncodeChains, 'Kette'),
|
||||||
|
outputTemplate: String(encodePlan?.outputTemplate || '').trim() || null
|
||||||
|
}
|
||||||
|
: null;
|
||||||
const cdTracks = Array.isArray(makemkvInfo?.tracks)
|
const cdTracks = Array.isArray(makemkvInfo?.tracks)
|
||||||
? makemkvInfo.tracks
|
? makemkvInfo.tracks
|
||||||
.map((track) => {
|
.map((track) => {
|
||||||
@@ -443,6 +545,23 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
const cdSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
const cdSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||||
? makemkvInfo.selectedMetadata
|
? makemkvInfo.selectedMetadata
|
||||||
: {};
|
: {};
|
||||||
|
const fallbackCdArtist = cdTracks
|
||||||
|
.map((track) => String(track?.artist || '').trim())
|
||||||
|
.find(Boolean) || null;
|
||||||
|
const resolvedCdMbId = String(
|
||||||
|
cdSelectedMeta?.mbId
|
||||||
|
|| cdSelectedMeta?.musicBrainzId
|
||||||
|
|| cdSelectedMeta?.musicbrainzId
|
||||||
|
|| cdSelectedMeta?.mbid
|
||||||
|
|| ''
|
||||||
|
).trim() || null;
|
||||||
|
const resolvedCdCoverUrl = String(
|
||||||
|
cdSelectedMeta?.coverUrl
|
||||||
|
|| cdSelectedMeta?.poster
|
||||||
|
|| cdSelectedMeta?.posterUrl
|
||||||
|
|| job?.poster_url
|
||||||
|
|| ''
|
||||||
|
).trim() || null;
|
||||||
const cdparanoiaCmd = String(makemkvInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
|
const cdparanoiaCmd = String(makemkvInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
|
||||||
const devicePath = String(job?.disc_device || '').trim() || null;
|
const devicePath = String(job?.disc_device || '').trim() || null;
|
||||||
const firstConfiguredTrack = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0
|
const firstConfiguredTrack = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0
|
||||||
@@ -458,12 +577,12 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
|
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
|
||||||
const selectedMetadata = {
|
const selectedMetadata = {
|
||||||
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
|
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
|
||||||
artist: cdSelectedMeta?.artist || null,
|
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
|
||||||
year: cdSelectedMeta?.year ?? job?.year ?? null,
|
year: cdSelectedMeta?.year ?? job?.year ?? null,
|
||||||
mbId: cdSelectedMeta?.mbId || null,
|
mbId: resolvedCdMbId,
|
||||||
coverUrl: cdSelectedMeta?.coverUrl || null,
|
coverUrl: resolvedCdCoverUrl,
|
||||||
imdbId: job?.imdb_id || null,
|
imdbId: job?.imdb_id || null,
|
||||||
poster: job?.poster_url || cdSelectedMeta?.coverUrl || null
|
poster: job?.poster_url || resolvedCdCoverUrl || null
|
||||||
};
|
};
|
||||||
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||||
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||||||
@@ -508,11 +627,14 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
const computedContext = {
|
const computedContext = {
|
||||||
jobId,
|
jobId,
|
||||||
rawPath: job?.raw_path || null,
|
rawPath: job?.raw_path || null,
|
||||||
|
outputPath: job?.output_path || null,
|
||||||
detectedTitle: job?.detected_title || null,
|
detectedTitle: job?.detected_title || null,
|
||||||
mediaProfile: resolveMediaType(job),
|
mediaProfile: resolveMediaType(job),
|
||||||
|
lastState,
|
||||||
devicePath,
|
devicePath,
|
||||||
cdparanoiaCmd,
|
cdparanoiaCmd,
|
||||||
cdparanoiaCommandPreview,
|
cdparanoiaCommandPreview,
|
||||||
|
cdRipConfig,
|
||||||
tracks: cdTracks,
|
tracks: cdTracks,
|
||||||
inputPath,
|
inputPath,
|
||||||
hasEncodableTitle,
|
hasEncodableTitle,
|
||||||
@@ -543,6 +665,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
...computedContext,
|
...computedContext,
|
||||||
...existingContext,
|
...existingContext,
|
||||||
rawPath: existingContext.rawPath || computedContext.rawPath,
|
rawPath: existingContext.rawPath || computedContext.rawPath,
|
||||||
|
outputPath: existingContext.outputPath || computedContext.outputPath,
|
||||||
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
|
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
|
||||||
? existingContext.tracks
|
? existingContext.tracks
|
||||||
: computedContext.tracks,
|
: computedContext.tracks,
|
||||||
@@ -559,6 +682,20 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
const liveJobProgress = currentPipeline?.jobProgress && jobId
|
const liveJobProgress = currentPipeline?.jobProgress && jobId
|
||||||
? (currentPipeline.jobProgress[jobId] || null)
|
? (currentPipeline.jobProgress[jobId] || null)
|
||||||
: null;
|
: null;
|
||||||
|
const liveContext = liveJobProgress?.context && typeof liveJobProgress.context === 'object'
|
||||||
|
? liveJobProgress.context
|
||||||
|
: null;
|
||||||
|
const mergedContext = liveContext
|
||||||
|
? {
|
||||||
|
...computedContext,
|
||||||
|
...liveContext,
|
||||||
|
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
|
||||||
|
? liveContext.tracks
|
||||||
|
: computedContext.tracks,
|
||||||
|
selectedMetadata: liveContext.selectedMetadata || computedContext.selectedMetadata,
|
||||||
|
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
|
||||||
|
}
|
||||||
|
: computedContext;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: liveJobProgress?.state || jobStatus,
|
state: liveJobProgress?.state || jobStatus,
|
||||||
@@ -566,7 +703,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
progress: liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0,
|
progress: liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0,
|
||||||
eta: liveJobProgress?.eta || null,
|
eta: liveJobProgress?.eta || null,
|
||||||
statusText: liveJobProgress?.statusText || job?.error_message || null,
|
statusText: liveJobProgress?.statusText || job?.error_message || null,
|
||||||
context: computedContext
|
context: mergedContext
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1184,12 +1321,13 @@ export default function DashboardPage({
|
|||||||
try {
|
try {
|
||||||
const response = await api.retryJob(jobId);
|
const response = await api.retryJob(jobId);
|
||||||
const result = getQueueActionResult(response);
|
const result = getQueueActionResult(response);
|
||||||
|
const retryJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
if (result.queued) {
|
if (result.queued) {
|
||||||
showQueuedToast(toastRef, 'Retry', result);
|
showQueuedToast(toastRef, 'Retry', result);
|
||||||
} else {
|
} else {
|
||||||
setExpandedJobId(normalizedJobId);
|
setExpandedJobId(retryJobId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error);
|
showError(error);
|
||||||
@@ -1216,12 +1354,13 @@ export default function DashboardPage({
|
|||||||
try {
|
try {
|
||||||
const response = await api.restartEncodeWithLastSettings(jobId);
|
const response = await api.restartEncodeWithLastSettings(jobId);
|
||||||
const result = getQueueActionResult(response);
|
const result = getQueueActionResult(response);
|
||||||
|
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
if (result.queued) {
|
if (result.queued) {
|
||||||
showQueuedToast(toastRef, 'Encode-Neustart', result);
|
showQueuedToast(toastRef, 'Encode-Neustart', result);
|
||||||
} else {
|
} else {
|
||||||
setExpandedJobId(normalizedJobId);
|
setExpandedJobId(replacementJobId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error);
|
showError(error);
|
||||||
@@ -1238,10 +1377,12 @@ export default function DashboardPage({
|
|||||||
|
|
||||||
setJobBusy(normalizedJobId, true);
|
setJobBusy(normalizedJobId, true);
|
||||||
try {
|
try {
|
||||||
await api.restartReviewFromRaw(normalizedJobId);
|
const response = await api.restartReviewFromRaw(normalizedJobId);
|
||||||
|
const result = getQueueActionResult(response);
|
||||||
|
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
setExpandedJobId(normalizedJobId);
|
setExpandedJobId(replacementJobId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error);
|
showError(error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1426,15 +1567,28 @@ export default function DashboardPage({
|
|||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setJobBusy(jobId, true);
|
const normalizedJobId = normalizeJobId(jobId);
|
||||||
|
if (normalizedJobId) {
|
||||||
|
setJobBusy(normalizedJobId, true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api.startCdRip(jobId, ripConfig);
|
const response = await api.startCdRip(jobId, ripConfig);
|
||||||
|
const result = getQueueActionResult(response);
|
||||||
|
if (result.queued) {
|
||||||
|
showQueuedToast(toastRef, 'Audio CD', result);
|
||||||
|
}
|
||||||
|
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
|
if (replacementJobId) {
|
||||||
|
setExpandedJobId(replacementJobId);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error);
|
showError(error);
|
||||||
} finally {
|
} finally {
|
||||||
setJobBusy(jobId, false);
|
if (normalizedJobId) {
|
||||||
|
setJobBusy(normalizedJobId, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1772,10 +1926,14 @@ export default function DashboardPage({
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Job Queue" subTitle="Starts werden nach Parallel-Limit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
|
<Card title="Job Queue" subTitle="Starts werden nach Typ- und Gesamtlimit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
|
||||||
<div className="pipeline-queue-meta">
|
<div className="pipeline-queue-meta">
|
||||||
<Tag value={`Parallel: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
||||||
<Tag value={`Laufend: ${queueState?.runningCount || 0}`} severity={queueRunningJobs.length > 0 ? 'warning' : 'success'} />
|
<Tag value={`CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
|
||||||
|
<Tag value={`Gesamt max.: ${queueState?.maxTotalEncodes || 3}`} severity="info" />
|
||||||
|
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio CDs überspringen die Film-Queue-Reihenfolge" />}
|
||||||
|
<Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
|
||||||
|
<Tag value={`CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
|
||||||
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
|
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2110,6 +2268,15 @@ export default function DashboardPage({
|
|||||||
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
||||||
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||||
const mediaIndicator = mediaIndicatorMeta(job);
|
const mediaIndicator = mediaIndicatorMeta(job);
|
||||||
|
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
|
||||||
|
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
||||||
|
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
|
||||||
|
const pipelineStatusText = String(pipelineForJob?.statusText || '').trim().toUpperCase();
|
||||||
|
const isCdJob = jobState.startsWith('CD_')
|
||||||
|
|| pipelineStage.startsWith('CD_')
|
||||||
|
|| mediaProfile === 'cd'
|
||||||
|
|| mediaIndicator.mediaType === 'cd'
|
||||||
|
|| pipelineStatusText.includes('CD_');
|
||||||
const rawProgress = Number(pipelineForJob?.progress ?? 0);
|
const rawProgress = Number(pipelineForJob?.progress ?? 0);
|
||||||
const clampedProgress = Number.isFinite(rawProgress)
|
const clampedProgress = Number.isFinite(rawProgress)
|
||||||
? Math.max(0, Math.min(100, rawProgress))
|
? Math.max(0, Math.min(100, rawProgress))
|
||||||
@@ -2151,30 +2318,22 @@ export default function DashboardPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
|
||||||
const isCdJob = jobState.startsWith('CD_');
|
|
||||||
if (isCdJob) {
|
if (isCdJob) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{jobState === 'CD_METADATA_SELECTION' ? (
|
{isCdJob ? (
|
||||||
<Button
|
<CdRipConfigPanel
|
||||||
label="CD-Metadaten auswählen"
|
pipeline={pipelineForJob}
|
||||||
icon="pi pi-list"
|
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
|
||||||
onClick={() => {
|
onCancel={() => handleCancel(jobId, jobState)}
|
||||||
|
onRetry={() => handleRetry(jobId)}
|
||||||
|
onOpenMetadata={() => {
|
||||||
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
||||||
? pipelineForJob.context
|
? pipelineForJob.context
|
||||||
: pipeline?.context || {};
|
: pipeline?.context || {};
|
||||||
setCdMetadataDialogContext({ ...ctx, jobId });
|
setCdMetadataDialogContext({ ...ctx, jobId });
|
||||||
setCdMetadataDialogVisible(true);
|
setCdMetadataDialogVisible(true);
|
||||||
}}
|
}}
|
||||||
disabled={busyJobIds.has(jobId)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{(jobState === 'CD_READY_TO_RIP' || jobState === 'CD_RIPPING' || jobState === 'CD_ENCODING') ? (
|
|
||||||
<CdRipConfigPanel
|
|
||||||
pipeline={pipelineForJob}
|
|
||||||
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
|
|
||||||
onCancel={() => handleCancel(jobId, jobState)}
|
|
||||||
busy={busyJobIds.has(jobId)}
|
busy={busyJobIds.has(jobId)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2183,7 +2342,7 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})()}
|
})()}
|
||||||
{!String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase().startsWith('CD_') ? (
|
{!isCdJob ? (
|
||||||
<PipelineStatusCard
|
<PipelineStatusCard
|
||||||
pipeline={pipelineForJob}
|
pipeline={pipelineForJob}
|
||||||
onAnalyze={handleAnalyze}
|
onAnalyze={handleAnalyze}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Dropdown } from 'primereact/dropdown';
|
|||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import JobDetailDialog from '../components/JobDetailDialog';
|
import JobDetailDialog from '../components/JobDetailDialog';
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
@@ -22,6 +23,7 @@ const MEDIA_FILTER_OPTIONS = [
|
|||||||
{ label: 'Alle Medien', value: '' },
|
{ label: 'Alle Medien', value: '' },
|
||||||
{ label: 'Blu-ray', value: 'bluray' },
|
{ label: 'Blu-ray', value: 'bluray' },
|
||||||
{ label: 'DVD', value: 'dvd' },
|
{ label: 'DVD', value: 'dvd' },
|
||||||
|
{ label: 'Audio CD', value: 'cd' },
|
||||||
{ label: 'Sonstiges', value: 'other' }
|
{ label: 'Sonstiges', value: 'other' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -36,13 +38,30 @@ const SORT_OPTIONS = [
|
|||||||
{ label: 'Medium: Z -> A', value: '!sortMediaType' }
|
{ label: 'Medium: Z -> A', value: '!sortMediaType' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CD_FORMAT_LABELS = {
|
||||||
|
flac: 'FLAC',
|
||||||
|
wav: 'WAV',
|
||||||
|
mp3: 'MP3',
|
||||||
|
opus: 'Opus',
|
||||||
|
ogg: 'Ogg Vorbis'
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePositiveInteger(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMediaType(row) {
|
function resolveMediaType(row) {
|
||||||
|
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
row?.mediaType,
|
row?.mediaType,
|
||||||
row?.media_type,
|
row?.media_type,
|
||||||
row?.mediaProfile,
|
row?.mediaProfile,
|
||||||
row?.media_profile,
|
row?.media_profile,
|
||||||
row?.encodePlan?.mediaProfile,
|
encodePlan?.mediaProfile,
|
||||||
row?.makemkvInfo?.analyzeContext?.mediaProfile,
|
row?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||||
row?.makemkvInfo?.mediaProfile,
|
row?.makemkvInfo?.mediaProfile,
|
||||||
row?.mediainfoInfo?.mediaProfile
|
row?.mediainfoInfo?.mediaProfile
|
||||||
@@ -58,6 +77,28 @@ function resolveMediaType(row) {
|
|||||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
|
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statusCandidates = [
|
||||||
|
row?.status,
|
||||||
|
row?.last_state,
|
||||||
|
row?.makemkvInfo?.lastState
|
||||||
|
];
|
||||||
|
if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
|
||||||
|
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
|
||||||
|
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
if (String(row?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
if (Array.isArray(row?.makemkvInfo?.tracks) && row.makemkvInfo.tracks.length > 0) {
|
||||||
|
return 'cd';
|
||||||
}
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
@@ -80,6 +121,14 @@ function resolveMediaTypeMeta(row) {
|
|||||||
alt: 'DVD'
|
alt: 'DVD'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (mediaType === 'cd') {
|
||||||
|
return {
|
||||||
|
mediaType,
|
||||||
|
icon: otherIndicatorIcon,
|
||||||
|
label: 'Audio CD',
|
||||||
|
alt: 'Audio CD'
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
mediaType,
|
mediaType,
|
||||||
icon: otherIndicatorIcon,
|
icon: otherIndicatorIcon,
|
||||||
@@ -88,6 +137,93 @@ function resolveMediaTypeMeta(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDurationSeconds(totalSeconds) {
|
||||||
|
const parsed = Number(totalSeconds);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rounded = Math.max(0, Math.trunc(parsed));
|
||||||
|
const hours = Math.floor(rounded / 3600);
|
||||||
|
const minutes = Math.floor((rounded % 3600) / 60);
|
||||||
|
const seconds = rounded % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCdDetails(row) {
|
||||||
|
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
|
||||||
|
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||||
|
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||||
|
? makemkvInfo.selectedMetadata
|
||||||
|
: {};
|
||||||
|
const tracksSource = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0
|
||||||
|
? makemkvInfo.tracks
|
||||||
|
: (Array.isArray(encodePlan?.tracks) ? encodePlan.tracks : []);
|
||||||
|
const tracks = tracksSource
|
||||||
|
.map((track) => {
|
||||||
|
const position = normalizePositiveInteger(track?.position);
|
||||||
|
if (!position) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...track,
|
||||||
|
position,
|
||||||
|
selected: track?.selected !== false
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
const selectedTracksFromPlan = Array.isArray(encodePlan?.selectedTracks)
|
||||||
|
? encodePlan.selectedTracks
|
||||||
|
.map((value) => normalizePositiveInteger(value))
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const selectedTrackPositions = selectedTracksFromPlan.length > 0
|
||||||
|
? selectedTracksFromPlan
|
||||||
|
: tracks.filter((track) => track.selected !== false).map((track) => track.position);
|
||||||
|
const fallbackArtist = tracks
|
||||||
|
.map((track) => String(track?.artist || '').trim())
|
||||||
|
.find(Boolean) || null;
|
||||||
|
const totalDurationSec = tracks.reduce((sum, track) => {
|
||||||
|
const durationMs = Number(track?.durationMs);
|
||||||
|
const durationSec = Number(track?.durationSec);
|
||||||
|
if (Number.isFinite(durationMs) && durationMs > 0) {
|
||||||
|
return sum + (durationMs / 1000);
|
||||||
|
}
|
||||||
|
if (Number.isFinite(durationSec) && durationSec > 0) {
|
||||||
|
return sum + durationSec;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
const format = String(encodePlan?.format || '').trim().toLowerCase();
|
||||||
|
const mbId = String(
|
||||||
|
selectedMetadata?.mbId
|
||||||
|
|| selectedMetadata?.musicBrainzId
|
||||||
|
|| selectedMetadata?.musicbrainzId
|
||||||
|
|| selectedMetadata?.mbid
|
||||||
|
|| ''
|
||||||
|
).trim() || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
artist: String(selectedMetadata?.artist || '').trim() || fallbackArtist || null,
|
||||||
|
trackCount: tracks.length,
|
||||||
|
selectedTrackCount: selectedTrackPositions.length,
|
||||||
|
format,
|
||||||
|
formatLabel: format ? (CD_FORMAT_LABELS[format] || format.toUpperCase()) : null,
|
||||||
|
totalDurationLabel: formatDurationSeconds(totalDurationSec),
|
||||||
|
mbId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutputLabelForRow(row) {
|
||||||
|
return resolveMediaType(row) === 'cd' ? 'Audio-Dateien' : 'Movie-Datei(en)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutputShortLabelForRow(row) {
|
||||||
|
return resolveMediaType(row) === 'cd' ? 'Audio' : 'Movie';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeJobId(value) {
|
function normalizeJobId(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
@@ -173,6 +309,11 @@ export default function HistoryPage() {
|
|||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
||||||
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
|
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
|
||||||
|
const [deleteEntryDialogVisible, setDeleteEntryDialogVisible] = useState(false);
|
||||||
|
const [deleteEntryDialogRow, setDeleteEntryDialogRow] = useState(null);
|
||||||
|
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
|
||||||
|
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
||||||
|
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
@@ -321,7 +462,9 @@ export default function HistoryPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteFiles = async (row, target) => {
|
const handleDeleteFiles = async (row, target) => {
|
||||||
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? 'Movie-Datei(en)' : 'RAW + Movie';
|
const outputLabel = getOutputLabelForRow(row);
|
||||||
|
const outputShortLabel = getOutputShortLabelForRow(row);
|
||||||
|
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
|
||||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||||
const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
|
const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
@@ -335,7 +478,7 @@ export default function HistoryPage() {
|
|||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Dateien gelöscht',
|
summary: 'Dateien gelöscht',
|
||||||
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, MOVIE: ${summary.movie?.filesDeleted ?? 0}`,
|
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, ${outputShortLabel}: ${summary.movie?.filesDeleted ?? 0}`,
|
||||||
life: 3500
|
life: 3500
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
@@ -440,28 +583,129 @@ export default function HistoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteEntry = async (row) => {
|
const handleRetry = async (row) => {
|
||||||
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
||||||
const confirmed = window.confirm(`Historieneintrag für "${title}" wirklich löschen?\nDateien werden NICHT gelöscht.`);
|
const mediaType = resolveMediaType(row);
|
||||||
|
const actionLabel = mediaType === 'cd' ? 'CD-Rip' : 'Retry';
|
||||||
|
const confirmed = window.confirm(`${actionLabel} für "${title}" neu starten?`);
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setActionBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await api.retryJob(row.id);
|
||||||
|
const result = getQueueActionResult(response);
|
||||||
|
const replacementJobId = normalizeJobId(result?.jobId);
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: result.queued ? 'info' : 'success',
|
||||||
|
summary: mediaType === 'cd' ? 'CD-Rip neu gestartet' : 'Retry gestartet',
|
||||||
|
detail: result.queued
|
||||||
|
? 'Job wurde in die Warteschlange eingeplant.'
|
||||||
|
: (replacementJobId ? `Neuer Job #${replacementJobId} wurde erstellt.` : 'Job wurde neu gestartet.'),
|
||||||
|
life: 4000
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
if (replacementJobId) {
|
||||||
|
const detailResponse = await api.getJob(replacementJobId, { includeLogs: false });
|
||||||
|
setSelectedJob(detailResponse.job);
|
||||||
|
setDetailVisible(true);
|
||||||
|
} else {
|
||||||
|
await refreshDetailIfOpen(row.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: mediaType === 'cd' ? 'CD-Rip Neustart fehlgeschlagen' : 'Retry fehlgeschlagen',
|
||||||
|
detail: error.message,
|
||||||
|
life: 4500
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeleteEntryDialog = () => {
|
||||||
|
if (deleteEntryTargetBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteEntryDialogVisible(false);
|
||||||
|
setDeleteEntryDialogRow(null);
|
||||||
|
setDeleteEntryPreview(null);
|
||||||
|
setDeleteEntryPreviewLoading(false);
|
||||||
|
setDeleteEntryTargetBusy(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEntry = async (row) => {
|
||||||
|
const jobId = Number(row?.id || 0);
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteEntryDialogRow(row);
|
||||||
|
setDeleteEntryPreview(null);
|
||||||
|
setDeleteEntryDialogVisible(true);
|
||||||
|
setDeleteEntryPreviewLoading(true);
|
||||||
setDeleteEntryBusy(true);
|
setDeleteEntryBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.deleteJobEntry(row.id, 'none');
|
const response = await api.getJobDeletePreview(jobId, { includeRelated: true });
|
||||||
|
setDeleteEntryPreview(response?.preview || null);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Löschvorschau fehlgeschlagen',
|
||||||
|
detail: error.message,
|
||||||
|
life: 4500
|
||||||
|
});
|
||||||
|
setDeleteEntryDialogVisible(false);
|
||||||
|
setDeleteEntryDialogRow(null);
|
||||||
|
setDeleteEntryPreview(null);
|
||||||
|
} finally {
|
||||||
|
setDeleteEntryPreviewLoading(false);
|
||||||
|
setDeleteEntryBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteEntry = async (target) => {
|
||||||
|
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||||
|
if (!['raw', 'movie', 'both'].includes(normalizedTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const jobId = Number(deleteEntryDialogRow?.id || 0);
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteEntryBusy(true);
|
||||||
|
setDeleteEntryTargetBusy(normalizedTarget);
|
||||||
|
try {
|
||||||
|
const response = await api.deleteJobEntry(jobId, normalizedTarget, { includeRelated: true });
|
||||||
|
const deletedJobIds = Array.isArray(response?.deletedJobIds) ? response.deletedJobIds : [];
|
||||||
|
const fileSummary = response?.fileSummary || {};
|
||||||
|
const rawFiles = Number(fileSummary?.raw?.filesDeleted || 0);
|
||||||
|
const movieFiles = Number(fileSummary?.movie?.filesDeleted || 0);
|
||||||
|
const rawDirs = Number(fileSummary?.raw?.dirsRemoved || 0);
|
||||||
|
const movieDirs = Number(fileSummary?.movie?.dirsRemoved || 0);
|
||||||
|
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Eintrag gelöscht',
|
summary: 'Historie gelöscht',
|
||||||
detail: `"${title}" wurde aus der Historie entfernt.`,
|
detail: `${deletedJobIds.length || 1} Eintrag/Einträge entfernt | RAW: ${rawFiles} Dateien, ${rawDirs} Ordner | ${deleteEntryOutputShortLabel}: ${movieFiles} Dateien, ${movieDirs} Ordner`,
|
||||||
life: 3500
|
life: 5000
|
||||||
});
|
});
|
||||||
|
|
||||||
|
closeDeleteEntryDialog();
|
||||||
setDetailVisible(false);
|
setDetailVisible(false);
|
||||||
setSelectedJob(null);
|
setSelectedJob(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 });
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Löschen fehlgeschlagen',
|
||||||
|
detail: error.message,
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
setDeleteEntryTargetBusy(null);
|
||||||
setDeleteEntryBusy(false);
|
setDeleteEntryBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -508,11 +752,12 @@ export default function HistoryPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderPoster = (row, className = 'history-dv-poster') => {
|
const renderPoster = (row, className = 'history-dv-poster') => {
|
||||||
|
const mediaMeta = resolveMediaTypeMeta(row);
|
||||||
const title = row?.title || row?.detected_title || 'Poster';
|
const title = row?.title || row?.detected_title || 'Poster';
|
||||||
if (row?.poster_url && row.poster_url !== 'N/A') {
|
if (row?.poster_url && row.poster_url !== 'N/A') {
|
||||||
return <img src={row.poster_url} alt={title} className={className} loading="lazy" />;
|
return <img src={row.poster_url} alt={title} className={className} loading="lazy" />;
|
||||||
}
|
}
|
||||||
return <div className="history-dv-poster-fallback">Kein Poster</div>;
|
return <div className="history-dv-poster-fallback">{mediaMeta.mediaType === 'cd' ? 'Kein Cover' : 'Kein Poster'}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPresenceChip = (label, available) => (
|
const renderPresenceChip = (label, available) => (
|
||||||
@@ -522,7 +767,39 @@ export default function HistoryPage() {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRatings = (row) => {
|
const renderSupplementalInfo = (row) => {
|
||||||
|
if (resolveMediaType(row) === 'cd') {
|
||||||
|
const cdDetails = resolveCdDetails(row);
|
||||||
|
const infoItems = [];
|
||||||
|
if (cdDetails.trackCount > 0) {
|
||||||
|
infoItems.push({
|
||||||
|
key: 'tracks',
|
||||||
|
label: 'Tracks',
|
||||||
|
value: cdDetails.selectedTrackCount > 0 && cdDetails.selectedTrackCount !== cdDetails.trackCount
|
||||||
|
? `${cdDetails.selectedTrackCount}/${cdDetails.trackCount}`
|
||||||
|
: String(cdDetails.trackCount)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cdDetails.formatLabel) {
|
||||||
|
infoItems.push({ key: 'format', label: 'Format', value: cdDetails.formatLabel });
|
||||||
|
}
|
||||||
|
if (cdDetails.totalDurationLabel) {
|
||||||
|
infoItems.push({ key: 'duration', label: 'Dauer', value: cdDetails.totalDurationLabel });
|
||||||
|
}
|
||||||
|
if (cdDetails.mbId) {
|
||||||
|
infoItems.push({ key: 'mb', label: 'MusicBrainz', value: 'gesetzt' });
|
||||||
|
}
|
||||||
|
if (infoItems.length === 0) {
|
||||||
|
return <span className="history-dv-subtle">Keine CD-Details</span>;
|
||||||
|
}
|
||||||
|
return infoItems.map((item) => (
|
||||||
|
<span key={`${row?.id}-${item.key}`} className="history-dv-rating-chip">
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
<span>{item.value}</span>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
const ratings = resolveRatings(row);
|
const ratings = resolveRatings(row);
|
||||||
if (ratings.length === 0) {
|
if (ratings.length === 0) {
|
||||||
return <span className="history-dv-subtle">Keine Ratings</span>;
|
return <span className="history-dv-subtle">Keine Ratings</span>;
|
||||||
@@ -545,6 +822,16 @@ export default function HistoryPage() {
|
|||||||
|
|
||||||
const listItem = (row) => {
|
const listItem = (row) => {
|
||||||
const mediaMeta = resolveMediaTypeMeta(row);
|
const mediaMeta = resolveMediaTypeMeta(row);
|
||||||
|
const isCdJob = mediaMeta.mediaType === 'cd';
|
||||||
|
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
||||||
|
const subtitle = isCdJob
|
||||||
|
? [
|
||||||
|
`#${row?.id || '-'}`,
|
||||||
|
cdDetails?.artist || '-',
|
||||||
|
row?.year || null,
|
||||||
|
cdDetails?.mbId ? 'MusicBrainz' : null
|
||||||
|
].filter(Boolean).join(' | ')
|
||||||
|
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-12" key={row.id}>
|
<div className="col-12" key={row.id}>
|
||||||
@@ -565,9 +852,7 @@ export default function HistoryPage() {
|
|||||||
<div className="history-dv-head">
|
<div className="history-dv-head">
|
||||||
<div className="history-dv-title-block">
|
<div className="history-dv-title-block">
|
||||||
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
|
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
|
||||||
<small className="history-dv-subtle">
|
<small className="history-dv-subtle">{subtitle}</small>
|
||||||
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
{renderStatusTag(row)}
|
{renderStatusTag(row)}
|
||||||
</div>
|
</div>
|
||||||
@@ -582,12 +867,22 @@ export default function HistoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-flags-row">
|
<div className="history-dv-flags-row">
|
||||||
|
{isCdJob ? (
|
||||||
|
<>
|
||||||
|
{renderPresenceChip('Audio', Boolean(row?.outputStatus?.exists))}
|
||||||
|
{renderPresenceChip('Rip', Boolean(row?.ripSuccessful))}
|
||||||
|
{renderPresenceChip('Metadaten', Boolean(cdDetails?.artist || cdDetails?.mbId))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
|
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-actions">
|
<div className="history-dv-actions">
|
||||||
@@ -608,6 +903,16 @@ export default function HistoryPage() {
|
|||||||
|
|
||||||
const gridItem = (row) => {
|
const gridItem = (row) => {
|
||||||
const mediaMeta = resolveMediaTypeMeta(row);
|
const mediaMeta = resolveMediaTypeMeta(row);
|
||||||
|
const isCdJob = mediaMeta.mediaType === 'cd';
|
||||||
|
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
||||||
|
const subtitle = isCdJob
|
||||||
|
? [
|
||||||
|
`#${row?.id || '-'}`,
|
||||||
|
cdDetails?.artist || '-',
|
||||||
|
row?.year || null,
|
||||||
|
cdDetails?.mbId ? 'MusicBrainz' : null
|
||||||
|
].filter(Boolean).join(' | ')
|
||||||
|
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-12 md-col-6 xl-col-4" key={row.id}>
|
<div className="col-12 md-col-6 xl-col-4" key={row.id}>
|
||||||
@@ -630,9 +935,7 @@ export default function HistoryPage() {
|
|||||||
{renderStatusTag(row)}
|
{renderStatusTag(row)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<small className="history-dv-subtle">
|
<small className="history-dv-subtle">{subtitle}</small>
|
||||||
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
|
||||||
</small>
|
|
||||||
|
|
||||||
<div className="history-dv-meta-row">
|
<div className="history-dv-meta-row">
|
||||||
<span className="job-step-cell">
|
<span className="job-step-cell">
|
||||||
@@ -644,12 +947,22 @@ export default function HistoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-flags-row">
|
<div className="history-dv-flags-row">
|
||||||
|
{isCdJob ? (
|
||||||
|
<>
|
||||||
|
{renderPresenceChip('Audio', Boolean(row?.outputStatus?.exists))}
|
||||||
|
{renderPresenceChip('Rip', Boolean(row?.ripSuccessful))}
|
||||||
|
{renderPresenceChip('Metadaten', Boolean(cdDetails?.artist || cdDetails?.mbId))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
|
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-actions history-dv-actions-grid">
|
<div className="history-dv-actions history-dv-actions-grid">
|
||||||
@@ -675,12 +988,21 @@ export default function HistoryPage() {
|
|||||||
return currentLayout === 'list' ? listItem(row) : gridItem(row);
|
return currentLayout === 'list' ? listItem(row) : gridItem(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||||
|
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
|
||||||
|
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
||||||
|
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
||||||
|
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
|
||||||
|
const deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
|
||||||
|
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
|
||||||
|
const deleteEntryOutputShortLabel = getOutputShortLabelForRow(deleteEntryDialogRow);
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<div className="history-dv-toolbar">
|
<div className="history-dv-toolbar">
|
||||||
<InputText
|
<InputText
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
placeholder="Suche nach Titel oder IMDb"
|
placeholder="Suche nach Titel, Interpret oder IMDb"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -748,6 +1070,7 @@ export default function HistoryPage() {
|
|||||||
onRestartEncode={handleRestartEncode}
|
onRestartEncode={handleRestartEncode}
|
||||||
onRestartReview={handleRestartReview}
|
onRestartReview={handleRestartReview}
|
||||||
onReencode={handleReencode}
|
onReencode={handleReencode}
|
||||||
|
onRetry={handleRetry}
|
||||||
onDeleteFiles={handleDeleteFiles}
|
onDeleteFiles={handleDeleteFiles}
|
||||||
onDeleteEntry={handleDeleteEntry}
|
onDeleteEntry={handleDeleteEntry}
|
||||||
onRemoveFromQueue={handleRemoveFromQueue}
|
onRemoveFromQueue={handleRemoveFromQueue}
|
||||||
@@ -761,6 +1084,118 @@ export default function HistoryPage() {
|
|||||||
setLogLoadingMode(null);
|
setLogLoadingMode(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
header="Historien-Eintrag löschen"
|
||||||
|
visible={deleteEntryDialogVisible}
|
||||||
|
onHide={closeDeleteEntryDialog}
|
||||||
|
style={{ width: '56rem', maxWidth: '96vw' }}
|
||||||
|
className="history-delete-dialog"
|
||||||
|
modal
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{deleteEntryDialogRow ? (
|
||||||
|
<small className="muted-inline">
|
||||||
|
Job: {deleteEntryDialogRow?.title || deleteEntryDialogRow?.detected_title || `Job #${deleteEntryDialogRow?.id || '-'}`}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{deleteEntryPreviewLoading ? (
|
||||||
|
<p>Löschvorschau wird geladen ...</p>
|
||||||
|
) : (
|
||||||
|
<div className="history-delete-preview-grid">
|
||||||
|
<div>
|
||||||
|
<h4>Rip/Encode Historie</h4>
|
||||||
|
{previewRelatedJobs.length > 0 ? (
|
||||||
|
<ul className="history-delete-preview-list">
|
||||||
|
{previewRelatedJobs.map((item) => (
|
||||||
|
<li key={`delete-related-${item.id}`}>
|
||||||
|
<strong>#{item.id}</strong> | {item.title || '-'} | {item.status || '-'} {item.isPrimary ? '(aktuell)' : '(Alt-Eintrag)'}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4>{`RAW (${previewRawExisting.length}/${previewRawPaths.length})`}</h4>
|
||||||
|
{previewRawPaths.length > 0 ? (
|
||||||
|
<ul className="history-delete-preview-list">
|
||||||
|
{previewRawPaths.map((item) => (
|
||||||
|
<li key={`delete-raw-${item.path}`}>
|
||||||
|
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||||
|
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||||
|
</span>
|
||||||
|
{' '}| {item.path}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<small className="history-dv-subtle">Keine RAW-Pfade.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4>{`${deleteEntryOutputShortLabel} (${previewMovieExisting.length}/${previewMoviePaths.length})`}</h4>
|
||||||
|
{previewMoviePaths.length > 0 ? (
|
||||||
|
<ul className="history-delete-preview-list">
|
||||||
|
{previewMoviePaths.map((item) => (
|
||||||
|
<li key={`delete-movie-${item.path}`}>
|
||||||
|
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||||
|
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||||
|
</span>
|
||||||
|
{' '}| {item.path}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<small className="history-dv-subtle">Keine Movie-Pfade.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<Button
|
||||||
|
label="Nur RAW löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="warning"
|
||||||
|
outlined
|
||||||
|
onClick={() => confirmDeleteEntry('raw')}
|
||||||
|
loading={deleteEntryTargetBusy === 'raw'}
|
||||||
|
disabled={deleteTargetActionsDisabled}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={`Nur ${deleteEntryOutputShortLabel} löschen`}
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="warning"
|
||||||
|
outlined
|
||||||
|
onClick={() => confirmDeleteEntry('movie')}
|
||||||
|
loading={deleteEntryTargetBusy === 'movie'}
|
||||||
|
disabled={deleteTargetActionsDisabled}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Beides löschen"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
onClick={() => confirmDeleteEntry('both')}
|
||||||
|
loading={deleteEntryTargetBusy === 'both'}
|
||||||
|
disabled={deleteTargetActionsDisabled}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Abbrechen"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={closeDeleteEntryDialog}
|
||||||
|
disabled={Boolean(deleteEntryTargetBusy)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import { TabView, TabPanel } from 'primereact/tabview';
|
|||||||
import { InputText } from 'primereact/inputtext';
|
import { InputText } from 'primereact/inputtext';
|
||||||
import { InputTextarea } from 'primereact/inputtextarea';
|
import { InputTextarea } from 'primereact/inputtextarea';
|
||||||
import { Dropdown } from 'primereact/dropdown';
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
|
import { InputSwitch } from 'primereact/inputswitch';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||||||
import CronJobsTab from '../components/CronJobsTab';
|
import CronJobsTab from '../components/CronJobsTab';
|
||||||
|
|
||||||
|
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
|
||||||
|
|
||||||
function buildValuesMap(categories) {
|
function buildValuesMap(categories) {
|
||||||
const next = {};
|
const next = {};
|
||||||
for (const category of categories || []) {
|
for (const category of categories || []) {
|
||||||
@@ -28,6 +31,17 @@ function isSameValue(a, b) {
|
|||||||
return a === b;
|
return a === b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toBoolean(value) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||||
|
}
|
||||||
|
|
||||||
function reorderListById(items, sourceId, targetIndex) {
|
function reorderListById(items, sourceId, targetIndex) {
|
||||||
const list = Array.isArray(items) ? items : [];
|
const list = Array.isArray(items) ? items : [];
|
||||||
const normalizedSourceId = Number(sourceId);
|
const normalizedSourceId = Number(sourceId);
|
||||||
@@ -138,6 +152,7 @@ export default function SettingsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testingPushover, setTestingPushover] = useState(false);
|
const [testingPushover, setTestingPushover] = useState(false);
|
||||||
|
const [updatingExpertMode, setUpdatingExpertMode] = useState(false);
|
||||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||||
const [initialValues, setInitialValues] = useState({});
|
const [initialValues, setInitialValues] = useState({});
|
||||||
const [draftValues, setDraftValues] = useState({});
|
const [draftValues, setDraftValues] = useState({});
|
||||||
@@ -184,6 +199,7 @@ export default function SettingsPage() {
|
|||||||
});
|
});
|
||||||
const [userPresetErrors, setUserPresetErrors] = useState({});
|
const [userPresetErrors, setUserPresetErrors] = useState({});
|
||||||
const [handBrakePresetSourceOptions, setHandBrakePresetSourceOptions] = useState([]);
|
const [handBrakePresetSourceOptions, setHandBrakePresetSourceOptions] = useState([]);
|
||||||
|
const [effectivePaths, setEffectivePaths] = useState(null);
|
||||||
|
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
|
|
||||||
@@ -317,6 +333,17 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadEffectivePaths = async ({ silent = false } = {}) => {
|
||||||
|
try {
|
||||||
|
const paths = await api.getEffectivePaths({ forceRefresh: true });
|
||||||
|
setEffectivePaths(paths || null);
|
||||||
|
} catch (_error) {
|
||||||
|
if (!silent) {
|
||||||
|
setEffectivePaths(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -327,6 +354,7 @@ export default function SettingsPage() {
|
|||||||
setInitialValues(values);
|
setInitialValues(values);
|
||||||
setDraftValues(values);
|
setDraftValues(values);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
loadEffectivePaths({ silent: true });
|
||||||
|
|
||||||
const presetsPromise = api.getHandBrakePresets();
|
const presetsPromise = api.getHandBrakePresets();
|
||||||
const scriptsPromise = api.getScripts();
|
const scriptsPromise = api.getScripts();
|
||||||
@@ -389,12 +417,41 @@ export default function SettingsPage() {
|
|||||||
}, [initialValues, draftValues]);
|
}, [initialValues, draftValues]);
|
||||||
|
|
||||||
const hasUnsavedChanges = dirtyKeys.size > 0;
|
const hasUnsavedChanges = dirtyKeys.size > 0;
|
||||||
|
const expertModeEnabled = toBoolean(draftValues?.[EXPERT_MODE_SETTING_KEY]);
|
||||||
|
|
||||||
const handleFieldChange = (key, value) => {
|
const handleFieldChange = (key, value) => {
|
||||||
setDraftValues((prev) => ({ ...prev, [key]: value }));
|
setDraftValues((prev) => ({ ...prev, [key]: value }));
|
||||||
setErrors((prev) => ({ ...prev, [key]: null }));
|
setErrors((prev) => ({ ...prev, [key]: null }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExpertModeToggle = async (checked) => {
|
||||||
|
const previousDraftValue = draftValues?.[EXPERT_MODE_SETTING_KEY];
|
||||||
|
const previousInitialValue = initialValues?.[EXPERT_MODE_SETTING_KEY];
|
||||||
|
const nextValue = Boolean(checked);
|
||||||
|
const currentValue = toBoolean(previousDraftValue);
|
||||||
|
if (nextValue === currentValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdatingExpertMode(true);
|
||||||
|
setDraftValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: nextValue }));
|
||||||
|
setInitialValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: nextValue }));
|
||||||
|
setErrors((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: null }));
|
||||||
|
try {
|
||||||
|
await api.updateSetting(EXPERT_MODE_SETTING_KEY, nextValue);
|
||||||
|
} catch (error) {
|
||||||
|
setDraftValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: previousDraftValue }));
|
||||||
|
setInitialValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: previousInitialValue }));
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Expertenmodus',
|
||||||
|
detail: error.message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUpdatingExpertMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!hasUnsavedChanges) {
|
if (!hasUnsavedChanges) {
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
@@ -415,6 +472,7 @@ export default function SettingsPage() {
|
|||||||
const response = await api.updateSettingsBulk(patch);
|
const response = await api.updateSettingsBulk(patch);
|
||||||
setInitialValues((prev) => ({ ...prev, ...patch }));
|
setInitialValues((prev) => ({ ...prev, ...patch }));
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
loadEffectivePaths({ silent: true });
|
||||||
const reviewRefresh = response?.reviewRefresh || null;
|
const reviewRefresh = response?.reviewRefresh || null;
|
||||||
const reviewRefreshHint = reviewRefresh?.triggered
|
const reviewRefreshHint = reviewRefresh?.triggered
|
||||||
? ' Mediainfo-Prüfung wird mit den neuen Settings automatisch neu berechnet.'
|
? ' Mediainfo-Prüfung wird mit den neuen Settings automatisch neu berechnet.'
|
||||||
@@ -946,7 +1004,7 @@ export default function SettingsPage() {
|
|||||||
icon="pi pi-save"
|
icon="pi pi-save"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
disabled={!hasUnsavedChanges}
|
disabled={!hasUnsavedChanges || updatingExpertMode}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Änderungen verwerfen"
|
label="Änderungen verwerfen"
|
||||||
@@ -954,7 +1012,7 @@ export default function SettingsPage() {
|
|||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
onClick={handleDiscard}
|
onClick={handleDiscard}
|
||||||
disabled={!hasUnsavedChanges || saving}
|
disabled={!hasUnsavedChanges || saving || updatingExpertMode}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Neu laden"
|
label="Neu laden"
|
||||||
@@ -962,7 +1020,7 @@ export default function SettingsPage() {
|
|||||||
severity="secondary"
|
severity="secondary"
|
||||||
onClick={load}
|
onClick={load}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={saving}
|
disabled={saving || updatingExpertMode}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="PushOver Test"
|
label="PushOver Test"
|
||||||
@@ -970,8 +1028,16 @@ export default function SettingsPage() {
|
|||||||
severity="info"
|
severity="info"
|
||||||
onClick={handlePushoverTest}
|
onClick={handlePushoverTest}
|
||||||
loading={testingPushover}
|
loading={testingPushover}
|
||||||
disabled={saving}
|
disabled={saving || updatingExpertMode}
|
||||||
/>
|
/>
|
||||||
|
<div className="settings-expert-toggle">
|
||||||
|
<span>Expertenmodus</span>
|
||||||
|
<InputSwitch
|
||||||
|
checked={expertModeEnabled}
|
||||||
|
onChange={(event) => handleExpertModeToggle(event.value)}
|
||||||
|
disabled={loading || saving || updatingExpertMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -983,6 +1049,7 @@ export default function SettingsPage() {
|
|||||||
errors={errors}
|
errors={errors}
|
||||||
dirtyKeys={dirtyKeys}
|
dirtyKeys={dirtyKeys}
|
||||||
onChange={handleFieldChange}
|
onChange={handleFieldChange}
|
||||||
|
effectivePaths={effectivePaths}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
@@ -1526,7 +1593,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<small>
|
<small>
|
||||||
Encode-Presets fassen ein HandBrake-Preset und zusätzliche CLI-Argumente zusammen.
|
Encode-Presets fassen ein HandBrake-Preset und zusätzliche CLI-Argumente zusammen.
|
||||||
Sie sind medienbezogen (Blu-ray, DVD, Sonstiges oder Universell) und können vor dem Encode
|
Sie sind medienbezogen (Blu-ray, DVD oder Universell) und können vor dem Encode
|
||||||
in der Mediainfo-Prüfung ausgewählt werden. Kein Preset gewählt = Fallback aus Einstellungen.
|
in der Mediainfo-Prüfung ausgewählt werden. Kein Preset gewählt = Fallback aus Einstellungen.
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
@@ -1544,7 +1611,6 @@ export default function SettingsPage() {
|
|||||||
<span className="preset-media-type-tag">
|
<span className="preset-media-type-tag">
|
||||||
{preset.mediaType === 'bluray' ? 'Blu-ray'
|
{preset.mediaType === 'bluray' ? 'Blu-ray'
|
||||||
: preset.mediaType === 'dvd' ? 'DVD'
|
: preset.mediaType === 'dvd' ? 'DVD'
|
||||||
: preset.mediaType === 'other' ? 'Sonstiges'
|
|
||||||
: 'Universell'}
|
: 'Universell'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1604,7 +1670,6 @@ export default function SettingsPage() {
|
|||||||
<option value="all">Universell (alle Medien)</option>
|
<option value="all">Universell (alle Medien)</option>
|
||||||
<option value="bluray">Blu-ray</option>
|
<option value="bluray">Blu-ray</option>
|
||||||
<option value="dvd">DVD</option>
|
<option value="dvd">DVD</option>
|
||||||
<option value="other">Sonstiges</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,22 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-expert-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-expert-toggle > span {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.hardware-monitor-head {
|
.hardware-monitor-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -389,17 +405,19 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hardware-storage-head {
|
.hardware-storage-head {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
justify-content: space-between;
|
align-items: start;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hardware-storage-head strong {
|
.hardware-storage-head strong {
|
||||||
|
display: block;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
line-height: 1.3;
|
||||||
text-overflow: ellipsis;
|
white-space: normal;
|
||||||
white-space: nowrap;
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hardware-storage-percent {
|
.hardware-storage-percent {
|
||||||
@@ -409,6 +427,8 @@ body {
|
|||||||
padding: 0.1rem 0.45rem;
|
padding: 0.1rem 0.45rem;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
justify-self: end;
|
||||||
|
align-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hardware-storage-percent.tone-ok {
|
.hardware-storage-percent.tone-ok {
|
||||||
@@ -1100,22 +1120,39 @@ body {
|
|||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cd-rip-status {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cd-meta-summary {
|
.cd-meta-summary {
|
||||||
border: 1px solid var(--rip-border);
|
border: 1px solid var(--rip-border);
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
background: var(--rip-panel-soft);
|
background: var(--rip-panel-soft);
|
||||||
padding: 0.55rem 0.65rem;
|
padding: 0.55rem 0.65rem;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cd-media-meta-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cd-cover-wrap {
|
||||||
|
width: 7rem;
|
||||||
|
min-width: 7rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cd-cover-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.cd-format-field {
|
.cd-format-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
@@ -1185,6 +1222,13 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cd-track-table td.status,
|
||||||
|
.cd-track-table th.status {
|
||||||
|
width: 6.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.cd-track-table td.artist .p-inputtext,
|
.cd-track-table td.artist .p-inputtext,
|
||||||
.cd-track-table td.title .p-inputtext {
|
.cd-track-table td.title .p-inputtext {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1258,6 +1302,55 @@ body {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toggle-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid + .notification-toggle-grid {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toggle-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toggle-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toggle-head > label {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toggle-head .p-inputswitch {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toggle-box .setting-description {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toggle-box .saved-tag {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.setting-owner-row {
|
.setting-owner-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -1281,6 +1374,109 @@ body {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Path Category Tab ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.path-category-tab {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-overview-card {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-overview-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-overview-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--rip-brown-800);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-overview-header small {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-overview-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-overview-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--rip-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-overview-table td {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--rip-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-overview-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-overview-table code {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-default-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: var(--rip-border);
|
||||||
|
color: var(--rip-muted);
|
||||||
|
border-radius: 999px;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-medium-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-medium-card {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: #fff7ea;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-medium-card-header {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--rip-border);
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-medium-card-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--rip-brown-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-medium-card .settings-grid {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.script-manager-wrap {
|
.script-manager-wrap {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
@@ -1729,6 +1925,43 @@ body {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-delete-preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-delete-preview-grid h4 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-delete-preview-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.22rem;
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-delete-preview-list li {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-delete-preview-list .exists-yes {
|
||||||
|
color: #176635;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-delete-preview-list .exists-no {
|
||||||
|
color: #8b2c2c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.table-scroll-wrap {
|
.table-scroll-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -2141,6 +2374,19 @@ body {
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cd-encode-item-order {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cd-encode-item-order .p-button {
|
||||||
|
width: 1.5rem;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.media-title-list,
|
.media-title-list,
|
||||||
.media-track-list {
|
.media-track-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2294,6 +2540,7 @@ body {
|
|||||||
.job-meta-grid,
|
.job-meta-grid,
|
||||||
.job-configured-selection-grid,
|
.job-configured-selection-grid,
|
||||||
.job-film-info-grid,
|
.job-film-info-grid,
|
||||||
|
.history-delete-preview-grid,
|
||||||
.table-filters,
|
.table-filters,
|
||||||
.history-dv-toolbar,
|
.history-dv-toolbar,
|
||||||
.job-head-row,
|
.job-head-row,
|
||||||
@@ -2303,6 +2550,10 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-toggle-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-job-row {
|
.dashboard-job-row {
|
||||||
grid-template-columns: 48px minmax(0, 1fr) auto;
|
grid-template-columns: 48px minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
@@ -2319,6 +2570,15 @@ body {
|
|||||||
min-width: 36rem;
|
min-width: 36rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cd-media-meta-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cd-cover-wrap {
|
||||||
|
width: 6.5rem;
|
||||||
|
min-width: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.script-list {
|
.script-list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -2390,6 +2650,14 @@ body {
|
|||||||
width: min(1280px, 98vw);
|
width: min(1280px, 98vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hardware-storage-head {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-percent {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
.table-filters {
|
.table-filters {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -2422,6 +2690,15 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-expert-toggle {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toggle-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-actions {
|
.dialog-actions {
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ export const STATUS_FILTER_OPTIONS = [
|
|||||||
{ label: getStatusLabel('FINISHED'), value: 'FINISHED' },
|
{ label: getStatusLabel('FINISHED'), value: 'FINISHED' },
|
||||||
{ label: getStatusLabel('CANCELLED'), value: 'CANCELLED' },
|
{ label: getStatusLabel('CANCELLED'), value: 'CANCELLED' },
|
||||||
{ label: getStatusLabel('ERROR'), value: 'ERROR' },
|
{ label: getStatusLabel('ERROR'), value: 'ERROR' },
|
||||||
|
{ label: getStatusLabel('CD_METADATA_SELECTION'), value: 'CD_METADATA_SELECTION' },
|
||||||
|
{ label: getStatusLabel('CD_READY_TO_RIP'), value: 'CD_READY_TO_RIP' },
|
||||||
|
{ label: getStatusLabel('CD_ANALYZING'), value: 'CD_ANALYZING' },
|
||||||
|
{ label: getStatusLabel('CD_RIPPING'), value: 'CD_RIPPING' },
|
||||||
|
{ label: getStatusLabel('CD_ENCODING'), value: 'CD_ENCODING' },
|
||||||
{ label: getStatusLabel('WAITING_FOR_USER_DECISION'), value: 'WAITING_FOR_USER_DECISION' },
|
{ label: getStatusLabel('WAITING_FOR_USER_DECISION'), value: 'WAITING_FOR_USER_DECISION' },
|
||||||
{ label: getStatusLabel('READY_TO_START'), value: 'READY_TO_START' },
|
{ label: getStatusLabel('READY_TO_START'), value: 'READY_TO_START' },
|
||||||
{ label: getStatusLabel('READY_TO_ENCODE'), value: 'READY_TO_ENCODE' },
|
{ label: getStatusLabel('READY_TO_ENCODE'), value: 'READY_TO_ENCODE' },
|
||||||
|
|||||||
955
install-dev.sh
955
install-dev.sh
@@ -1,955 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# =============================================================================
|
|
||||||
# Ripster – Installationsskript
|
|
||||||
# Unterstützt: Debian 11/12, Ubuntu 22.04/24.04
|
|
||||||
# Benötigt: sudo / root
|
|
||||||
#
|
|
||||||
# Verwendung:
|
|
||||||
# chmod +x install.sh
|
|
||||||
# sudo ./install.sh [Optionen]
|
|
||||||
#
|
|
||||||
# Optionen:
|
|
||||||
# --dir <pfad> Installationsverzeichnis (Standard: /opt/ripster)
|
|
||||||
# --user <benutzer> Systembenutzer für den Dienst (Standard: ripster)
|
|
||||||
# --port <port> Backend-Port (Standard: 3001)
|
|
||||||
# --host <hostname> Hostname/IP für die Weboberfläche (Standard: Maschinen-IP)
|
|
||||||
# --no-makemkv MakeMKV-Installation überspringen
|
|
||||||
# --no-handbrake HandBrake-Installation überspringen
|
|
||||||
# --build-handbrake HandBrake aus Quellcode mit NVDEC-Unterstützung bauen
|
|
||||||
# --handbrake-version HandBrake-Version für Source-Build (Standard: 1.9.0)
|
|
||||||
# --handbrake-update-policy <keep|prompt|build>
|
|
||||||
# Bei NVDEC-Self-Build: bei neuer Git-Release behalten,
|
|
||||||
# nachfragen oder direkt neu bauen (Standard: keep)
|
|
||||||
# --no-nginx Nginx-Einrichtung überspringen (Frontend läuft dann auf Port 5173)
|
|
||||||
# --reinstall Vorhandene Installation ersetzen (Daten bleiben erhalten)
|
|
||||||
# -h, --help Diese Hilfe anzeigen
|
|
||||||
# =============================================================================
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# --- Farben -------------------------------------------------------------------
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m'
|
|
||||||
|
|
||||||
info() { echo -e "${BLUE}[INFO]${RESET} $*"; }
|
|
||||||
ok() { echo -e "${GREEN}[OK]${RESET} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
|
|
||||||
error() { echo -e "${RED}[FEHLER]${RESET} $*" >&2; }
|
|
||||||
header() { echo -e "\n${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; \
|
|
||||||
echo -e "${BOLD} $*${RESET}"; \
|
|
||||||
echo -e "${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; }
|
|
||||||
fatal() { error "$*"; exit 1; }
|
|
||||||
|
|
||||||
# --- Standard-Optionen --------------------------------------------------------
|
|
||||||
INSTALL_DIR="/opt/ripster"
|
|
||||||
SERVICE_USER="ripster"
|
|
||||||
BACKEND_PORT="3001"
|
|
||||||
FRONTEND_HOST="" # wird automatisch ermittelt, wenn leer
|
|
||||||
SKIP_MAKEMKV=false
|
|
||||||
SKIP_HANDBRAKE=false
|
|
||||||
BUILD_HANDBRAKE_NVDEC=false
|
|
||||||
HANDBRAKE_MODE_SELECTED=false
|
|
||||||
HANDBRAKE_VERSION="1.9.0"
|
|
||||||
HANDBRAKE_UPDATE_POLICY="keep"
|
|
||||||
HANDBRAKE_UPDATE_POLICY_SELECTED=false
|
|
||||||
SKIP_NGINX=false
|
|
||||||
REINSTALL=false
|
|
||||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
HANDBRAKE_SELFBUILD_MARKER="/usr/local/share/ripster/handbrake-selfbuild.env"
|
|
||||||
|
|
||||||
# --- Argumente parsen ---------------------------------------------------------
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--dir) INSTALL_DIR="$2"; shift 2 ;;
|
|
||||||
--user) SERVICE_USER="$2"; shift 2 ;;
|
|
||||||
--port) BACKEND_PORT="$2"; shift 2 ;;
|
|
||||||
--host) FRONTEND_HOST="$2"; shift 2 ;;
|
|
||||||
--no-makemkv) SKIP_MAKEMKV=true; shift ;;
|
|
||||||
--no-handbrake) SKIP_HANDBRAKE=true; shift ;;
|
|
||||||
--build-handbrake) BUILD_HANDBRAKE_NVDEC=true; HANDBRAKE_MODE_SELECTED=true; shift ;;
|
|
||||||
--handbrake-version) HANDBRAKE_VERSION="$2"; shift 2 ;;
|
|
||||||
--handbrake-update-policy)
|
|
||||||
case "$2" in
|
|
||||||
keep|prompt|build) HANDBRAKE_UPDATE_POLICY="$2"; HANDBRAKE_UPDATE_POLICY_SELECTED=true ;;
|
|
||||||
*) fatal "Ungültige --handbrake-update-policy: $2 (erlaubt: keep|prompt|build)" ;;
|
|
||||||
esac
|
|
||||||
shift 2 ;;
|
|
||||||
--no-nginx) SKIP_NGINX=true; shift ;;
|
|
||||||
--reinstall) REINSTALL=true; shift ;;
|
|
||||||
-h|--help)
|
|
||||||
sed -n '/^# Verwendung/,/^# ====/p' "$0" | head -n -1 | sed 's/^# \?//'
|
|
||||||
exit 0 ;;
|
|
||||||
*) fatal "Unbekannte Option: $1" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Voraussetzungen prüfen ---------------------------------------------------
|
|
||||||
header "Ripster Installationsskript"
|
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
|
||||||
fatal "Dieses Skript muss als root ausgeführt werden (sudo ./install.sh)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# OS-Erkennung
|
|
||||||
if [[ ! -f /etc/os-release ]]; then
|
|
||||||
fatal "Betriebssystem nicht erkennbar. Nur Debian/Ubuntu wird unterstützt."
|
|
||||||
fi
|
|
||||||
. /etc/os-release
|
|
||||||
case "$ID" in
|
|
||||||
debian|ubuntu|linuxmint|pop) ok "Betriebssystem: $PRETTY_NAME" ;;
|
|
||||||
*) fatal "Nicht unterstütztes OS: $ID. Nur Debian/Ubuntu unterstützt." ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Host-IP ermitteln
|
|
||||||
if [[ -z "$FRONTEND_HOST" ]]; then
|
|
||||||
FRONTEND_HOST=$(hostname -I | awk '{print $1}')
|
|
||||||
info "Erkannte IP: $FRONTEND_HOST"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Quelldirectory prüfen
|
|
||||||
[[ -f "$SOURCE_DIR/backend/package.json" ]] || \
|
|
||||||
fatal "Ripster-Quellen nicht gefunden in: $SOURCE_DIR"
|
|
||||||
|
|
||||||
info "Quellverzeichnis: $SOURCE_DIR"
|
|
||||||
info "Installationsverzeichnis: $INSTALL_DIR"
|
|
||||||
info "Systembenutzer: $SERVICE_USER"
|
|
||||||
info "Backend-Port: $BACKEND_PORT"
|
|
||||||
info "Frontend-Host: $FRONTEND_HOST"
|
|
||||||
|
|
||||||
# --- Hilfsfunktionen ----------------------------------------------------------
|
|
||||||
|
|
||||||
command_exists() { command -v "$1" &>/dev/null; }
|
|
||||||
|
|
||||||
install_node() {
|
|
||||||
header "Node.js installieren"
|
|
||||||
local required_major=20
|
|
||||||
|
|
||||||
if command_exists node; then
|
|
||||||
local current_major
|
|
||||||
current_major=$(node -e "process.stdout.write(String(process.version.split('.')[0].replace('v','')))")
|
|
||||||
if [[ "$current_major" -ge "$required_major" ]]; then
|
|
||||||
ok "Node.js $(node --version) bereits installiert"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
warn "Node.js $(node --version) zu alt – Node.js 20 wird installiert"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Installiere Node.js 20.x über NodeSource..."
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
|
||||||
apt-get install -y nodejs
|
|
||||||
ok "Node.js $(node --version) installiert"
|
|
||||||
}
|
|
||||||
|
|
||||||
install_makemkv() {
|
|
||||||
header "MakeMKV installieren"
|
|
||||||
|
|
||||||
if command_exists makemkvcon; then
|
|
||||||
ok "makemkvcon bereits installiert ($(makemkvcon --version 2>&1 | head -1))"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Installiere Build-Abhängigkeiten für MakeMKV..."
|
|
||||||
apt-get install -y \
|
|
||||||
build-essential pkg-config libc6-dev libssl-dev \
|
|
||||||
libexpat1-dev libavcodec-dev libgl1-mesa-dev \
|
|
||||||
qtbase5-dev zlib1g-dev wget
|
|
||||||
|
|
||||||
local makemkv_fallback="1.18.3"
|
|
||||||
info "Ermittle aktuelle MakeMKV-Version (forum.makemkv.com)..."
|
|
||||||
local makemkv_version
|
|
||||||
makemkv_version=$(curl -s --max-time 15 \
|
|
||||||
"https://forum.makemkv.com/forum/viewtopic.php?f=3&t=224" \
|
|
||||||
| grep -oP 'MakeMKV \K[0-9]+\.[0-9]+\.[0-9]+(?= for Linux)' | head -1 || true)
|
|
||||||
|
|
||||||
if [[ -z "$makemkv_version" ]]; then
|
|
||||||
warn "MakeMKV-Version konnte nicht ermittelt werden – verwende Fallback $makemkv_fallback"
|
|
||||||
makemkv_version="$makemkv_fallback"
|
|
||||||
else
|
|
||||||
info "Gefundene Version: $makemkv_version"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Baue MakeMKV $makemkv_version..."
|
|
||||||
local tmp_dir
|
|
||||||
tmp_dir=$(mktemp -d)
|
|
||||||
cd "$tmp_dir"
|
|
||||||
|
|
||||||
local base_url="https://www.makemkv.com/download"
|
|
||||||
wget -q "${base_url}/makemkv-bin-${makemkv_version}.tar.gz"
|
|
||||||
wget -q "${base_url}/makemkv-oss-${makemkv_version}.tar.gz"
|
|
||||||
|
|
||||||
tar xf "makemkv-oss-${makemkv_version}.tar.gz"
|
|
||||||
cd "makemkv-oss-${makemkv_version}"
|
|
||||||
./configure
|
|
||||||
make -j"$(nproc)"
|
|
||||||
make install
|
|
||||||
|
|
||||||
cd "$tmp_dir"
|
|
||||||
tar xf "makemkv-bin-${makemkv_version}.tar.gz"
|
|
||||||
cd "makemkv-bin-${makemkv_version}"
|
|
||||||
mkdir -p tmp && echo "accepted" > tmp/eula_accepted
|
|
||||||
make -j"$(nproc)"
|
|
||||||
make install
|
|
||||||
|
|
||||||
cd /
|
|
||||||
rm -rf "$tmp_dir"
|
|
||||||
ok "MakeMKV $makemkv_version installiert"
|
|
||||||
warn "Hinweis: MakeMKV benötigt eine Lizenz oder den Beta-Key."
|
|
||||||
warn "Beta-Key: https://www.makemkv.com/forum/viewtopic.php?t=1053"
|
|
||||||
}
|
|
||||||
|
|
||||||
handbrake_has_nvdec() {
|
|
||||||
command_exists HandBrakeCLI || return 1
|
|
||||||
HandBrakeCLI --help 2>&1 | grep -qi "nvdec"
|
|
||||||
}
|
|
||||||
|
|
||||||
handbrake_installed_version() {
|
|
||||||
command_exists HandBrakeCLI || return 1
|
|
||||||
HandBrakeCLI --version 2>/dev/null | grep -oE '[0-9]+(\.[0-9]+){1,3}' | head -1
|
|
||||||
}
|
|
||||||
|
|
||||||
handbrake_latest_git_version() {
|
|
||||||
local latest=""
|
|
||||||
latest=$(curl -fsSL --max-time 10 "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" 2>/dev/null \
|
|
||||||
| grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' \
|
|
||||||
| head -1 \
|
|
||||||
| sed -E 's/.*"([^"]+)".*/\1/' \
|
|
||||||
| sed 's/^v//')
|
|
||||||
[[ -n "$latest" ]] || return 1
|
|
||||||
[[ "$latest" =~ ^[0-9]+(\.[0-9]+){1,3}$ ]] || return 1
|
|
||||||
printf '%s\n' "$latest"
|
|
||||||
}
|
|
||||||
|
|
||||||
handbrake_is_self_built() {
|
|
||||||
local hb_path="${1:-$(command -v HandBrakeCLI 2>/dev/null || true)}"
|
|
||||||
local resolved_path=""
|
|
||||||
[[ -n "$hb_path" ]] || return 1
|
|
||||||
[[ "$hb_path" == "/usr/local/bin/HandBrakeCLI" ]] || return 1
|
|
||||||
[[ -f "$HANDBRAKE_SELFBUILD_MARKER" ]] && return 0
|
|
||||||
resolved_path=$(readlink -f "$hb_path" 2>/dev/null || true)
|
|
||||||
dpkg -S "$hb_path" >/dev/null 2>&1 && return 1
|
|
||||||
[[ -n "$resolved_path" ]] && dpkg -S "$resolved_path" >/dev/null 2>&1 && return 1
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
remove_non_selfbuilt_handbrake() {
|
|
||||||
info "Entferne nicht-selbst-gebaute HandBrake-Installationen..."
|
|
||||||
apt-get remove -y handbrake-cli handbrake 2>/dev/null || true
|
|
||||||
snap remove handbrake-cli 2>/dev/null || true
|
|
||||||
|
|
||||||
rm -f /usr/bin/HandBrakeCLI \
|
|
||||||
/snap/bin/handbrake-cli \
|
|
||||||
/snap/bin/HandBrakeCLI
|
|
||||||
|
|
||||||
if [[ -e /usr/local/bin/HandBrakeCLI ]] && ! handbrake_is_self_built "/usr/local/bin/HandBrakeCLI"; then
|
|
||||||
warn "Entferne fremdes /usr/local/bin/HandBrakeCLI"
|
|
||||||
rm -f /usr/local/bin/HandBrakeCLI
|
|
||||||
fi
|
|
||||||
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
ok "Bereinigung abgeschlossen"
|
|
||||||
}
|
|
||||||
|
|
||||||
remove_selfbuilt_handbrake() {
|
|
||||||
if [[ -e /usr/local/bin/HandBrakeCLI ]] && handbrake_is_self_built "/usr/local/bin/HandBrakeCLI"; then
|
|
||||||
warn "Entferne selbst gebautes /usr/local/bin/HandBrakeCLI"
|
|
||||||
rm -f /usr/local/bin/HandBrakeCLI
|
|
||||||
fi
|
|
||||||
rm -f "$HANDBRAKE_SELFBUILD_MARKER"
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
build_handbrake_nvdec() {
|
|
||||||
header "HandBrake ${HANDBRAKE_VERSION} mit NVDEC aus Quellcode bauen"
|
|
||||||
|
|
||||||
local cache_dir="/var/cache/ripster/handbrake"
|
|
||||||
local src_url="https://github.com/HandBrake/HandBrake/releases/download/${HANDBRAKE_VERSION}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2"
|
|
||||||
local tarball="${cache_dir}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2"
|
|
||||||
local src_dir="${cache_dir}/HandBrake-${HANDBRAKE_VERSION}"
|
|
||||||
|
|
||||||
if [[ -t 0 && -d "$cache_dir" ]]; then
|
|
||||||
local clear_cache_answer=""
|
|
||||||
warn "Build-Cache gefunden: $cache_dir"
|
|
||||||
warn "Wenn du ihn löschst, startet der Source-Build wieder komplett von vorne."
|
|
||||||
read -r -p "Build-Cache jetzt löschen (sudo rm -rf $cache_dir)? [y/N] " clear_cache_answer
|
|
||||||
case "${clear_cache_answer,,}" in
|
|
||||||
y|yes|j|ja)
|
|
||||||
rm -rf "$cache_dir"
|
|
||||||
info "Build-Cache gelöscht."
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
mkdir -p "$cache_dir"
|
|
||||||
|
|
||||||
# Build-Abhängigkeiten
|
|
||||||
info "Installiere Build-Abhängigkeiten..."
|
|
||||||
apt-get install -y \
|
|
||||||
autoconf automake build-essential clang cmake git \
|
|
||||||
libass-dev libbz2-dev libdvdnav-dev libdvdread-dev \
|
|
||||||
libfontconfig-dev libfreetype-dev libfribidi-dev libharfbuzz-dev \
|
|
||||||
libjansson-dev liblzma-dev libmp3lame-dev libnuma-dev libogg-dev \
|
|
||||||
libopus-dev libsamplerate0-dev libspeex-dev libtheora-dev libtool libtool-bin libva-dev \
|
|
||||||
libturbojpeg0-dev libvorbis-dev libvpx-dev libx264-dev libxml2-dev \
|
|
||||||
m4 meson nasm ninja-build patch pkg-config python3 tar yasm zlib1g-dev \
|
|
||||||
>/dev/null
|
|
||||||
|
|
||||||
# CUDA Toolkit für NVDEC-Header
|
|
||||||
info "Installiere CUDA Toolkit (für NVDEC-Header)..."
|
|
||||||
if ! dpkg -l 2>/dev/null | grep -q '^ii.*nvidia-cuda-toolkit'; then
|
|
||||||
apt-get install -y nvidia-cuda-toolkit >/dev/null 2>&1 || {
|
|
||||||
warn "nvidia-cuda-toolkit nicht verfügbar – versuche Fallback-Header..."
|
|
||||||
local cuda_keyring="/tmp/cuda-keyring.deb"
|
|
||||||
local ubuntu_ver="${VERSION_ID//./}"
|
|
||||||
curl -fsSL "https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${ubuntu_ver}/x86_64/cuda-keyring_1.1-1_all.deb" \
|
|
||||||
-o "$cuda_keyring" 2>/dev/null && \
|
|
||||||
dpkg -i "$cuda_keyring" 2>/dev/null && \
|
|
||||||
apt-get update -qq && \
|
|
||||||
apt-get install -y cuda-cudart-dev-12-8 >/dev/null 2>&1 || \
|
|
||||||
warn "CUDA-Header konnten nicht installiert werden – NVDEC wird möglicherweise nicht verfügbar sein."
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
ok "Build-Abhängigkeiten installiert"
|
|
||||||
|
|
||||||
if [[ ! -d "$src_dir" ]]; then
|
|
||||||
if [[ ! -f "$tarball" ]]; then
|
|
||||||
info "Lade HandBrake ${HANDBRAKE_VERSION} herunter..."
|
|
||||||
curl -fsSL "$src_url" -o "$tarball" 2>/dev/null || \
|
|
||||||
wget -q "$src_url" -O "$tarball" || \
|
|
||||||
fatal "HandBrake-Quellcode konnte nicht heruntergeladen werden (${src_url})"
|
|
||||||
else
|
|
||||||
info "Nutze vorhandenes HandBrake-Source-Archiv: $tarball"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Entpacke Quellcode..."
|
|
||||||
tar xjf "$tarball" -C "$cache_dir"
|
|
||||||
[[ -d "$src_dir" ]] || src_dir=$(find "$cache_dir" -maxdepth 1 -type d -name "HandBrake*" | head -1)
|
|
||||||
[[ -d "$src_dir" ]] || fatal "HandBrake-Quellverzeichnis nicht gefunden in $cache_dir"
|
|
||||||
else
|
|
||||||
info "Nutze vorhandenen HandBrake-Source-Baum: $src_dir"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$src_dir"
|
|
||||||
|
|
||||||
local configure_log="${src_dir}/build/configure-ripster.log"
|
|
||||||
local configure_stamp="${src_dir}/build/.ripster-config"
|
|
||||||
local configure_args="--enable-nvdec --disable-gtk --prefix=/usr/local"
|
|
||||||
local need_configure="false"
|
|
||||||
local configure_cmd=(
|
|
||||||
./configure
|
|
||||||
--launch-jobs="$(nproc)"
|
|
||||||
--enable-nvdec
|
|
||||||
--disable-gtk
|
|
||||||
--prefix=/usr/local
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ ! -f "$src_dir/build/GNUmakefile" ]]; then
|
|
||||||
need_configure="true"
|
|
||||||
elif [[ ! -f "$configure_stamp" ]]; then
|
|
||||||
need_configure="true"
|
|
||||||
elif ! grep -qx "args=${configure_args}" "$configure_stamp"; then
|
|
||||||
need_configure="true"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$need_configure" == "true" ]]; then
|
|
||||||
if [[ -d "$src_dir/build" ]]; then
|
|
||||||
configure_cmd=(./configure --force "${configure_cmd[@]:1}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f "$src_dir/build/GNUmakefile" ]]; then
|
|
||||||
info "Vorhandener Build gefunden – aktualisiere Konfiguration (CLI-only)."
|
|
||||||
else
|
|
||||||
info "Konfiguriere HandBrake mit NVDEC (CLI-only)..."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! "${configure_cmd[@]}" >"$configure_log" 2>&1; then
|
|
||||||
tail -n 50 "$configure_log" >&2 || true
|
|
||||||
fatal "HandBrake-Konfiguration fehlgeschlagen. Vollständiges Log: $configure_log"
|
|
||||||
fi
|
|
||||||
tail -n 10 "$configure_log"
|
|
||||||
|
|
||||||
mkdir -p "${src_dir}/build"
|
|
||||||
cat > "$configure_stamp" <<EOF
|
|
||||||
args=${configure_args}
|
|
||||||
version=${HANDBRAKE_VERSION}
|
|
||||||
configured_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "Vorhandene CLI-only-Konfiguration erkannt – überspringe configure."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -x "$src_dir/build/HandBrakeCLI" ]]; then
|
|
||||||
info "Vorhandenes HandBrakeCLI-Build-Artefakt gefunden – versuche direkte Installation."
|
|
||||||
if ! make --directory=build install; then
|
|
||||||
warn "Direkte Installation fehlgeschlagen – setze Build fort."
|
|
||||||
make --directory=build -j"$(nproc)"
|
|
||||||
make --directory=build install
|
|
||||||
fi
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
if ! command_exists HandBrakeCLI; then
|
|
||||||
warn "HandBrakeCLI nach direkter Installation nicht gefunden – setze Build fort."
|
|
||||||
make --directory=build -j"$(nproc)"
|
|
||||||
make --directory=build install
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
info "Baue HandBrake ($(nproc) Threads – bitte warten)..."
|
|
||||||
make --directory=build -j"$(nproc)"
|
|
||||||
info "Installiere HandBrake nach /usr/local/bin..."
|
|
||||||
make --directory=build install
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd /
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
|
|
||||||
if command_exists HandBrakeCLI; then
|
|
||||||
local ver
|
|
||||||
ver=$(HandBrakeCLI --version 2>&1 | head -1)
|
|
||||||
handbrake_has_nvdec || fatal "HandBrakeCLI ist installiert, aber ohne NVDEC-Unterstützung."
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$HANDBRAKE_SELFBUILD_MARKER")"
|
|
||||||
cat > "$HANDBRAKE_SELFBUILD_MARKER" <<EOF
|
|
||||||
version=${HANDBRAKE_VERSION}
|
|
||||||
source_dir=${src_dir}
|
|
||||||
binary_path=$(command -v HandBrakeCLI)
|
|
||||||
installed_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
EOF
|
|
||||||
|
|
||||||
ok "HandBrakeCLI mit NVDEC installiert: ${ver}"
|
|
||||||
if ldconfig -p 2>/dev/null | grep -q libnvcuvid; then
|
|
||||||
ok "libnvcuvid gefunden – NVDEC ist zur Laufzeit verfügbar."
|
|
||||||
else
|
|
||||||
warn "libnvcuvid NICHT gefunden. NVDEC benötigt den installierten NVIDIA-Treiber."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
fatal "HandBrakeCLI nach dem Build nicht gefunden – Build fehlgeschlagen."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
has_nvidia_gpu() {
|
|
||||||
[[ -e /dev/nvidia0 ]] && return 0
|
|
||||||
command_exists nvidia-smi && nvidia-smi &>/dev/null && return 0
|
|
||||||
command_exists lspci && lspci 2>/dev/null | grep -qi "nvidia" && return 0
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
install_handbrake() {
|
|
||||||
header "HandBrake CLI installieren"
|
|
||||||
|
|
||||||
local hb_path
|
|
||||||
local current_mode="none"
|
|
||||||
hb_path=$(command -v HandBrakeCLI 2>/dev/null || true)
|
|
||||||
if [[ -n "$hb_path" ]]; then
|
|
||||||
if handbrake_has_nvdec; then
|
|
||||||
current_mode="nvdec"
|
|
||||||
else
|
|
||||||
current_mode="standard"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$BUILD_HANDBRAKE_NVDEC" == true ]]; then
|
|
||||||
info "Installmodus: Source-Build mit NVDEC-Support"
|
|
||||||
if has_nvidia_gpu; then
|
|
||||||
info "NVIDIA-GPU erkannt – NVDEC-Build wird verwendet."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if handbrake_has_nvdec; then
|
|
||||||
if handbrake_is_self_built "$hb_path"; then
|
|
||||||
local installed_ver latest_ver answer
|
|
||||||
installed_ver=$(handbrake_installed_version || true)
|
|
||||||
latest_ver=$(handbrake_latest_git_version || true)
|
|
||||||
|
|
||||||
if [[ -n "$installed_ver" && -n "$latest_ver" ]] && dpkg --compare-versions "$latest_ver" gt "$installed_ver"; then
|
|
||||||
case "$HANDBRAKE_UPDATE_POLICY" in
|
|
||||||
keep)
|
|
||||||
warn "Neuere HandBrake-Version verfügbar (${latest_ver}, installiert: ${installed_ver}) – behalte aktuelle Installation."
|
|
||||||
return
|
|
||||||
;;
|
|
||||||
prompt)
|
|
||||||
if [[ -t 0 ]]; then
|
|
||||||
read -r -p "Neue HandBrake-Version ${latest_ver} verfügbar (installiert ${installed_ver}). Neu bauen? [y/N] " answer
|
|
||||||
case "${answer,,}" in
|
|
||||||
y|yes|j|ja)
|
|
||||||
info "Aktualisiere auf HandBrake ${latest_ver}."
|
|
||||||
HANDBRAKE_VERSION="$latest_ver"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
info "Behalte bestehende Installation (${installed_ver})."
|
|
||||||
return
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
warn "Neuere HandBrake-Version verfügbar (${latest_ver}), aber kein TTY für Rückfrage – behalte aktuelle Installation."
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
build)
|
|
||||||
info "Neuere HandBrake-Version verfügbar (${latest_ver}, installiert: ${installed_ver}) – aktualisiere."
|
|
||||||
HANDBRAKE_VERSION="$latest_ver"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
ok "Selbst gebautes HandBrakeCLI mit NVDEC bereits installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
warn "HandBrakeCLI mit NVDEC gefunden (${hb_path}), aber nicht als Selbst-Build erkannt."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$hb_path" ]] && ! handbrake_has_nvdec; then
|
|
||||||
warn "HandBrakeCLI ohne NVDEC gefunden (${hb_path}) – wird ersetzt durch Selbst-Build."
|
|
||||||
elif [[ -z "$hb_path" ]]; then
|
|
||||||
info "Kein HandBrakeCLI gefunden – baue aus Quellcode."
|
|
||||||
fi
|
|
||||||
|
|
||||||
remove_non_selfbuilt_handbrake
|
|
||||||
build_handbrake_nvdec
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Installmodus: Standard (APT, ohne NVDEC-Zwang)"
|
|
||||||
if [[ "$current_mode" == "standard" ]] && ! handbrake_is_self_built "$hb_path"; then
|
|
||||||
ok "HandBrakeCLI bereits installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$current_mode" == "nvdec" ]]; then
|
|
||||||
warn "Umschalten von NVDEC-Build auf Standard-Installation."
|
|
||||||
fi
|
|
||||||
|
|
||||||
remove_selfbuilt_handbrake
|
|
||||||
remove_non_selfbuilt_handbrake
|
|
||||||
|
|
||||||
info "Installiere HandBrakeCLI aus den Standard-Repositories..."
|
|
||||||
apt_update
|
|
||||||
apt-get install -y handbrake-cli >/dev/null
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
|
|
||||||
if command_exists HandBrakeCLI; then
|
|
||||||
ok "HandBrakeCLI installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command_exists handbrake-cli; then
|
|
||||||
ln -sf "$(command -v handbrake-cli)" /usr/local/bin/HandBrakeCLI
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
ok "HandBrakeCLI Alias angelegt auf: $(command -v handbrake-cli)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
fatal "HandBrake wurde installiert, aber kein CLI-Befehl wurde gefunden."
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- apt-Hilfsfunktionen ------------------------------------------------------
|
|
||||||
|
|
||||||
apt_update() {
|
|
||||||
local output
|
|
||||||
if output=$(apt-get update 2>&1); then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if echo "$output" | grep -q "no longer has a Release file\|does not have a Release file"; then
|
|
||||||
warn "apt-Sources fehlerhaft. Versuche Reparatur..."
|
|
||||||
|
|
||||||
if apt-get update --allow-releaseinfo-change -qq 2>/dev/null; then
|
|
||||||
ok "apt-Update mit --allow-releaseinfo-change erfolgreich"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${VERSION_CODENAME:-}" ]]; then
|
|
||||||
warn "Schreibe minimale sources.list für $VERSION_CODENAME..."
|
|
||||||
local main_list=/etc/apt/sources.list
|
|
||||||
cp "$main_list" "${main_list}.bak-$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
|
|
||||||
|
|
||||||
case "$ID" in
|
|
||||||
ubuntu)
|
|
||||||
cat > "$main_list" <<EOF
|
|
||||||
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME} main restricted universe multiverse
|
|
||||||
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-updates main restricted universe multiverse
|
|
||||||
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-security main restricted universe multiverse
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
debian)
|
|
||||||
cat > "$main_list" <<EOF
|
|
||||||
deb http://deb.debian.org/debian ${VERSION_CODENAME} main contrib non-free
|
|
||||||
deb http://deb.debian.org/debian ${VERSION_CODENAME}-updates main contrib non-free
|
|
||||||
deb http://security.debian.org/debian-security ${VERSION_CODENAME}-security main contrib non-free
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if apt-get update -qq 2>/dev/null; then
|
|
||||||
ok "apt-Update nach Sources-Reparatur erfolgreich"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
warn "Deaktiviere fehlerhafte Eintraege in /etc/apt/sources.list.d/ ..."
|
|
||||||
local broken_files
|
|
||||||
broken_files=$(apt-get update 2>&1 | grep -oP "(?<=The repository ').*?(?=' )" | \
|
|
||||||
xargs -I{} grep -rl "{}" /etc/apt/sources.list.d/ 2>/dev/null || true)
|
|
||||||
if [[ -n "$broken_files" ]]; then
|
|
||||||
echo "$broken_files" | while read -r f; do
|
|
||||||
warn "Deaktiviere: $f"
|
|
||||||
mv "$f" "${f}.disabled" 2>/dev/null || true
|
|
||||||
done
|
|
||||||
if apt-get update -qq 2>/dev/null; then
|
|
||||||
ok "apt-Update nach Deaktivierung fehlerhafter Sources erfolgreich"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
error "apt-Update fehlgeschlagen. Bitte Sources manuell pruefen:"
|
|
||||||
echo "$output"
|
|
||||||
fatal "Installation abgebrochen. Repariere /etc/apt/sources.list und starte erneut."
|
|
||||||
else
|
|
||||||
error "apt-Update fehlgeschlagen:"
|
|
||||||
echo "$output"
|
|
||||||
fatal "Installation abgebrochen."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Systemabhängigkeiten -----------------------------------------------------
|
|
||||||
header "Systemabhängigkeiten installieren"
|
|
||||||
|
|
||||||
info "Paketlisten aktualisieren..."
|
|
||||||
apt_update
|
|
||||||
|
|
||||||
info "Installiere Basispakete..."
|
|
||||||
apt-get install -y \
|
|
||||||
curl wget git \
|
|
||||||
mediainfo \
|
|
||||||
util-linux udev \
|
|
||||||
ca-certificates gnupg \
|
|
||||||
lsb-release
|
|
||||||
|
|
||||||
ok "Basispakete installiert"
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
install_node
|
|
||||||
|
|
||||||
# MakeMKV
|
|
||||||
if [[ "$SKIP_MAKEMKV" == false ]]; then
|
|
||||||
install_makemkv
|
|
||||||
else
|
|
||||||
warn "MakeMKV-Installation übersprungen (--no-makemkv)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# HandBrake
|
|
||||||
if [[ "$SKIP_HANDBRAKE" == false ]]; then
|
|
||||||
install_handbrake
|
|
||||||
else
|
|
||||||
warn "HandBrake-Installation übersprungen (--no-handbrake)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Nginx
|
|
||||||
if [[ "$SKIP_NGINX" == false ]]; then
|
|
||||||
if ! command_exists nginx; then
|
|
||||||
info "Installiere nginx..."
|
|
||||||
apt-get install -y nginx
|
|
||||||
fi
|
|
||||||
ok "nginx installiert"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Systembenutzer anlegen ---------------------------------------------------
|
|
||||||
header "Systembenutzer anlegen"
|
|
||||||
|
|
||||||
if id "$SERVICE_USER" &>/dev/null; then
|
|
||||||
ok "Benutzer '$SERVICE_USER' existiert bereits"
|
|
||||||
else
|
|
||||||
info "Lege Systembenutzer '$SERVICE_USER' an..."
|
|
||||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
|
|
||||||
ok "Benutzer '$SERVICE_USER' angelegt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
SERVICE_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
|
|
||||||
if [[ -z "$SERVICE_HOME" || "$SERVICE_HOME" == "/" || "$SERVICE_HOME" == "/nonexistent" ]]; then
|
|
||||||
SERVICE_HOME="/home/$SERVICE_USER"
|
|
||||||
fi
|
|
||||||
mkdir -p "$SERVICE_HOME"
|
|
||||||
chown "$SERVICE_USER:$SERVICE_USER" "$SERVICE_HOME" 2>/dev/null || true
|
|
||||||
chmod 755 "$SERVICE_HOME" 2>/dev/null || true
|
|
||||||
info "Service-Home für '$SERVICE_USER': $SERVICE_HOME"
|
|
||||||
|
|
||||||
# Optisches Laufwerk: Benutzer zur cdrom/optical-Gruppe hinzufügen
|
|
||||||
for grp in cdrom optical disk; do
|
|
||||||
if getent group "$grp" &>/dev/null; then
|
|
||||||
usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true
|
|
||||||
info "Benutzer '$SERVICE_USER' zur Gruppe '$grp' hinzugefügt"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Dateien kopieren ---------------------------------------------------------
|
|
||||||
header "Ripster-Dateien installieren"
|
|
||||||
|
|
||||||
if [[ -d "$INSTALL_DIR" && "$REINSTALL" == false ]]; then
|
|
||||||
fatal "Verzeichnis $INSTALL_DIR existiert bereits.\nVerwende --reinstall um zu überschreiben (Daten bleiben erhalten)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Bei Reinstall: Daten sichern
|
|
||||||
if [[ -d "$INSTALL_DIR/backend/data" ]]; then
|
|
||||||
info "Sichere vorhandene Datenbank..."
|
|
||||||
cp -a "$INSTALL_DIR/backend/data" "/tmp/ripster-data-backup-$(date +%Y%m%d%H%M%S)"
|
|
||||||
ok "Datenbank gesichert"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Kopiere Quellen nach $INSTALL_DIR..."
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
|
||||||
|
|
||||||
rsync -a --delete \
|
|
||||||
--exclude='.git' \
|
|
||||||
--exclude='node_modules' \
|
|
||||||
--exclude='backend/node_modules' \
|
|
||||||
--exclude='frontend/node_modules' \
|
|
||||||
--exclude='backend/data' \
|
|
||||||
--exclude='backend/logs' \
|
|
||||||
--exclude='frontend/dist' \
|
|
||||||
--exclude='*.sh' \
|
|
||||||
--exclude='deploy-ripster.sh' \
|
|
||||||
--exclude='debug/' \
|
|
||||||
--exclude='site/' \
|
|
||||||
--exclude='docs/' \
|
|
||||||
"$SOURCE_DIR/" "$INSTALL_DIR/"
|
|
||||||
|
|
||||||
# Datenbank-/Log-Verzeichnisse anlegen
|
|
||||||
mkdir -p "$INSTALL_DIR/backend/data"
|
|
||||||
mkdir -p "$INSTALL_DIR/backend/logs"
|
|
||||||
|
|
||||||
# Bei Reinstall: Daten wiederherstellen
|
|
||||||
if [[ -d "$INSTALL_DIR/../ripster-data-backup" ]]; then
|
|
||||||
cp -a /tmp/ripster-data-backup-*/ "$INSTALL_DIR/backend/data/" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
ok "Dateien kopiert"
|
|
||||||
|
|
||||||
# --- npm-Abhängigkeiten installieren -----------------------------------------
|
|
||||||
header "npm-Abhängigkeiten installieren"
|
|
||||||
|
|
||||||
info "Installiere Root-Abhängigkeiten..."
|
|
||||||
npm install --prefix "$INSTALL_DIR" --omit=dev --silent
|
|
||||||
|
|
||||||
info "Installiere Backend-Abhängigkeiten..."
|
|
||||||
npm install --prefix "$INSTALL_DIR/backend" --omit=dev --silent
|
|
||||||
|
|
||||||
info "Installiere Frontend-Abhängigkeiten..."
|
|
||||||
npm install --prefix "$INSTALL_DIR/frontend" --silent
|
|
||||||
|
|
||||||
ok "npm-Abhängigkeiten installiert"
|
|
||||||
|
|
||||||
# --- Frontend bauen -----------------------------------------------------------
|
|
||||||
header "Frontend bauen"
|
|
||||||
|
|
||||||
info "Baue Frontend für $FRONTEND_HOST..."
|
|
||||||
|
|
||||||
# Relative URLs verwenden – funktioniert mit jedem Hostnamen/Domain, da nginx
|
|
||||||
# /api/ und /ws auf dem selben Host proxied. Absolute IP-URLs würden Chromes
|
|
||||||
# Private Network Access (PNA) Policy verletzen, wenn das Frontend über einen
|
|
||||||
# Domainnamen aufgerufen wird.
|
|
||||||
rm -f "$INSTALL_DIR/frontend/.env.production.local"
|
|
||||||
|
|
||||||
npm run build --prefix "$INSTALL_DIR/frontend" --silent
|
|
||||||
ok "Frontend gebaut: $INSTALL_DIR/frontend/dist"
|
|
||||||
|
|
||||||
# --- Backend-Konfiguration ---------------------------------------------------
|
|
||||||
header "Backend konfigurieren"
|
|
||||||
|
|
||||||
ENV_FILE="$INSTALL_DIR/backend/.env"
|
|
||||||
|
|
||||||
if [[ -f "$ENV_FILE" && "$REINSTALL" == false ]]; then
|
|
||||||
warn "Backend .env existiert bereits – wird nicht überschrieben"
|
|
||||||
else
|
|
||||||
info "Erstelle Backend .env..."
|
|
||||||
cat > "$ENV_FILE" <<EOF
|
|
||||||
# Ripster Backend – Konfiguration
|
|
||||||
# Generiert von install.sh am $(date)
|
|
||||||
|
|
||||||
PORT=${BACKEND_PORT}
|
|
||||||
DB_PATH=./data/ripster.db
|
|
||||||
LOG_DIR=./logs
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# CORS: Erlaube Anfragen vom Frontend (nginx)
|
|
||||||
CORS_ORIGIN=http://${FRONTEND_HOST}
|
|
||||||
EOF
|
|
||||||
ok "Backend .env erstellt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Berechtigungen setzen ---------------------------------------------------
|
|
||||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
|
|
||||||
chmod -R 755 "$INSTALL_DIR"
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
|
|
||||||
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
|
|
||||||
MAKEMKV_SERVICE_DIR="${SERVICE_HOME}/.MakeMKV"
|
|
||||||
if [[ ! -d "$MAKEMKV_SERVICE_DIR" ]]; then
|
|
||||||
mkdir -p "$MAKEMKV_SERVICE_DIR"
|
|
||||||
ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_SERVICE_DIR"
|
|
||||||
else
|
|
||||||
info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_SERVICE_DIR"
|
|
||||||
fi
|
|
||||||
chown "$SERVICE_USER:$SERVICE_USER" "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
|
||||||
chmod 700 "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
|
||||||
|
|
||||||
# --- Systemd-Dienst: Backend -------------------------------------------------
|
|
||||||
header "Systemd-Dienst (Backend) erstellen"
|
|
||||||
|
|
||||||
cat > /etc/systemd/system/ripster-backend.service <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=Ripster Backend API
|
|
||||||
Documentation=https://github.com/your-repo/ripster
|
|
||||||
After=network.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=${SERVICE_USER}
|
|
||||||
Group=${SERVICE_USER}
|
|
||||||
WorkingDirectory=${INSTALL_DIR}/backend
|
|
||||||
ExecStart=$(command -v node) src/index.js
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
StartLimitIntervalSec=60
|
|
||||||
StartLimitBurst=3
|
|
||||||
|
|
||||||
# Umgebung
|
|
||||||
Environment=NODE_ENV=production
|
|
||||||
Environment=HOME=${SERVICE_HOME}
|
|
||||||
Environment=LANG=C.UTF-8
|
|
||||||
Environment=LC_ALL=C.UTF-8
|
|
||||||
Environment=LANGUAGE=C.UTF-8
|
|
||||||
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=ripster-backend
|
|
||||||
|
|
||||||
# Sicherheit
|
|
||||||
# Für Skriptausführung via GUI (inkl. optionalem sudo in User-Skripten)
|
|
||||||
# darf no_new_privileges nicht aktiv sein.
|
|
||||||
NoNewPrivileges=false
|
|
||||||
ProtectSystem=full
|
|
||||||
ProtectHome=read-only
|
|
||||||
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
ok "ripster-backend.service erstellt"
|
|
||||||
|
|
||||||
# --- nginx konfigurieren -----------------------------------------------------
|
|
||||||
if [[ "$SKIP_NGINX" == false ]]; then
|
|
||||||
header "nginx konfigurieren"
|
|
||||||
|
|
||||||
cat > /etc/nginx/sites-available/ripster <<EOF
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name ${FRONTEND_HOST} _;
|
|
||||||
|
|
||||||
# Frontend (statische Dateien)
|
|
||||||
root ${INSTALL_DIR}/frontend/dist;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# SPA: alle unbekannten Pfade → index.html
|
|
||||||
location / {
|
|
||||||
try_files \$uri \$uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# API → Backend
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://127.0.0.1:${BACKEND_PORT};
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host \$host;
|
|
||||||
proxy_set_header X-Real-IP \$remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# WebSocket → Backend
|
|
||||||
location /ws {
|
|
||||||
proxy_pass http://127.0.0.1:${BACKEND_PORT}/ws;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade \$http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host \$host;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Alte Default-Seite deaktivieren, Ripster aktivieren
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
|
||||||
ln -sf /etc/nginx/sites-available/ripster /etc/nginx/sites-enabled/ripster
|
|
||||||
|
|
||||||
nginx -t && ok "nginx-Konfiguration gültig" || fatal "nginx-Konfiguration fehlerhaft!"
|
|
||||||
|
|
||||||
ok "nginx konfiguriert"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Dienste aktivieren und starten ------------------------------------------
|
|
||||||
header "Dienste starten"
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
|
|
||||||
systemctl enable ripster-backend
|
|
||||||
systemctl restart ripster-backend
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
if systemctl is-active --quiet ripster-backend; then
|
|
||||||
ok "ripster-backend läuft"
|
|
||||||
else
|
|
||||||
error "ripster-backend konnte nicht gestartet werden!"
|
|
||||||
journalctl -u ripster-backend -n 30 --no-pager
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$SKIP_NGINX" == false ]]; then
|
|
||||||
systemctl enable nginx
|
|
||||||
systemctl restart nginx
|
|
||||||
if systemctl is-active --quiet nginx; then
|
|
||||||
ok "nginx läuft"
|
|
||||||
else
|
|
||||||
error "nginx konnte nicht gestartet werden!"
|
|
||||||
journalctl -u nginx -n 20 --no-pager
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Zusammenfassung ----------------------------------------------------------
|
|
||||||
header "Installation abgeschlossen!"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e " ${GREEN}${BOLD}Ripster ist installiert und läuft.${RESET}"
|
|
||||||
echo ""
|
|
||||||
if [[ "$SKIP_NGINX" == false ]]; then
|
|
||||||
echo -e " ${BOLD}Weboberfläche:${RESET} http://${FRONTEND_HOST}"
|
|
||||||
else
|
|
||||||
echo -e " ${BOLD}Backend API:${RESET} http://${FRONTEND_HOST}:${BACKEND_PORT}/api"
|
|
||||||
warn "nginx deaktiviert – Frontend nicht automatisch erreichbar."
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}Dienste verwalten:${RESET}"
|
|
||||||
echo -e " sudo systemctl status ripster-backend"
|
|
||||||
echo -e " sudo systemctl restart ripster-backend"
|
|
||||||
echo -e " sudo systemctl stop ripster-backend"
|
|
||||||
echo -e " sudo journalctl -u ripster-backend -f"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}Konfiguration:${RESET} $INSTALL_DIR/backend/.env"
|
|
||||||
echo -e " ${BOLD}Datenbank:${RESET} $INSTALL_DIR/backend/data/ripster.db"
|
|
||||||
echo -e " ${BOLD}Logs:${RESET} $INSTALL_DIR/backend/logs/"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Warnungen zu fehlenden Tools
|
|
||||||
missing_tools=()
|
|
||||||
command_exists makemkvcon || missing_tools+=("makemkvcon")
|
|
||||||
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
|
|
||||||
command_exists mediainfo || missing_tools+=("mediainfo")
|
|
||||||
|
|
||||||
if [[ ${#missing_tools[@]} -gt 0 ]]; then
|
|
||||||
echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:"
|
|
||||||
for t in "${missing_tools[@]}"; do
|
|
||||||
echo -e " ${YELLOW}✗${RESET} $t"
|
|
||||||
done
|
|
||||||
echo -e " Diese können in den Ripster-Einstellungen konfiguriert werden."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
723
install.sh
723
install.sh
@@ -1,723 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# =============================================================================
|
|
||||||
# Ripster – Installationsskript (Git)
|
|
||||||
# Unterstützt: Debian 11/12, Ubuntu 22.04/24.04
|
|
||||||
# Benötigt: sudo / root, Internetzugang
|
|
||||||
#
|
|
||||||
# Verwendung:
|
|
||||||
# curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
|
||||||
# oder:
|
|
||||||
# wget -qO- https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
|
||||||
#
|
|
||||||
# Mit Optionen (nur via Datei möglich):
|
|
||||||
# sudo bash install.sh [Optionen]
|
|
||||||
#
|
|
||||||
# Optionen:
|
|
||||||
# --branch <branch> Git-Branch (Standard: main)
|
|
||||||
# --dir <pfad> Installationsverzeichnis (Standard: /opt/ripster)
|
|
||||||
# --user <benutzer> Systembenutzer für den Dienst (Standard: ripster)
|
|
||||||
# --port <port> Backend-Port (Standard: 3001)
|
|
||||||
# --host <hostname> Hostname/IP für die Weboberfläche (Standard: Maschinen-IP)
|
|
||||||
# --no-makemkv MakeMKV-Installation überspringen
|
|
||||||
# --no-handbrake HandBrake-Installation überspringen
|
|
||||||
# --no-nginx Nginx-Einrichtung überspringen
|
|
||||||
# --reinstall Vorhandene Installation aktualisieren (Daten bleiben erhalten)
|
|
||||||
# -h, --help Diese Hilfe anzeigen
|
|
||||||
# =============================================================================
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_URL="https://github.com/Mboehmlaender/ripster.git"
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)"
|
|
||||||
BUNDLED_HANDBRAKE_CLI="${SCRIPT_DIR}/bin/HandBrakeCLI"
|
|
||||||
|
|
||||||
# --- Farben -------------------------------------------------------------------
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m'
|
|
||||||
|
|
||||||
info() { echo -e "${BLUE}[INFO]${RESET} $*"; }
|
|
||||||
ok() { echo -e "${GREEN}[OK]${RESET} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
|
|
||||||
error() { echo -e "${RED}[FEHLER]${RESET} $*" >&2; }
|
|
||||||
header() { echo -e "\n${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; \
|
|
||||||
echo -e "${BOLD} $*${RESET}"; \
|
|
||||||
echo -e "${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; }
|
|
||||||
fatal() { error "$*"; exit 1; }
|
|
||||||
|
|
||||||
# --- Standard-Optionen --------------------------------------------------------
|
|
||||||
GIT_BRANCH="cd-ripping"
|
|
||||||
INSTALL_DIR="/opt/ripster"
|
|
||||||
SERVICE_USER="ripster"
|
|
||||||
BACKEND_PORT="3001"
|
|
||||||
FRONTEND_HOST=""
|
|
||||||
SKIP_MAKEMKV=false
|
|
||||||
SKIP_HANDBRAKE=false
|
|
||||||
HANDBRAKE_INSTALL_MODE=""
|
|
||||||
SKIP_NGINX=false
|
|
||||||
REINSTALL=false
|
|
||||||
|
|
||||||
# --- Argumente parsen ---------------------------------------------------------
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--branch) GIT_BRANCH="$2"; shift 2 ;;
|
|
||||||
--dir) INSTALL_DIR="$2"; shift 2 ;;
|
|
||||||
--user) SERVICE_USER="$2"; shift 2 ;;
|
|
||||||
--port) BACKEND_PORT="$2"; shift 2 ;;
|
|
||||||
--host) FRONTEND_HOST="$2"; shift 2 ;;
|
|
||||||
--no-makemkv) SKIP_MAKEMKV=true; shift ;;
|
|
||||||
--no-handbrake) SKIP_HANDBRAKE=true; shift ;;
|
|
||||||
--no-nginx) SKIP_NGINX=true; shift ;;
|
|
||||||
--reinstall) REINSTALL=true; shift ;;
|
|
||||||
-h|--help)
|
|
||||||
sed -n '/^# Verwendung/,/^# ====/p' "$0" | head -n -1 | sed 's/^# \?//'
|
|
||||||
exit 0 ;;
|
|
||||||
*) fatal "Unbekannte Option: $1" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Voraussetzungen prüfen ---------------------------------------------------
|
|
||||||
header "Ripster Installationsskript (Git)"
|
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
|
||||||
fatal "Dieses Skript muss als root ausgeführt werden (sudo bash install.sh)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -f /etc/os-release ]]; then
|
|
||||||
fatal "Betriebssystem nicht erkennbar. Nur Debian/Ubuntu wird unterstützt."
|
|
||||||
fi
|
|
||||||
. /etc/os-release
|
|
||||||
case "$ID" in
|
|
||||||
debian|ubuntu|linuxmint|pop) ok "Betriebssystem: $PRETTY_NAME" ;;
|
|
||||||
*) fatal "Nicht unterstütztes OS: $ID. Nur Debian/Ubuntu unterstützt." ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [[ -z "$FRONTEND_HOST" ]]; then
|
|
||||||
FRONTEND_HOST=$(hostname -I | awk '{print $1}')
|
|
||||||
info "Erkannte IP: $FRONTEND_HOST"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Repository: $REPO_URL"
|
|
||||||
info "Branch: $GIT_BRANCH"
|
|
||||||
info "Installationsverzeichnis: $INSTALL_DIR"
|
|
||||||
info "Systembenutzer: $SERVICE_USER"
|
|
||||||
info "Backend-Port: $BACKEND_PORT"
|
|
||||||
info "Frontend-Host: $FRONTEND_HOST"
|
|
||||||
|
|
||||||
# --- Hilfsfunktionen ----------------------------------------------------------
|
|
||||||
|
|
||||||
command_exists() { command -v "$1" &>/dev/null; }
|
|
||||||
|
|
||||||
install_node() {
|
|
||||||
header "Node.js installieren"
|
|
||||||
local required_major=20
|
|
||||||
|
|
||||||
if command_exists node; then
|
|
||||||
local current_major
|
|
||||||
current_major=$(node -e "process.stdout.write(String(process.version.split('.')[0].replace('v','')))")
|
|
||||||
if [[ "$current_major" -ge "$required_major" ]]; then
|
|
||||||
ok "Node.js $(node --version) bereits installiert"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
warn "Node.js $(node --version) zu alt – Node.js 20 wird installiert"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Installiere Node.js 20.x über NodeSource..."
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
|
||||||
apt-get install -y nodejs
|
|
||||||
ok "Node.js $(node --version) installiert"
|
|
||||||
}
|
|
||||||
|
|
||||||
install_makemkv() {
|
|
||||||
header "MakeMKV installieren"
|
|
||||||
|
|
||||||
if command_exists makemkvcon; then
|
|
||||||
ok "makemkvcon bereits installiert ($(makemkvcon --version 2>&1 | head -1))"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Installiere Build-Abhängigkeiten für MakeMKV..."
|
|
||||||
apt-get install -y \
|
|
||||||
build-essential pkg-config libc6-dev libssl-dev \
|
|
||||||
libexpat1-dev libavcodec-dev libgl1-mesa-dev \
|
|
||||||
qtbase5-dev zlib1g-dev wget
|
|
||||||
|
|
||||||
# Aktuelle Version aus dem offiziellen Linux-Forum-Thread ermitteln.
|
|
||||||
# Der Titel lautet immer: "MakeMKV X.Y.Z for Linux is available"
|
|
||||||
local makemkv_fallback="1.18.3"
|
|
||||||
info "Ermittle aktuelle MakeMKV-Version (forum.makemkv.com)..."
|
|
||||||
local makemkv_version
|
|
||||||
makemkv_version=$(curl -s --max-time 15 \
|
|
||||||
"https://forum.makemkv.com/forum/viewtopic.php?f=3&t=224" \
|
|
||||||
| grep -oP 'MakeMKV \K[0-9]+\.[0-9]+\.[0-9]+(?= for Linux)' | head -1 || true)
|
|
||||||
|
|
||||||
if [[ -z "$makemkv_version" ]]; then
|
|
||||||
warn "MakeMKV-Version konnte nicht ermittelt werden – verwende Fallback $makemkv_fallback"
|
|
||||||
makemkv_version="$makemkv_fallback"
|
|
||||||
else
|
|
||||||
info "Aktuelle Version: $makemkv_version"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Baue MakeMKV $makemkv_version..."
|
|
||||||
local tmp_dir
|
|
||||||
tmp_dir=$(mktemp -d)
|
|
||||||
cd "$tmp_dir"
|
|
||||||
|
|
||||||
local base_url="https://www.makemkv.com/download"
|
|
||||||
wget -q "${base_url}/makemkv-bin-${makemkv_version}.tar.gz"
|
|
||||||
wget -q "${base_url}/makemkv-oss-${makemkv_version}.tar.gz"
|
|
||||||
|
|
||||||
tar xf "makemkv-oss-${makemkv_version}.tar.gz"
|
|
||||||
cd "makemkv-oss-${makemkv_version}"
|
|
||||||
./configure
|
|
||||||
make -j"$(nproc)"
|
|
||||||
make install
|
|
||||||
|
|
||||||
cd "$tmp_dir"
|
|
||||||
tar xf "makemkv-bin-${makemkv_version}.tar.gz"
|
|
||||||
cd "makemkv-bin-${makemkv_version}"
|
|
||||||
mkdir -p tmp && echo "accepted" > tmp/eula_accepted
|
|
||||||
make -j"$(nproc)"
|
|
||||||
make install
|
|
||||||
|
|
||||||
cd /
|
|
||||||
rm -rf "$tmp_dir"
|
|
||||||
ok "MakeMKV $makemkv_version installiert"
|
|
||||||
warn "Hinweis: MakeMKV benötigt eine Lizenz oder den Beta-Key."
|
|
||||||
warn "Beta-Key: https://www.makemkv.com/forum/viewtopic.php?t=1053"
|
|
||||||
}
|
|
||||||
|
|
||||||
select_handbrake_mode() {
|
|
||||||
[[ "$SKIP_HANDBRAKE" == true ]] && return
|
|
||||||
|
|
||||||
local mode_answer=""
|
|
||||||
echo ""
|
|
||||||
echo "Install HandBrake:"
|
|
||||||
echo ""
|
|
||||||
echo "1. Standard version (apt install handbrake-cli)"
|
|
||||||
echo "2. GPU version with NVDEC (use bundled binary)"
|
|
||||||
|
|
||||||
if [[ -t 0 ]]; then
|
|
||||||
read -r -p "Select option [1/2]: " mode_answer
|
|
||||||
elif [[ -r /dev/tty ]]; then
|
|
||||||
read -r -p "Select option [1/2]: " mode_answer </dev/tty
|
|
||||||
else
|
|
||||||
HANDBRAKE_INSTALL_MODE="standard"
|
|
||||||
warn "Kein interaktives Terminal erkannt – verwende Standardversion (apt)."
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$mode_answer" in
|
|
||||||
2) HANDBRAKE_INSTALL_MODE="gpu" ;;
|
|
||||||
1|"") HANDBRAKE_INSTALL_MODE="standard" ;;
|
|
||||||
*) warn "Ungültige Auswahl '$mode_answer' – verwende Standardversion."; HANDBRAKE_INSTALL_MODE="standard" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
install_handbrake_standard() {
|
|
||||||
info "Installiere HandBrakeCLI aus den Standard-Repositories..."
|
|
||||||
info "Aktualisiere Paketlisten..."
|
|
||||||
apt_update
|
|
||||||
apt-get install -y handbrake-cli
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
|
|
||||||
if command_exists HandBrakeCLI; then
|
|
||||||
ok "HandBrakeCLI installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command_exists handbrake-cli; then
|
|
||||||
ok "handbrake-cli installiert: $(handbrake-cli --version 2>&1 | head -1)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
fatal "HandBrake wurde installiert, aber kein CLI-Befehl wurde gefunden."
|
|
||||||
}
|
|
||||||
|
|
||||||
install_handbrake_gpu_bundled() {
|
|
||||||
info "Installiere gebündeltes HandBrakeCLI mit NVDEC..."
|
|
||||||
|
|
||||||
if [[ ! -f "$BUNDLED_HANDBRAKE_CLI" ]]; then
|
|
||||||
fatal "Bundled Binary fehlt: ./bin/HandBrakeCLI (aufgelöst zu: $BUNDLED_HANDBRAKE_CLI)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -m 0755 "$BUNDLED_HANDBRAKE_CLI" /usr/local/bin/HandBrakeCLI
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
|
|
||||||
ok "Bundled HandBrakeCLI installiert nach /usr/local/bin/HandBrakeCLI"
|
|
||||||
if command_exists HandBrakeCLI; then
|
|
||||||
ok "HandBrakeCLI Version: $(HandBrakeCLI --version 2>&1 | head -1)"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
install_handbrake() {
|
|
||||||
header "HandBrake CLI installieren"
|
|
||||||
|
|
||||||
if [[ -z "$HANDBRAKE_INSTALL_MODE" ]]; then
|
|
||||||
HANDBRAKE_INSTALL_MODE="standard"
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$HANDBRAKE_INSTALL_MODE" in
|
|
||||||
standard) install_handbrake_standard ;;
|
|
||||||
gpu) install_handbrake_gpu_bundled ;;
|
|
||||||
*) fatal "Unbekannter HandBrake-Modus: $HANDBRAKE_INSTALL_MODE" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- apt-Hilfsfunktionen ------------------------------------------------------
|
|
||||||
|
|
||||||
# Führt apt-get update aus. Bei Release-Fehlern wird versucht, die Sources zu
|
|
||||||
# reparieren (Proxmox-Container, veraltete Spiegelserver, etc.).
|
|
||||||
apt_update() {
|
|
||||||
local output
|
|
||||||
if output=$(apt-get update 2>&1); then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Release-Datei fehlt → versuche Repair
|
|
||||||
if echo "$output" | grep -q "no longer has a Release file\|does not have a Release file"; then
|
|
||||||
warn "apt-Sources fehlerhaft. Versuche Reparatur..."
|
|
||||||
|
|
||||||
# Strategie 1: --allow-releaseinfo-change
|
|
||||||
if apt-get update --allow-releaseinfo-change -qq 2>/dev/null; then
|
|
||||||
ok "apt-Update mit --allow-releaseinfo-change erfolgreich"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Strategie 2: Kaputte Einträge aus sources.list.d entfernen und Fallback
|
|
||||||
# auf offizielle Spiegel schreiben
|
|
||||||
if [[ -n "${VERSION_CODENAME:-}" ]]; then
|
|
||||||
warn "Schreibe minimale sources.list für $VERSION_CODENAME..."
|
|
||||||
local main_list=/etc/apt/sources.list
|
|
||||||
|
|
||||||
# Backup
|
|
||||||
cp "$main_list" "${main_list}.bak-$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
|
|
||||||
|
|
||||||
case "$ID" in
|
|
||||||
ubuntu)
|
|
||||||
cat > "$main_list" <<EOF
|
|
||||||
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME} main restricted universe multiverse
|
|
||||||
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-updates main restricted universe multiverse
|
|
||||||
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-security main restricted universe multiverse
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
debian)
|
|
||||||
cat > "$main_list" <<EOF
|
|
||||||
deb http://deb.debian.org/debian ${VERSION_CODENAME} main contrib non-free
|
|
||||||
deb http://deb.debian.org/debian ${VERSION_CODENAME}-updates main contrib non-free
|
|
||||||
deb http://security.debian.org/debian-security ${VERSION_CODENAME}-security main contrib non-free
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if apt-get update -qq 2>/dev/null; then
|
|
||||||
ok "apt-Update nach Sources-Reparatur erfolgreich"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Strategie 3: Kaputte .list-Dateien in sources.list.d deaktivieren
|
|
||||||
warn "Deaktiviere fehlerhafte Eintraege in /etc/apt/sources.list.d/ ..."
|
|
||||||
local broken_files
|
|
||||||
broken_files=$(apt-get update 2>&1 | grep -oP "(?<=The repository ').*?(?=' )" | \
|
|
||||||
xargs -I{} grep -rl "{}" /etc/apt/sources.list.d/ 2>/dev/null || true)
|
|
||||||
if [[ -n "$broken_files" ]]; then
|
|
||||||
echo "$broken_files" | while read -r f; do
|
|
||||||
warn "Deaktiviere: $f"
|
|
||||||
mv "$f" "${f}.disabled" 2>/dev/null || true
|
|
||||||
done
|
|
||||||
if apt-get update -qq 2>/dev/null; then
|
|
||||||
ok "apt-Update nach Deaktivierung fehlerhafter Sources erfolgreich"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
error "apt-Update fehlgeschlagen. Bitte Sources manuell pruefen:"
|
|
||||||
echo "$output"
|
|
||||||
fatal "Installation abgebrochen. Repariere /etc/apt/sources.list und starte erneut."
|
|
||||||
else
|
|
||||||
error "apt-Update fehlgeschlagen:"
|
|
||||||
echo "$output"
|
|
||||||
fatal "Installation abgebrochen."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- HandBrake-Installmodus auswählen ----------------------------------------
|
|
||||||
select_handbrake_mode
|
|
||||||
|
|
||||||
# --- Systemabhängigkeiten -----------------------------------------------------
|
|
||||||
header "Systemabhängigkeiten installieren"
|
|
||||||
|
|
||||||
info "Paketlisten aktualisieren..."
|
|
||||||
apt_update
|
|
||||||
|
|
||||||
info "Installiere Basispakete..."
|
|
||||||
apt-get install -y \
|
|
||||||
curl wget git \
|
|
||||||
mediainfo \
|
|
||||||
util-linux udev \
|
|
||||||
ca-certificates gnupg \
|
|
||||||
lsb-release
|
|
||||||
|
|
||||||
ok "Basispakete installiert"
|
|
||||||
|
|
||||||
info "Installiere CD-Ripping-Tools..."
|
|
||||||
apt-get install -y \
|
|
||||||
cdparanoia \
|
|
||||||
flac \
|
|
||||||
lame \
|
|
||||||
opus-tools \
|
|
||||||
vorbis-tools
|
|
||||||
|
|
||||||
ok "CD-Ripping-Tools installiert (cdparanoia, flac, lame, opus-tools, vorbis-tools)"
|
|
||||||
|
|
||||||
install_node
|
|
||||||
|
|
||||||
if [[ "$SKIP_MAKEMKV" == false ]]; then
|
|
||||||
install_makemkv
|
|
||||||
else
|
|
||||||
warn "MakeMKV-Installation übersprungen (--no-makemkv)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$SKIP_HANDBRAKE" == false ]]; then
|
|
||||||
install_handbrake
|
|
||||||
else
|
|
||||||
warn "HandBrake-Installation übersprungen (--no-handbrake)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$SKIP_NGINX" == false ]]; then
|
|
||||||
if ! command_exists nginx; then
|
|
||||||
info "Installiere nginx..."
|
|
||||||
apt-get install -y nginx
|
|
||||||
fi
|
|
||||||
ok "nginx installiert"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Systembenutzer anlegen ---------------------------------------------------
|
|
||||||
header "Systembenutzer anlegen"
|
|
||||||
|
|
||||||
if id "$SERVICE_USER" &>/dev/null; then
|
|
||||||
ok "Benutzer '$SERVICE_USER' existiert bereits"
|
|
||||||
else
|
|
||||||
info "Lege Systembenutzer '$SERVICE_USER' an..."
|
|
||||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
|
|
||||||
ok "Benutzer '$SERVICE_USER' angelegt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
SERVICE_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
|
|
||||||
if [[ -z "$SERVICE_HOME" || "$SERVICE_HOME" == "/" || "$SERVICE_HOME" == "/nonexistent" ]]; then
|
|
||||||
SERVICE_HOME="/home/$SERVICE_USER"
|
|
||||||
fi
|
|
||||||
mkdir -p "$SERVICE_HOME"
|
|
||||||
chown "$SERVICE_USER:$SERVICE_USER" "$SERVICE_HOME" 2>/dev/null || true
|
|
||||||
chmod 755 "$SERVICE_HOME" 2>/dev/null || true
|
|
||||||
info "Service-Home für '$SERVICE_USER': $SERVICE_HOME"
|
|
||||||
|
|
||||||
for grp in cdrom optical disk video render; do
|
|
||||||
if getent group "$grp" &>/dev/null; then
|
|
||||||
usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true
|
|
||||||
info "Benutzer '$SERVICE_USER' zur Gruppe '$grp' hinzugefügt"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Repository klonen / aktualisieren ----------------------------------------
|
|
||||||
header "Repository holen (Git)"
|
|
||||||
|
|
||||||
# Prüfen ob der gewünschte Branch auf dem Remote existiert
|
|
||||||
info "Prüfe Branch '$GIT_BRANCH' auf Remote..."
|
|
||||||
if ! git ls-remote --exit-code --heads "$REPO_URL" "$GIT_BRANCH" &>/dev/null; then
|
|
||||||
fatal "Branch '$GIT_BRANCH' existiert nicht im Repository $REPO_URL.\nVerfügbare Branches: $(git ls-remote --heads "$REPO_URL" | awk '{print $2}' | sed 's|refs/heads/||' | tr '\n' ' ')"
|
|
||||||
fi
|
|
||||||
ok "Branch '$GIT_BRANCH' gefunden"
|
|
||||||
|
|
||||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
|
||||||
if [[ "$REINSTALL" == true ]]; then
|
|
||||||
info "Aktualisiere bestehendes Repository..."
|
|
||||||
# Daten sichern
|
|
||||||
if [[ -d "$INSTALL_DIR/backend/data" ]]; then
|
|
||||||
DATA_BACKUP="/tmp/ripster-data-backup-$(date +%Y%m%d%H%M%S)"
|
|
||||||
cp -a "$INSTALL_DIR/backend/data" "$DATA_BACKUP"
|
|
||||||
info "Datenbank gesichert nach: $DATA_BACKUP"
|
|
||||||
fi
|
|
||||||
# safe.directory nötig wenn das Verzeichnis einem anderen User gehört
|
|
||||||
# (z.B. ripster-Serviceuser nach erstem Install)
|
|
||||||
git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
|
|
||||||
git -C "$INSTALL_DIR" remote set-branches origin '*'
|
|
||||||
git -C "$INSTALL_DIR" fetch --quiet origin
|
|
||||||
git -C "$INSTALL_DIR" reset --hard HEAD
|
|
||||||
git -C "$INSTALL_DIR" checkout --quiet -B "$GIT_BRANCH" "origin/$GIT_BRANCH"
|
|
||||||
git -C "$INSTALL_DIR" reset --hard "origin/$GIT_BRANCH"
|
|
||||||
ok "Repository aktualisiert auf Branch '$GIT_BRANCH'"
|
|
||||||
else
|
|
||||||
fatal "$INSTALL_DIR enthält bereits ein Git-Repository.\nVerwende --reinstall um zu aktualisieren."
|
|
||||||
fi
|
|
||||||
elif [[ -d "$INSTALL_DIR" && "$REINSTALL" == false ]]; then
|
|
||||||
fatal "Verzeichnis $INSTALL_DIR existiert bereits (kein Git-Repo).\nBitte manuell entfernen oder --reinstall verwenden."
|
|
||||||
else
|
|
||||||
info "Klone $REPO_URL (Branch: $GIT_BRANCH)..."
|
|
||||||
git clone --quiet --branch "$GIT_BRANCH" --depth 1 "$REPO_URL" "$INSTALL_DIR"
|
|
||||||
ok "Repository geklont nach $INSTALL_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Daten- und Log-Verzeichnisse sicherstellen
|
|
||||||
mkdir -p "$INSTALL_DIR/backend/data"
|
|
||||||
mkdir -p "$INSTALL_DIR/backend/logs"
|
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
|
||||||
|
|
||||||
# Gesicherte Daten zurückspielen
|
|
||||||
if [[ -n "${DATA_BACKUP:-}" && -d "$DATA_BACKUP" ]]; then
|
|
||||||
cp -a "$DATA_BACKUP/." "$INSTALL_DIR/backend/data/"
|
|
||||||
ok "Datenbank wiederhergestellt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- npm-Abhängigkeiten installieren -----------------------------------------
|
|
||||||
header "npm-Abhängigkeiten installieren"
|
|
||||||
|
|
||||||
info "Root-Abhängigkeiten..."
|
|
||||||
npm install --prefix "$INSTALL_DIR" --omit=dev --silent
|
|
||||||
|
|
||||||
info "Backend-Abhängigkeiten..."
|
|
||||||
npm install --prefix "$INSTALL_DIR/backend" --omit=dev --silent
|
|
||||||
|
|
||||||
info "Frontend-Abhängigkeiten..."
|
|
||||||
npm install --prefix "$INSTALL_DIR/frontend" --silent
|
|
||||||
|
|
||||||
ok "npm-Abhängigkeiten installiert"
|
|
||||||
|
|
||||||
# --- Frontend bauen -----------------------------------------------------------
|
|
||||||
header "Frontend bauen"
|
|
||||||
|
|
||||||
info "Baue Frontend für $FRONTEND_HOST..."
|
|
||||||
|
|
||||||
# Relative URLs verwenden – funktioniert mit jedem Hostnamen/Domain, da nginx
|
|
||||||
# /api/ und /ws auf dem selben Host proxied. Absolute IP-URLs würden Chromes
|
|
||||||
# Private Network Access (PNA) Policy verletzen, wenn das Frontend über einen
|
|
||||||
# Domainnamen aufgerufen wird.
|
|
||||||
rm -f "$INSTALL_DIR/frontend/.env.production.local"
|
|
||||||
|
|
||||||
npm run build --prefix "$INSTALL_DIR/frontend" --silent
|
|
||||||
ok "Frontend gebaut: $INSTALL_DIR/frontend/dist"
|
|
||||||
|
|
||||||
# --- Backend-Konfiguration ---------------------------------------------------
|
|
||||||
header "Backend konfigurieren"
|
|
||||||
|
|
||||||
ENV_FILE="$INSTALL_DIR/backend/.env"
|
|
||||||
|
|
||||||
if [[ -f "$ENV_FILE" && "$REINSTALL" == true ]]; then
|
|
||||||
warn "Bestehende .env bleibt erhalten (--reinstall)"
|
|
||||||
else
|
|
||||||
info "Erstelle Backend .env..."
|
|
||||||
cat > "$ENV_FILE" <<EOF
|
|
||||||
# Ripster Backend – Konfiguration
|
|
||||||
# Generiert von install.sh am $(date)
|
|
||||||
|
|
||||||
PORT=${BACKEND_PORT}
|
|
||||||
DB_PATH=./data/ripster.db
|
|
||||||
LOG_DIR=./logs
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# CORS: Erlaube Anfragen vom Frontend (nginx)
|
|
||||||
CORS_ORIGIN=http://${FRONTEND_HOST}
|
|
||||||
EOF
|
|
||||||
ok "Backend .env erstellt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Berechtigungen setzen ---------------------------------------------------
|
|
||||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
|
|
||||||
chmod -R 755 "$INSTALL_DIR"
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
|
|
||||||
# Ausgabe- und Log-Verzeichnisse dem installierenden User zuweisen
|
|
||||||
# (SUDO_USER = der echte User hinter sudo; leer wenn direkt als root ausgeführt)
|
|
||||||
ACTUAL_USER="${SUDO_USER:-}"
|
|
||||||
if [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]]; then
|
|
||||||
chown -R "$ACTUAL_USER:$SERVICE_USER" \
|
|
||||||
"$INSTALL_DIR/backend/data/output" \
|
|
||||||
"$INSTALL_DIR/backend/data/logs"
|
|
||||||
chmod -R 775 \
|
|
||||||
"$INSTALL_DIR/backend/data/output" \
|
|
||||||
"$INSTALL_DIR/backend/data/logs"
|
|
||||||
ok "Verzeichnisse $ACTUAL_USER:$SERVICE_USER (775) zugewiesen"
|
|
||||||
else
|
|
||||||
ok "Verzeichnisse bereits $SERVICE_USER gehörig (kein SUDO_USER erkannt)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
|
|
||||||
# Laufzeit-relevant ist das Verzeichnis des Service-Users.
|
|
||||||
MAKEMKV_SERVICE_DIR="${SERVICE_HOME}/.MakeMKV"
|
|
||||||
if [[ ! -d "$MAKEMKV_SERVICE_DIR" ]]; then
|
|
||||||
mkdir -p "$MAKEMKV_SERVICE_DIR"
|
|
||||||
ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_SERVICE_DIR"
|
|
||||||
else
|
|
||||||
info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_SERVICE_DIR"
|
|
||||||
fi
|
|
||||||
chown "$SERVICE_USER:$SERVICE_USER" "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
|
||||||
chmod 700 "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
|
||||||
|
|
||||||
# --- Systemd-Dienst: Backend -------------------------------------------------
|
|
||||||
header "Systemd-Dienst (Backend) erstellen"
|
|
||||||
|
|
||||||
cat > /etc/systemd/system/ripster-backend.service <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=Ripster Backend API
|
|
||||||
After=network.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=${SERVICE_USER}
|
|
||||||
Group=${SERVICE_USER}
|
|
||||||
WorkingDirectory=${INSTALL_DIR}/backend
|
|
||||||
ExecStart=$(command -v node) src/index.js
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
StartLimitIntervalSec=60
|
|
||||||
StartLimitBurst=3
|
|
||||||
|
|
||||||
Environment=NODE_ENV=production
|
|
||||||
Environment=HOME=${SERVICE_HOME}
|
|
||||||
Environment=LANG=C.UTF-8
|
|
||||||
Environment=LC_ALL=C.UTF-8
|
|
||||||
Environment=LANGUAGE=C.UTF-8
|
|
||||||
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
|
||||||
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=ripster-backend
|
|
||||||
|
|
||||||
# Kein statisches DeviceAllow: Device-Pfade unterscheiden sich je nach Host/Container.
|
|
||||||
# Damit Ripster auf unterschiedlichen Systemen funktioniert, kein Device-Cgroup-Filter.
|
|
||||||
DevicePolicy=auto
|
|
||||||
SupplementaryGroups=video render cdrom disk
|
|
||||||
|
|
||||||
# Für Skriptausführung via GUI (inkl. optionalem sudo in User-Skripten)
|
|
||||||
# darf no_new_privileges nicht aktiv sein.
|
|
||||||
NoNewPrivileges=false
|
|
||||||
ProtectSystem=full
|
|
||||||
ProtectHome=read-only
|
|
||||||
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
ok "ripster-backend.service erstellt"
|
|
||||||
|
|
||||||
# --- nginx konfigurieren -----------------------------------------------------
|
|
||||||
if [[ "$SKIP_NGINX" == false ]]; then
|
|
||||||
header "nginx konfigurieren"
|
|
||||||
|
|
||||||
cat > /etc/nginx/sites-available/ripster <<EOF
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name ${FRONTEND_HOST} _;
|
|
||||||
|
|
||||||
root ${INSTALL_DIR}/frontend/dist;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files \$uri \$uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://127.0.0.1:${BACKEND_PORT};
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host \$host;
|
|
||||||
proxy_set_header X-Real-IP \$remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /ws {
|
|
||||||
proxy_pass http://127.0.0.1:${BACKEND_PORT}/ws;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade \$http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host \$host;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
|
||||||
ln -sf /etc/nginx/sites-available/ripster /etc/nginx/sites-enabled/ripster
|
|
||||||
|
|
||||||
nginx -t && ok "nginx-Konfiguration gültig" || fatal "nginx-Konfiguration fehlerhaft!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Dienste starten ----------------------------------------------------------
|
|
||||||
header "Dienste starten"
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
|
|
||||||
systemctl enable ripster-backend
|
|
||||||
systemctl restart ripster-backend
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
if systemctl is-active --quiet ripster-backend; then
|
|
||||||
ok "ripster-backend läuft"
|
|
||||||
else
|
|
||||||
error "ripster-backend konnte nicht gestartet werden!"
|
|
||||||
journalctl -u ripster-backend -n 30 --no-pager
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$SKIP_NGINX" == false ]]; then
|
|
||||||
systemctl enable nginx
|
|
||||||
systemctl restart nginx
|
|
||||||
if systemctl is-active --quiet nginx; then
|
|
||||||
ok "nginx läuft"
|
|
||||||
else
|
|
||||||
error "nginx konnte nicht gestartet werden!"
|
|
||||||
journalctl -u nginx -n 20 --no-pager
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Zusammenfassung ----------------------------------------------------------
|
|
||||||
header "Installation abgeschlossen!"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e " ${GREEN}${BOLD}Ripster ist installiert und läuft.${RESET}"
|
|
||||||
echo ""
|
|
||||||
if [[ "$SKIP_NGINX" == false ]]; then
|
|
||||||
echo -e " ${BOLD}Weboberfläche:${RESET} http://${FRONTEND_HOST}"
|
|
||||||
else
|
|
||||||
echo -e " ${BOLD}Backend API:${RESET} http://${FRONTEND_HOST}:${BACKEND_PORT}/api"
|
|
||||||
warn "nginx deaktiviert – Frontend nicht automatisch erreichbar."
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}Dienste verwalten:${RESET}"
|
|
||||||
echo -e " sudo systemctl status ripster-backend"
|
|
||||||
echo -e " sudo systemctl restart ripster-backend"
|
|
||||||
echo -e " sudo systemctl stop ripster-backend"
|
|
||||||
echo -e " sudo journalctl -u ripster-backend -f"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}Konfiguration:${RESET} $INSTALL_DIR/backend/.env"
|
|
||||||
echo -e " ${BOLD}Datenbank:${RESET} $INSTALL_DIR/backend/data/ripster.db"
|
|
||||||
echo -e " ${BOLD}Logs:${RESET} $INSTALL_DIR/backend/logs/"
|
|
||||||
echo -e " ${BOLD}Aktualisieren:${RESET} sudo bash $INSTALL_DIR/install.sh --reinstall"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
missing_tools=()
|
|
||||||
command_exists makemkvcon || missing_tools+=("makemkvcon")
|
|
||||||
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
|
|
||||||
command_exists mediainfo || missing_tools+=("mediainfo")
|
|
||||||
command_exists cdparanoia || missing_tools+=("cdparanoia")
|
|
||||||
command_exists flac || missing_tools+=("flac")
|
|
||||||
command_exists lame || missing_tools+=("lame")
|
|
||||||
command_exists opusenc || missing_tools+=("opusenc")
|
|
||||||
command_exists oggenc || missing_tools+=("oggenc")
|
|
||||||
|
|
||||||
if [[ ${#missing_tools[@]} -gt 0 ]]; then
|
|
||||||
echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:"
|
|
||||||
for t in "${missing_tools[@]}"; do
|
|
||||||
echo -e " ${YELLOW}✗${RESET} $t"
|
|
||||||
done
|
|
||||||
echo -e " Diese können in den Ripster-Einstellungen konfiguriert werden."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
Reference in New Issue
Block a user