31 Commits
main ... dev

Author SHA1 Message Date
466e7a7a3d Remove gitea scripts from tracking 2026-03-13 22:30:46 +00:00
e67c0d316d kk 2026-03-13 22:27:59 +00:00
1da5ee3e34 Fix 2026-03-13 22:11:24 +00:00
4d377f3eb4 ignore 2026-03-13 18:43:08 +00:00
df708485b5 merge 2026-03-13 15:50:45 +00:00
b6cac5efb4 merge 2026-03-13 15:15:50 +00:00
f38081649f merge 2026-03-13 11:21:29 +00:00
5b41f728c5 UI/Features 2026-03-13 11:07:34 +00:00
7948dd298c DiskFix 2026-03-12 12:54:13 +00:00
ee6603ffad Drive Fix 2026-03-12 11:29:02 +00:00
93ed0e6eb2 Drive Fix 2026-03-12 11:22:11 +00:00
778fabb2e5 Drive Fix 2026-03-12 11:14:51 +00:00
5d8796404c setup skript 2026-03-12 11:12:31 +00:00
a2b9f3625c setup skript 2026-03-12 11:08:26 +00:00
1e30f00b45 Readme 2026-03-12 11:00:33 +00:00
519b7a30ef Readme 2026-03-12 10:54:51 +00:00
3ae883326a Readme 2026-03-12 10:52:43 +00:00
46f415d778 Readme 2026-03-12 10:48:49 +00:00
d473f296ff CD Ripping Feature 2026-03-12 10:45:42 +00:00
e0bba8cbfc Prototype 2026-03-12 10:41:38 +00:00
715dfbbc38 Prototype 2026-03-12 10:27:09 +00:00
5cf869eaca Prototype 2026-03-12 10:15:50 +00:00
3dd043689e fix: always generate CAA cover art URL, handle missing covers with onError
Per CAA API docs, front-250 URLs are predictable from mbId. Remove the
cover-art-archive.front check (unreliable in search results) and always
construct the URL. A CoverThumb component handles 404s gracefully.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:36:24 +00:00
882dad57e2 fix: CD metadata dialog search/images/Weiter button
- Remove auto MusicBrainz search from analyzeCd; user triggers search manually
- Dialog uses single results state (replaced per search, not appended)
- Add cover-art-archive to MB search includes so cover images load
- Weiter button only blocked by track selection when tracks actually exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:32:23 +00:00
15f1a49378 fix: add cdparanoia fallback for audio CD detection in VMs
udevadm media flags are not propagated in VM/passthrough scenarios.
When blkid and udevadm both return nothing, try cdparanoia -Q to
detect audio CDs directly via TOC read.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:18:55 +00:00
871e39bba2 fix: reset hard before branch switch to discard stale local changes
Partial writes from a failed install leave files as "local changes",
blocking the checkout. Reset to HEAD first so the switch always works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:09:11 +00:00
d8a6b4c56d fix: fetch all remote branches before checkout on reinstall
Single-branch clones only track one branch. Reset the remote refspec
to '*' before fetching so any target branch can be checked out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:08:05 +00:00
16e76d70c9 fix: validate remote branch exists before clone/reinstall
Prevents cryptic git errors on fresh machines or when switching branches.
Shows available branches in the error message for easier debugging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:07:32 +00:00
45a19c7a12 fix: use checkout -B for reinstall to support new/switched branches
Fixes "pathspec did not match" error when switching to a branch that
doesn't yet have a local tracking ref on the target machine.
Also adds CD ripping tools install and output/cd directory creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:05:56 +00:00
8af4da5a08 First test 2026-03-12 08:04:23 +00:00
c13ce5a50b First test 2026-03-12 08:00:23 +00:00
36 changed files with 9045 additions and 871 deletions

View File

@@ -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

View File

@@ -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=

View File

@@ -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')
}; };

View File

@@ -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,51 @@ 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 });
}
}
const rawDirCdLabel = 'CD RAW-Ordner';
const rawDirCdDescription = 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).';
const rawDirCdResult = await db.run(
`UPDATE settings_schema
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'raw_dir_cd' AND (label != ? OR description != ?)`,
[rawDirCdLabel, rawDirCdDescription, rawDirCdLabel, rawDirCdDescription]
);
if (rawDirCdResult?.changes > 0) {
logger.info('migrate:settings-schema-cd-raw-updated', {
key: 'raw_dir_cd',
label: rawDirCdLabel
});
}
// Migrate raw_dir_cd_owner label
await db.run(
`UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP
WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'`
);
// Add movie_dir_cd if not already present
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL)`);
// Add movie_dir_cd_owner if not already present
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL)`);
} }
async function getDb() { async function getDb() {

View File

@@ -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 = () => {

View File

@@ -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 });
}) })

View File

@@ -46,6 +46,85 @@ router.get(
}) })
); );
router.get(
'/cd/musicbrainz/search',
asyncHandler(async (req, res) => {
const query = req.query.q || '';
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query });
const results = await pipelineService.searchMusicBrainz(String(query));
res.json({ results });
})
);
router.get(
'/cd/musicbrainz/release/:mbId',
asyncHandler(async (req, res) => {
const mbId = String(req.params.mbId || '').trim();
if (!mbId) {
const error = new Error('mbId fehlt.');
error.statusCode = 400;
throw error;
}
logger.info('get:cd:musicbrainz:release', { reqId: req.reqId, mbId });
const release = await pipelineService.getMusicBrainzReleaseById(mbId);
res.json({ release });
})
);
router.post(
'/cd/select-metadata',
asyncHandler(async (req, res) => {
const { jobId, title, artist, year, mbId, coverUrl, tracks } = req.body;
if (!jobId) {
const error = new Error('jobId fehlt.');
error.statusCode = 400;
throw error;
}
logger.info('post:cd:select-metadata', { reqId: req.reqId, jobId, title, artist, year, mbId });
const job = await pipelineService.selectCdMetadata({
jobId: Number(jobId),
title,
artist,
year,
mbId,
coverUrl,
tracks
});
res.json({ job });
})
);
router.post(
'/cd/start/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const ripConfig = req.body || {};
logger.info('post:cd:start', {
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 });
})
);
router.post( router.post(
'/select-metadata', '/select-metadata',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {

View File

@@ -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) => {

View File

@@ -0,0 +1,698 @@
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const logger = require('./logger').child('CD_RIP');
const { spawnTrackedProcess } = require('./processRunner');
const { parseCdParanoiaProgress } = require('../utils/progressParsers');
const { ensureDir } = require('../utils/files');
const { errorToMeta } = require('../utils/errorMeta');
const execFileAsync = promisify(execFile);
const SUPPORTED_FORMATS = new Set(['wav', 'flac', 'mp3', 'opus', 'ogg']);
const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artist} - {title}';
/**
* Parse cdparanoia -Q output to extract track information.
* Supports both bracket styles shown by different builds:
* track 1: 0 (00:00.00) 24218 (05:22.43)
* track 1: 0 [00:00.00] 24218 [05:22.43]
*/
function parseToc(tocOutput) {
const lines = String(tocOutput || '').split(/\r?\n/);
const tracks = [];
for (const line of lines) {
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
if (trackMatch) {
const position = Number(trackMatch[1]);
const payloadWithoutTimes = String(trackMatch[2] || '')
.replace(/[\(\[]\s*\d+:\d+\.\d+\s*[\)\]]/g, ' ');
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
if (sectorValues.length < 2) {
continue;
}
const startSector = Number(sectorValues[0]);
const lengthSector = Number(sectorValues[1]);
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
continue;
}
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
continue;
}
// duration in seconds: sectors / 75
const durationSec = Math.round(lengthSector / 75);
tracks.push({
position,
startSector,
lengthSector,
durationSec,
durationMs: durationSec * 1000
});
continue;
}
// Alternative cdparanoia -Q table style:
// 1. 16503 [03:40.03] 0 [00:00.00] no no 2
// ^ length sectors ^ start sector
const tableMatch = line.match(
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]/i
);
if (!tableMatch) {
continue;
}
const position = Number(tableMatch[1]);
const lengthSector = Number(tableMatch[2]);
const startSector = Number(tableMatch[3]);
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
continue;
}
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
continue;
}
const durationSec = Math.round(lengthSector / 75);
tracks.push({
position,
startSector,
lengthSector,
durationSec,
durationMs: durationSec * 1000
});
}
return tracks;
}
async function readToc(devicePath, cmd) {
const cdparanoia = String(cmd || 'cdparanoia').trim() || 'cdparanoia';
logger.info('toc:read', { devicePath, cmd: cdparanoia });
try {
// Depending on distro/build, TOC can appear on stderr and/or stdout.
const { stdout, stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], {
timeout: 15000
});
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
logger.info('toc:done', { devicePath, trackCount: tracks.length });
return tracks;
} catch (error) {
// cdparanoia -Q may exit non-zero even when TOC is readable.
const stderr = String(error?.stderr || '');
const stdout = String(error?.stdout || '');
const tracks = parseToc(`${stderr}\n${stdout}`);
if (tracks.length > 0) {
logger.info('toc:done-from-error-streams', { devicePath, trackCount: tracks.length });
return tracks;
}
logger.warn('toc:failed', { devicePath, error: errorToMeta(error) });
return [];
}
}
function buildOutputFilename(track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate);
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
return `${relativeBasePath}.${ext}`;
}
function sanitizePathSegment(value, fallback = 'unknown') {
const raw = String(value == null ? '' : value)
.normalize('NFC')
.replace(/[\\/:*?"<>|]/g, '-')
// Keep umlauts/special letters, but filter heart symbols in filenames.
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!raw || raw === '.' || raw === '..') {
return fallback;
}
return raw.slice(0, 180);
}
function normalizeTemplateTokenKey(rawKey) {
const key = String(rawKey || '').trim().toLowerCase();
if (!key) {
return '';
}
if (key === 'tracknr' || key === 'tracknumberpadded' || key === 'tracknopadded') {
return 'trackNr';
}
if (key === 'tracknumber' || key === 'trackno' || key === 'tracknum' || key === 'track') {
return 'trackNo';
}
if (key === 'trackartist' || key === 'track_artist') {
return 'trackArtist';
}
if (key === 'albumartist') {
return 'albumArtist';
}
if (key === 'interpret') {
return 'artist';
}
return key;
}
function cleanupRenderedTemplate(value) {
return String(value || '')
.replace(/\(\s*\)/g, '')
.replace(/\[\s*]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
function renderOutputTemplate(template, values) {
const source = String(template || DEFAULT_CD_OUTPUT_TEMPLATE).trim() || DEFAULT_CD_OUTPUT_TEMPLATE;
const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
const normalizedKey = normalizeTemplateTokenKey(keyA || keyB);
const rawValue = values[normalizedKey];
if (rawValue === undefined || rawValue === null) {
return '';
}
return String(rawValue);
});
return cleanupRenderedTemplate(rendered);
}
function buildTemplateValues(track, meta, format = null) {
const trackNo = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : 1;
const trackTitle = sanitizePathSegment(track?.title || `Track ${trackNo}`, `Track ${trackNo}`);
const albumArtist = sanitizePathSegment(meta?.artist || 'Unknown Artist', 'Unknown Artist');
const trackArtist = sanitizePathSegment(track?.artist || meta?.artist || 'Unknown Artist', 'Unknown Artist');
const album = sanitizePathSegment(meta?.title || meta?.album || 'Unknown Album', 'Unknown Album');
const year = meta?.year == null ? '' : sanitizePathSegment(String(meta.year), '');
return {
artist: albumArtist,
albumArtist,
trackArtist,
album,
year,
title: trackTitle,
trackNr: String(trackNo).padStart(2, '0'),
trackNo: String(trackNo),
format: format ? String(format).trim().toLowerCase() : ''
};
}
function buildTrackRelativeBasePath(track, meta, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE, format = null) {
const values = buildTemplateValues(track, meta, format);
const rendered = renderOutputTemplate(outputTemplate, values)
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/^\/+|\/+$/g, '');
const parts = rendered
.split('/')
.map((part) => sanitizePathSegment(part, 'unknown'))
.filter(Boolean);
if (parts.length === 0) {
return `${String(track?.position || 1).padStart(2, '0')} Track ${String(track?.position || 1).padStart(2, '0')}`;
}
return path.join(...parts);
}
function buildOutputDir(meta, baseDir, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
const sampleTrack = {
position: 1,
title: 'Track 1'
};
const relativeBasePath = buildTrackRelativeBasePath(sampleTrack, meta, outputTemplate);
const relativeDir = path.dirname(relativeBasePath);
if (!relativeDir || relativeDir === '.' || relativeDir === path.sep) {
return baseDir;
}
return path.join(baseDir, relativeDir);
}
function splitPathSegments(value) {
return String(value || '')
.replace(/\\/g, '/')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean);
}
function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) {
const outputSegments = splitPathSegments(outputBaseDir);
const relativeSegments = splitPathSegments(relativeDir);
if (relativeSegments.length === 0 || outputSegments.length < relativeSegments.length) {
return false;
}
const offset = outputSegments.length - relativeSegments.length;
for (let i = 0; i < relativeSegments.length; i++) {
if (outputSegments[offset + i] !== relativeSegments[i]) {
return false;
}
}
return true;
}
function stripLeadingRelativeDir(relativeFilePath, relativeDir) {
const fileSegments = splitPathSegments(relativeFilePath);
const dirSegments = splitPathSegments(relativeDir);
if (dirSegments.length === 0 || fileSegments.length <= dirSegments.length) {
return relativeFilePath;
}
for (let i = 0; i < dirSegments.length; i++) {
if (fileSegments[i] !== dirSegments[i]) {
return relativeFilePath;
}
}
return path.join(...fileSegments.slice(dirSegments.length));
}
function buildOutputFilePath(outputBaseDir, track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate, format);
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
const relativeDir = path.dirname(relativeBasePath);
let relativeFilePath = `${relativeBasePath}.${ext}`;
if (relativeDir && relativeDir !== '.' && relativeDir !== path.sep) {
if (outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir)) {
relativeFilePath = stripLeadingRelativeDir(relativeFilePath, relativeDir);
}
}
const outFile = path.join(outputBaseDir, relativeFilePath);
return {
outFile,
relativeFilePath,
outFilename: path.basename(relativeFilePath)
};
}
function buildCancelledError() {
const error = new Error('Job wurde vom Benutzer abgebrochen.');
error.statusCode = 409;
return error;
}
function assertNotCancelled(isCancelled) {
if (typeof isCancelled === 'function' && isCancelled()) {
throw buildCancelledError();
}
}
function normalizeExitCode(error) {
const code = Number(error?.code);
if (Number.isFinite(code)) {
return Math.trunc(code);
}
return 1;
}
function quoteShellArg(value) {
const text = String(value == null ? '' : value);
if (!text) {
return "''";
}
if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(text)) {
return text;
}
return `'${text.replace(/'/g, "'\\''")}'`;
}
function formatCommandLine(cmd, args = []) {
const normalizedArgs = Array.isArray(args) ? args : [];
return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
}
function copyFilePreservingRaw(sourcePath, targetPath) {
const rawSource = String(sourcePath || '').trim();
const rawTarget = String(targetPath || '').trim();
if (!rawSource || !rawTarget) {
return;
}
const source = path.resolve(rawSource);
const target = path.resolve(rawTarget);
if (source === target) {
return;
}
fs.copyFileSync(source, target);
}
async function runProcessTracked({
cmd,
args,
cwd,
onStdoutLine,
onStderrLine,
context,
onProcessHandle,
isCancelled
}) {
assertNotCancelled(isCancelled);
const handle = spawnTrackedProcess({
cmd,
args,
cwd,
onStdoutLine,
onStderrLine,
context
});
if (typeof onProcessHandle === 'function') {
onProcessHandle(handle);
}
if (typeof isCancelled === 'function' && isCancelled()) {
handle.cancel();
}
try {
return await handle.promise;
} catch (error) {
if (typeof isCancelled === 'function' && isCancelled()) {
throw buildCancelledError();
}
throw error;
}
}
/**
* Rip and encode a CD.
*
* @param {object} options
* @param {string} options.jobId - Job ID for logging
* @param {string} options.devicePath - e.g. /dev/sr0
* @param {string} options.cdparanoiaCmd - path/cmd for cdparanoia
* @param {string} options.rawWavDir - temp dir for WAV files
* @param {string} options.outputDir - final output dir
* @param {string} options.format - wav|flac|mp3|opus|ogg
* @param {object} options.formatOptions - encoder-specific options
* @param {number[]} options.selectedTracks - track positions to rip (empty = all)
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
* @param {object} options.meta - album metadata {title, artist, year}
* @param {string} options.outputTemplate - template for relative output path without extension
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
* @param {Function} options.onLog - (level, msg) => void
* @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
* @param {Function} options.isCancelled - returns true when user requested cancellation
* @param {object} options.context - passed to spawnTrackedProcess
*/
async function ripAndEncode(options) {
const {
jobId,
devicePath,
cdparanoiaCmd = 'cdparanoia',
rawWavDir,
outputDir,
format = 'flac',
formatOptions = {},
selectedTracks = [],
tracks = [],
meta = {},
outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE,
onProgress,
onLog,
onProcessHandle,
isCancelled,
context
} = options;
if (!SUPPORTED_FORMATS.has(format)) {
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
}
const tracksToRip = selectedTracks.length > 0
? tracks.filter((t) => selectedTracks.includes(t.position))
: tracks;
if (tracksToRip.length === 0) {
throw new Error('Keine Tracks zum Rippen ausgewählt.');
}
await ensureDir(rawWavDir);
await ensureDir(outputDir);
logger.info('rip:start', {
jobId,
devicePath,
format,
trackCount: tracksToRip.length
});
const log = (level, msg) => {
logger[level] && logger[level](msg, { jobId });
onLog && onLog(level, msg);
};
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
for (let i = 0; i < tracksToRip.length; i++) {
assertNotCancelled(isCancelled);
const track = tracksToRip[i];
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
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', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`);
try {
await runProcessTracked({
cmd: cdparanoiaCmd,
args: ripArgs,
cwd: rawWavDir,
onStderrLine(line) {
const parsed = parseCdParanoiaProgress(line);
if (parsed && parsed.percent !== null) {
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
onProgress && onProgress({
phase: 'rip',
trackEvent: 'progress',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: parsed.percent,
percent: overallPercent
});
}
},
context,
onProcessHandle,
isCancelled
});
} catch (error) {
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
throw error;
}
throw new Error(
`cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
);
}
onProgress && onProgress({
phase: 'rip',
trackEvent: 'complete',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 100,
percent: ((i + 1) / tracksToRip.length) * 50
});
log('info', `Track ${track.position} gerippt.`);
}
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
if (format === 'wav') {
// Keep RAW WAVs in place and copy them to the final output structure.
for (let i = 0; i < tracksToRip.length; i++) {
assertNotCancelled(isCancelled);
const track = tracksToRip[i];
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
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));
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
copyFilePreservingRaw(wavFile, outFile);
onProgress && onProgress({
phase: 'encode',
trackEvent: 'complete',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 100,
percent: 50 + ((i + 1) / tracksToRip.length) * 50
});
log('info', `WAV für Track ${track.position} gespeichert.`);
}
return { outputDir, format, trackCount: tracksToRip.length };
}
for (let i = 0; i < tracksToRip.length; i++) {
assertNotCancelled(isCancelled);
const track = tracksToRip[i];
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
if (!fs.existsSync(wavFile)) {
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
}
const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate);
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}`);
const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile);
log('info', `Promptkette [Encode ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(encodeArgs.cmd, encodeArgs.args)}`);
try {
await runProcessTracked({
cmd: encodeArgs.cmd,
args: encodeArgs.args,
cwd: rawWavDir,
onStdoutLine() {},
onStderrLine() {},
context,
onProcessHandle,
isCancelled
});
} catch (error) {
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
throw error;
}
throw new Error(
`${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
);
}
onProgress && onProgress({
phase: 'encode',
trackEvent: 'complete',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 100,
percent: 50 + ((i + 1) / tracksToRip.length) * 50
});
log('info', `Track ${track.position} encodiert.`);
}
return { outputDir, format, trackCount: tracksToRip.length };
}
function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
const artist = track?.artist || meta?.artist || '';
const album = meta?.title || '';
const year = meta?.year ? String(meta.year) : '';
const trackTitle = track.title || `Track ${track.position}`;
const trackNum = String(track.position);
if (format === 'flac') {
const level = Number(opts.flacCompression ?? 5);
const clampedLevel = Math.max(0, Math.min(8, level));
return {
cmd: 'flac',
args: [
`--compression-level-${clampedLevel}`,
'--tag', `TITLE=${trackTitle}`,
'--tag', `ARTIST=${artist}`,
'--tag', `ALBUM=${album}`,
'--tag', `DATE=${year}`,
'--tag', `TRACKNUMBER=${trackNum}`,
wavFile,
'-o', outFile
]
};
}
if (format === 'mp3') {
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
const args = ['--id3v2-only', '--noreplaygain'];
if (mode === 'vbr') {
const quality = Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)));
args.push('-V', String(quality));
} else {
const bitrate = Number(opts.mp3Bitrate ?? 192);
args.push('-b', String(bitrate));
}
args.push(
'--tt', trackTitle,
'--ta', artist,
'--tl', album,
'--ty', year,
'--tn', trackNum,
wavFile,
outFile
);
return { cmd: 'lame', args };
}
if (format === 'opus') {
const bitrate = Math.max(32, Math.min(512, Number(opts.opusBitrate ?? 160)));
const complexity = Math.max(0, Math.min(10, Number(opts.opusComplexity ?? 10)));
return {
cmd: 'opusenc',
args: [
'--bitrate', String(bitrate),
'--comp', String(complexity),
'--title', trackTitle,
'--artist', artist,
'--album', album,
'--date', year,
'--tracknumber', trackNum,
wavFile,
outFile
]
};
}
if (format === 'ogg') {
const quality = Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)));
return {
cmd: 'oggenc',
args: [
'-q', String(quality),
'-t', trackTitle,
'-a', artist,
'-l', album,
'-d', year,
'-N', trackNum,
'-o', outFile,
wavFile
]
};
}
throw new Error(`Unbekanntes Format: ${format}`);
}
module.exports = {
parseToc,
readToc,
ripAndEncode,
buildOutputDir,
buildOutputFilename,
DEFAULT_CD_OUTPUT_TEMPLATE,
SUPPORTED_FORMATS
};

View File

@@ -4,9 +4,42 @@ const { execFile } = require('child_process');
const { promisify } = require('util'); const { promisify } = require('util');
const settingsService = require('./settingsService'); const settingsService = require('./settingsService');
const logger = require('./logger').child('DISK'); const logger = require('./logger').child('DISK');
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 || []) {
@@ -52,14 +85,14 @@ function normalizeMediaProfile(rawValue) {
) { ) {
return 'dvd'; return 'dvd';
} }
if (value === 'disc' || value === 'other' || value === 'sonstiges' || value === 'cd') { if (value === 'cd' || value === 'audio_cd') {
return 'other'; return 'cd';
} }
return null; return null;
} }
function isSpecificMediaProfile(value) { function isSpecificMediaProfile(value) {
return value === 'bluray' || value === 'dvd'; return value === 'bluray' || value === 'dvd' || value === 'cd';
} }
function inferMediaProfileFromTextParts(parts) { function inferMediaProfileFromTextParts(parts) {
@@ -82,6 +115,9 @@ function inferMediaProfileFromTextParts(parts) {
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
const fstype = String(rawFsType || '').trim().toLowerCase(); const fstype = String(rawFsType || '').trim().toLowerCase();
if (fstype === 'audio_cd') {
return 'cd';
}
const model = String(rawModel || '').trim().toLowerCase(); const model = String(rawModel || '').trim().toLowerCase();
const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model); const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model);
const hasDvdModelMarker = /dvd/.test(model); const hasDvdModelMarker = /dvd/.test(model);
@@ -142,7 +178,7 @@ function inferMediaProfileFromUdevProperties(properties = {}) {
return 'dvd'; return 'dvd';
} }
if (hasFlag('ID_CDROM_MEDIA_CD')) { if (hasFlag('ID_CDROM_MEDIA_CD')) {
return 'other'; return 'cd';
} }
return null; return null;
} }
@@ -181,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);
@@ -493,22 +535,74 @@ class DiskDetectionService extends EventEmitter {
} }
async checkMediaPresent(devicePath) { async checkMediaPresent(devicePath) {
let blkidType = null;
try { try {
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]); const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
const type = String(stdout || '').trim().toLowerCase(); blkidType = String(stdout || '').trim().toLowerCase() || null;
const has = type.length > 0; } catch (_error) {
logger.debug('blkid:result', { devicePath, hasMedia: has, type }); // blkid failed could mean no disc, or an audio CD (no filesystem type)
return {
hasMedia: has,
type: type || null
};
} catch (error) {
logger.debug('blkid:no-media-or-fail', { devicePath, error: errorToMeta(error) });
return {
hasMedia: false,
type: null
};
} }
if (blkidType) {
logger.debug('blkid:result', { devicePath, hasMedia: true, type: blkidType });
return { hasMedia: true, type: blkidType };
}
// blkid found nothing audio CDs have no filesystem, so fall back to udevadm
try {
const { stdout } = await execFileAsync('udevadm', [
'info',
'--query=property',
'--name',
devicePath
]);
const props = {};
for (const line of String(stdout || '').split(/\r?\n/)) {
const idx = line.indexOf('=');
if (idx <= 0) {
continue;
}
props[line.slice(0, idx).trim().toUpperCase()] = line.slice(idx + 1).trim();
}
const hasBD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_BD') && props[k] === '1');
const hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1');
const hasCD = props['ID_CDROM_MEDIA_CD'] === '1';
if (hasCD && !hasDVD && !hasBD) {
logger.debug('udevadm:audio-cd', { devicePath });
return { hasMedia: true, type: 'audio_cd' };
}
} catch (_udevError) {
// udevadm not available or failed ignore
}
// Last resort: cdparanoia can read the TOC of audio CDs directly.
// Useful when udev media flags are not propagated (e.g. VM passthrough).
// Some builds return non-zero even when TOC output exists, so parse both
// stdout/stderr and treat valid TOC lines as "audio CD present".
// Keep compatibility with previous behavior: exit 0 counts as media even
// when TOC output format cannot be parsed.
try {
const { stdout, stderr } = await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 });
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
if (tracks.length > 0) {
logger.debug('cdparanoia:audio-cd', { devicePath, trackCount: tracks.length });
return { hasMedia: true, type: 'audio_cd' };
}
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath });
return { hasMedia: true, type: 'audio_cd' };
} catch (cdError) {
const stderr = String(cdError?.stderr || '');
const stdout = String(cdError?.stdout || '');
const tracks = parseToc(`${stderr}\n${stdout}`);
if (tracks.length > 0) {
logger.debug('cdparanoia:audio-cd-from-error-streams', { devicePath, trackCount: tracks.length });
return { hasMedia: true, type: 'audio_cd' };
}
// cdparanoia failed and no TOC output could be parsed.
}
logger.debug('blkid:no-media-or-fail', { devicePath });
return { hasMedia: false, type: null };
} }
async getDiscLabel(devicePath) { async getDiscLabel(devicePath) {
@@ -560,6 +654,11 @@ class DiskDetectionService extends EventEmitter {
} }
async inferMediaProfile(devicePath, hints = {}) { async inferMediaProfile(devicePath, hints = {}) {
// Audio CDs have no filesystem short-circuit immediately
if (String(hints?.fstype || '').trim().toLowerCase() === 'audio_cd') {
return 'cd';
}
const explicit = normalizeMediaProfile(hints?.mediaProfile); const explicit = normalizeMediaProfile(hints?.mediaProfile);
if (isSpecificMediaProfile(explicit)) { if (isSpecificMediaProfile(explicit)) {
return explicit; return explicit;

View File

@@ -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 RAW-Ordner', 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

View File

@@ -0,0 +1,169 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('MUSICBRAINZ');
const MB_BASE = 'https://musicbrainz.org/ws/2';
const MB_USER_AGENT = 'Ripster/1.0 (https://github.com/ripster)';
const MB_TIMEOUT_MS = 10000;
async function mbFetch(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), MB_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': MB_USER_AGENT
},
signal: controller.signal
});
clearTimeout(timer);
if (!response.ok) {
throw new Error(`MusicBrainz Anfrage fehlgeschlagen (${response.status})`);
}
return response.json();
} catch (error) {
clearTimeout(timer);
throw error;
}
}
function normalizeRelease(release) {
if (!release) {
return null;
}
const artistCredit = Array.isArray(release['artist-credit'])
? release['artist-credit'].map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ')
: null;
const date = String(release.date || '').trim();
const yearMatch = date.match(/\b(\d{4})\b/);
const year = yearMatch ? Number(yearMatch[1]) : null;
const media = Array.isArray(release.media) ? release.media : [];
const normalizedTracks = media.flatMap((medium, mediumIdx) => {
const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : [];
return mediumTracks.map((track, trackIdx) => {
const rawPosition = String(track.position || track.number || '').trim();
const parsedPosition = Number.parseInt(rawPosition, 10);
const fallbackPosition = mediumIdx * 100 + trackIdx + 1;
const position = Number.isFinite(parsedPosition) && parsedPosition > 0
? parsedPosition
: fallbackPosition;
return {
position,
number: String(track.number || track.position || ''),
title: String(track.title || ''),
durationMs: Number(track.length || 0) || null,
rawTrackArtistCredit: Array.isArray(track['artist-credit']) ? track['artist-credit'] : [],
rawRecordingArtistCredit: Array.isArray(track?.recording?.['artist-credit']) ? track.recording['artist-credit'] : []
};
});
}).map((track) => {
const trackArtistCredit = Array.isArray(track?.rawTrackArtistCredit)
? track.rawTrackArtistCredit
: [];
const recordingArtistCredit = Array.isArray(track?.rawRecordingArtistCredit)
? track.rawRecordingArtistCredit
: [];
const artistFromTrack = trackArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
const artistFromRecording = recordingArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
return {
position: track.position,
number: track.number,
title: track.title,
durationMs: track.durationMs,
artist: artistFromTrack || artistFromRecording || artistCredit || null
};
});
// Always generate the CAA URL when an id is present; the browser/onError
// handles 404s for releases that have no front cover.
const coverArtUrl = release.id
? `https://coverartarchive.org/release/${release.id}/front-250`
: null;
return {
mbId: String(release.id || ''),
title: String(release.title || ''),
artist: artistCredit || null,
year,
date,
country: String(release.country || '').trim() || null,
label: Array.isArray(release['label-info'])
? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null
: null,
coverArtUrl,
tracks: normalizedTracks
};
}
class MusicBrainzService {
async isEnabled() {
const settings = await settingsService.getSettingsMap();
return settings.musicbrainz_enabled !== 'false';
}
async searchByTitle(query) {
const q = String(query || '').trim();
if (!q) {
return [];
}
const enabled = await this.isEnabled();
if (!enabled) {
return [];
}
logger.info('search:start', { query: q });
const url = new URL(`${MB_BASE}/release`);
url.searchParams.set('query', q);
url.searchParams.set('fmt', 'json');
url.searchParams.set('limit', '10');
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
try {
const data = await mbFetch(url.toString());
const releases = Array.isArray(data.releases) ? data.releases : [];
const results = releases.map(normalizeRelease).filter(Boolean);
logger.info('search:done', { query: q, count: results.length });
return results;
} catch (error) {
logger.warn('search:failed', { query: q, error: String(error?.message || error) });
return [];
}
}
async searchByDiscLabel(discLabel) {
return this.searchByTitle(discLabel);
}
async getReleaseById(mbId) {
const id = String(mbId || '').trim();
if (!id) {
return null;
}
const enabled = await this.isEnabled();
if (!enabled) {
return null;
}
logger.info('getById:start', { mbId: id });
const url = new URL(`${MB_BASE}/release/${id}`);
url.searchParams.set('fmt', 'json');
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
try {
const data = await mbFetch(url.toString());
const result = normalizeRelease(data);
logger.info('getById:done', { mbId: id, title: result?.title });
return result;
} catch (error) {
logger.warn('getById:failed', { mbId: id, error: String(error?.message || error) });
return null;
}
}
}
module.exports = new MusicBrainzService();

File diff suppressed because it is too large Load Diff

View File

@@ -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'

View File

@@ -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,27 +38,27 @@ 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']; 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'
}, },
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'
}, },
movie_dir: { movie_dir: {
bluray: 'movie_dir_bluray', bluray: 'movie_dir_bluray',
dvd: 'movie_dir_dvd', dvd: 'movie_dir_dvd',
other: 'movie_dir_other' cd: 'movie_dir_cd'
}, },
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' cd: 'movie_dir_cd_owner'
}, },
mediainfo_extra_args: { mediainfo_extra_args: {
bluray: 'mediainfo_extra_args_bluray', bluray: 'mediainfo_extra_args_bluray',
@@ -86,13 +88,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([
@@ -371,8 +369,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;
} }
@@ -385,9 +383,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'];
} }
@@ -692,6 +687,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 = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_MOVIE_DIR;
}
}
effective[legacyKey] = resolvedValue; effective[legacyKey] = resolvedValue;
continue; continue;
} }
@@ -716,6 +719,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, movies: cd.movie_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(
@@ -1306,20 +1326,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}`;
}
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 = {}) {
@@ -1466,4 +1476,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;

View 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
};

View File

@@ -63,7 +63,38 @@ function parseHandBrakeProgress(line) {
return null; return null;
} }
function parseCdParanoiaProgress(line) {
// cdparanoia writes progress to stderr with \r overwrites.
// Formats seen in the wild:
// "Ripping track 1 of 12 progress: ( 34.21%)"
// "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])"
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
const progressMatch = normalized.match(/progress:\s*\(\s*(\d+(?:\.\d+)?)\s*%\s*\)/i);
if (progressMatch) {
const trackMatch = normalized.match(/track\s+(\d+)\s+of\s+(\d+)/i);
const currentTrack = trackMatch ? Number(trackMatch[1]) : null;
const totalTracks = trackMatch ? Number(trackMatch[2]) : null;
return {
percent: clampPercent(Number(progressMatch[1])),
currentTrack,
totalTracks,
eta: null
};
}
// "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])" style no clear percent here
// Fall back to generic percent match
const percent = parseGenericPercent(normalized);
if (percent !== null) {
return { percent, currentTrack: null, totalTracks: null, eta: null };
}
return null;
}
module.exports = { module.exports = {
parseMakeMkvProgress, parseMakeMkvProgress,
parseHandBrakeProgress parseHandBrakeProgress,
parseCdParanoiaProgress
}; };

View File

@@ -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,46 @@ 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})');
-- Tools CD
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('cdparanoia_command', 'Tools', 'cdparanoia Kommando', 'string', 1, 'Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist.', 'cdparanoia', '[]', '{"minLength":1}', 230);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('cdparanoia_command', 'cdparanoia');
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 ('output_folder_template_dvd', 'Tools', 'Ordnername Template', 'string', 0, 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (DVD).', NULL, '[]', '{}', 540); VALUES (
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_folder_template_dvd', NULL); 'cd_output_template',
'Pfade',
'CD Output Template',
'string',
1,
'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}',
'[]',
'{"minLength":1}',
235
);
INSERT OR IGNORE INTO settings_values (key, value)
VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - {title}');
-- Pfade CD
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 RAW-Ordner', 'path', 0, 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104);
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)
VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD RAW-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_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL);
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL);
-- Metadaten -- Metadaten
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)
@@ -349,7 +397,15 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410); VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', 1, 'MusicBrainz-Metadatensuche für CDs aktivieren.', 'true', '[]', '{}', 420);
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');

View File

@@ -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;
}); });

View File

@@ -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,
@@ -273,6 +279,28 @@ export const api = {
searchOmdb(q) { searchOmdb(q) {
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`); return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
}, },
searchMusicBrainz(q) {
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
},
getMusicBrainzRelease(mbId) {
return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`);
},
async selectCdMetadata(payload) {
const result = await request('/pipeline/cd/select-metadata', {
method: 'POST',
body: JSON.stringify(payload)
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async startCdRip(jobId, ripConfig) {
const result = await request(`/pipeline/cd/start/${jobId}`, {
method: 'POST',
body: JSON.stringify(ripConfig || {})
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async selectMetadata(payload) { async selectMetadata(payload) {
const result = await request('/pipeline/select-metadata', { const result = await request('/pipeline/select-metadata', {
method: 'POST', method: 'POST',
@@ -415,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;

View File

@@ -0,0 +1,313 @@
import { useEffect, useRef, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext';
import { InputNumber } from 'primereact/inputnumber';
function CoverThumb({ url, alt }) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [url]);
if (!url || failed) {
return <div className="poster-thumb-lg poster-fallback">-</div>;
}
return (
<img
src={url}
alt={alt}
className="poster-thumb-lg"
loading="eager"
decoding="sync"
onError={() => setFailed(true)}
/>
);
}
const COVER_PRELOAD_TIMEOUT_MS = 3000;
function preloadCoverImage(url) {
const src = String(url || '').trim();
if (!src) {
return Promise.resolve();
}
return new Promise((resolve) => {
const image = new Image();
let settled = false;
const cleanup = () => {
image.onload = null;
image.onerror = null;
};
const done = () => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve();
};
const timer = window.setTimeout(done, COVER_PRELOAD_TIMEOUT_MS);
image.onload = () => {
window.clearTimeout(timer);
done();
};
image.onerror = () => {
window.clearTimeout(timer);
done();
};
image.src = src;
});
}
export default function CdMetadataDialog({
visible,
context,
onHide,
onSubmit,
onSearch,
onFetchRelease,
busy
}) {
const [selected, setSelected] = useState(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [searchBusy, setSearchBusy] = useState(false);
const searchRunRef = useRef(0);
// Manual metadata inputs
const [manualTitle, setManualTitle] = useState('');
const [manualArtist, setManualArtist] = useState('');
const [manualYear, setManualYear] = useState(null);
// Track titles are pre-filled from MusicBrainz and edited in the next step.
const [trackTitles, setTrackTitles] = useState({});
const tocTracks = Array.isArray(context?.tracks) ? context.tracks : [];
useEffect(() => {
if (!visible) {
return;
}
setSelected(null);
setQuery('');
setManualTitle('');
setManualArtist('');
setManualYear(null);
setResults([]);
setSearchBusy(false);
const titles = {};
for (const t of tocTracks) {
titles[t.position] = t.title || `Track ${t.position}`;
}
setTrackTitles(titles);
}, [visible, context]);
useEffect(() => {
if (!selected) {
return;
}
setManualTitle(selected.title || '');
setManualArtist(selected.artist || '');
setManualYear(selected.year || null);
// Pre-fill track titles from the MusicBrainz result
if (Array.isArray(selected.tracks) && selected.tracks.length > 0) {
const titles = {};
for (const t of selected.tracks) {
if (t.position <= tocTracks.length) {
titles[t.position] = t.title || `Track ${t.position}`;
}
}
// Fill any remaining tracks not in MB result
for (const t of tocTracks) {
if (!titles[t.position]) {
titles[t.position] = t.title || `Track ${t.position}`;
}
}
setTrackTitles(titles);
}
}, [selected]);
const handleSearch = async () => {
const trimmedQuery = query.trim();
if (!trimmedQuery) {
return;
}
setSearchBusy(true);
const searchRunId = searchRunRef.current + 1;
searchRunRef.current = searchRunId;
try {
const searchResults = await onSearch(trimmedQuery);
const normalizedResults = Array.isArray(searchResults) ? searchResults : [];
await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl)));
if (searchRunRef.current !== searchRunId) {
return;
}
setResults(normalizedResults);
setSelected(null);
} finally {
if (searchRunRef.current === searchRunId) {
setSearchBusy(false);
}
}
};
const handleSubmit = async () => {
const normalizeTrackText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
let releaseDetails = selected;
if (selected?.mbId && (!Array.isArray(selected?.tracks) || selected.tracks.length === 0) && typeof onFetchRelease === 'function') {
const fetched = await onFetchRelease(selected.mbId);
if (fetched && typeof fetched === 'object') {
releaseDetails = fetched;
}
}
const releaseTracks = Array.isArray(releaseDetails?.tracks) ? releaseDetails.tracks : [];
const releaseTracksByPosition = new Map();
releaseTracks.forEach((track, index) => {
const parsedPosition = Number(track?.position);
const normalizedPosition = Number.isFinite(parsedPosition) && parsedPosition > 0
? Math.trunc(parsedPosition)
: index + 1;
if (!releaseTracksByPosition.has(normalizedPosition)) {
releaseTracksByPosition.set(normalizedPosition, track);
}
});
const tracks = tocTracks.map((t, index) => {
const position = Number(t.position);
const byPosition = releaseTracksByPosition.get(position);
const byIndex = releaseTracks[index];
return {
position,
title: normalizeTrackText(
byPosition?.title
|| byIndex?.title
|| trackTitles[t.position]
) || `Track ${t.position}`,
artist: normalizeTrackText(
byPosition?.artist
|| byIndex?.artist
|| manualArtist.trim()
|| releaseDetails?.artist
) || null,
selected: true
};
});
const payload = {
jobId: context.jobId,
title: manualTitle.trim() || context?.detectedTitle || 'Audio CD',
artist: manualArtist.trim() || null,
year: manualYear || null,
mbId: releaseDetails?.mbId || selected?.mbId || null,
coverUrl: releaseDetails?.coverArtUrl || selected?.coverArtUrl || null,
tracks
};
await onSubmit(payload);
};
const mbTitleBody = (row) => (
<div className="mb-result-row">
<CoverThumb url={row.coverArtUrl} alt={row.title} />
<div>
<div><strong>{row.title}</strong></div>
<small>{row.artist}{row.year ? ` | ${row.year}` : ''}</small>
{row.label ? <small> | {row.label}</small> : null}
</div>
</div>
);
return (
<Dialog
header="CD-Metadaten auswählen"
visible={visible}
onHide={onHide}
style={{ width: '58rem', maxWidth: '97vw' }}
className="cd-metadata-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
modal
>
{/* MusicBrainz search */}
<div className="search-row">
<InputText
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Album / Interpret suchen"
/>
<Button
label="MusicBrainz Suche"
icon="pi pi-search"
onClick={handleSearch}
loading={busy || searchBusy}
/>
</div>
{results.length > 0 ? (
<div className="table-scroll-wrap table-scroll-medium">
<DataTable
value={results}
selectionMode="single"
selection={selected}
onSelectionChange={(e) => setSelected(e.value)}
dataKey="mbId"
size="small"
scrollable
scrollHeight="16rem"
emptyMessage="Keine Treffer"
>
<Column header="Album" body={mbTitleBody} />
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
<Column field="country" header="Land" style={{ width: '6rem' }} />
</DataTable>
</div>
) : null}
{/* Manual metadata */}
<h4 style={{ marginTop: '1rem', marginBottom: '0.5rem' }}>Metadaten</h4>
<div className="metadata-grid">
<InputText
value={manualTitle}
onChange={(e) => setManualTitle(e.target.value)}
placeholder="Album-Titel"
/>
<InputText
value={manualArtist}
onChange={(e) => setManualArtist(e.target.value)}
placeholder="Interpret / Band"
/>
<InputNumber
value={manualYear}
onValueChange={(e) => setManualYear(e.value)}
placeholder="Jahr"
useGrouping={false}
min={1900}
max={2100}
/>
</div>
{/* Track selection/editing moved to CD-Rip configuration panel */}
{tocTracks.length > 0 ? (
<small style={{ display: 'block', marginTop: '0.9rem' }}>
{tocTracks.length} Tracks erkannt. Auswahl/Feinschliff (Checkboxen, Interpret, Titel, Länge) erfolgt im nächsten Schritt in der Job-Übersicht.
</small>
) : null}
<div className="dialog-actions" style={{ marginTop: '1rem' }}>
<Button label="Abbrechen" severity="secondary" text onClick={onHide} />
<Button
label="Weiter"
icon="pi pi-arrow-right"
onClick={handleSubmit}
loading={busy}
disabled={!manualTitle.trim() && !context?.detectedTitle}
/>
</div>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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', 'movie_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,367 @@ 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 cdRaw = ep.cd?.raw || defaultCd;
const cdMovies = ep.cd?.movies || cdRaw;
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>
<code>{cdRaw}</code>
{isDefault(cdRaw, defaultCd) && <span className="path-default-badge">Standard</span>}
</td>
<td>
<code>{cdMovies}</code>
{isDefault(cdMovies, cdRaw) && <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 +685,6 @@ export default function DynamicSettingsForm({
</TabPanel> </TabPanel>
))} ))}
</TabView> </TabView>
</div>
); );
} }

View File

@@ -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

View File

@@ -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();

View File

@@ -0,0 +1,126 @@
/**
* CD output format schemas.
* Each format defines the fields shown in CdRipConfigPanel.
*/
export const CD_FORMATS = [
{ label: 'FLAC (verlustlos)', value: 'flac' },
{ label: 'MP3', value: 'mp3' },
{ label: 'Opus', value: 'opus' },
{ label: 'OGG Vorbis', value: 'ogg' },
{ label: 'WAV (unkomprimiert)', value: 'wav' }
];
export const CD_FORMAT_SCHEMAS = {
wav: {
fields: []
},
flac: {
fields: [
{
key: 'flacCompression',
label: 'Kompressionsstufe',
description: '0 = schnell / wenig Kompression, 8 = maximale Kompression',
type: 'slider',
min: 0,
max: 8,
step: 1,
default: 5
}
]
},
mp3: {
fields: [
{
key: 'mp3Mode',
label: 'Modus',
type: 'select',
options: [
{ label: 'CBR (Konstante Bitrate)', value: 'cbr' },
{ label: 'VBR (Variable Bitrate)', value: 'vbr' }
],
default: 'cbr'
},
{
key: 'mp3Bitrate',
label: 'Bitrate (kbps)',
type: 'select',
showWhen: { field: 'mp3Mode', value: 'cbr' },
options: [
{ label: '128 kbps', value: 128 },
{ label: '160 kbps', value: 160 },
{ label: '192 kbps', value: 192 },
{ label: '256 kbps', value: 256 },
{ label: '320 kbps', value: 320 }
],
default: 192
},
{
key: 'mp3Quality',
label: 'VBR Qualität (V0V9)',
description: '0 = beste Qualität, 9 = kleinste Datei',
type: 'slider',
min: 0,
max: 9,
step: 1,
showWhen: { field: 'mp3Mode', value: 'vbr' },
default: 4
}
]
},
opus: {
fields: [
{
key: 'opusBitrate',
label: 'Bitrate (kbps)',
description: 'Empfohlen: 96192 kbps für Musik',
type: 'slider',
min: 32,
max: 512,
step: 8,
default: 160
},
{
key: 'opusComplexity',
label: 'Encoder-Komplexität',
description: '0 = schnell, 10 = beste Qualität',
type: 'slider',
min: 0,
max: 10,
step: 1,
default: 10
}
]
},
ogg: {
fields: [
{
key: 'oggQuality',
label: 'Qualität',
description: '-1 = kleinste Datei, 10 = beste Qualität. Empfohlen: 57.',
type: 'slider',
min: -1,
max: 10,
step: 1,
default: 6
}
]
}
};
export function getDefaultFormatOptions(format) {
const schema = CD_FORMAT_SCHEMAS[format];
if (!schema) {
return {};
}
const defaults = {};
for (const field of schema.fields) {
if (field.default !== undefined) {
defaults[field.key] = field.default;
}
}
return defaults;
}

View File

@@ -9,12 +9,14 @@ import { InputNumber } from 'primereact/inputnumber';
import { api } from '../api/client'; import { api } from '../api/client';
import PipelineStatusCard from '../components/PipelineStatusCard'; import PipelineStatusCard from '../components/PipelineStatusCard';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog'; import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import CdMetadataDialog from '../components/CdMetadataDialog';
import CdRipConfigPanel from '../components/CdRipConfigPanel';
import blurayIndicatorIcon from '../assets/media-bluray.svg'; import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg'; import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg'; import otherIndicatorIcon from '../assets/media-other.svg';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation'; import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING']; const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
const dashboardStatuses = new Set([ const dashboardStatuses = new Set([
'ANALYZING', 'ANALYZING',
'METADATA_SELECTION', 'METADATA_SELECTION',
@@ -25,7 +27,12 @@ const dashboardStatuses = new Set([
'RIPPING', 'RIPPING',
'ENCODING', 'ENCODING',
'CANCELLED', 'CANCELLED',
'ERROR' 'ERROR',
'CD_METADATA_SELECTION',
'CD_READY_TO_RIP',
'CD_ANALYZING',
'CD_RIPPING',
'CD_ENCODING'
]); ]);
function normalizeJobId(value) { function normalizeJobId(value) {
@@ -213,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),
@@ -341,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
@@ -362,32 +374,44 @@ 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((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';
} }
function mediaIndicatorMeta(job) { function mediaIndicatorMeta(job) {
const mediaType = resolveMediaType(job); const mediaType = resolveMediaType(job);
return mediaType === 'bluray' if (mediaType === 'bluray') {
? { return { mediaType, src: blurayIndicatorIcon, alt: 'Blu-ray', title: 'Blu-ray' };
mediaType,
src: blurayIndicatorIcon,
alt: 'Blu-ray',
title: 'Blu-ray'
} }
: mediaType === 'dvd' if (mediaType === 'dvd') {
? { return { mediaType, src: discIndicatorIcon, alt: 'DVD', title: 'DVD' };
mediaType,
src: discIndicatorIcon,
alt: 'DVD',
title: 'DVD'
} }
: { if (mediaType === 'cd') {
mediaType, return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
src: otherIndicatorIcon, }
alt: 'Sonstiges Medium', return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' };
title: 'Sonstiges Medium'
};
} }
function JobStepChecks({ backupSuccess, encodeSuccess }) { function JobStepChecks({ backupSuccess, encodeSuccess }) {
@@ -423,7 +447,143 @@ 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 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)
? makemkvInfo.tracks
.map((track) => {
const position = Number(track?.position);
if (!Number.isFinite(position) || position <= 0) {
return null;
}
return {
...track,
position: Math.trunc(position),
selected: track?.selected !== false
};
})
.filter(Boolean)
: [];
const cdSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? 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 devicePath = String(job?.disc_device || '').trim() || null;
const firstConfiguredTrack = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0
? Number(encodePlan.selectedTracks[0])
: null;
const fallbackTrack = cdTracks[0]?.position ? Number(cdTracks[0].position) : null;
const previewTrackPos = Number.isFinite(firstConfiguredTrack) && firstConfiguredTrack > 0
? Math.trunc(firstConfiguredTrack)
: (Number.isFinite(fallbackTrack) && fallbackTrack > 0 ? Math.trunc(fallbackTrack) : null);
const previewWavPath = previewTrackPos && job?.raw_path
? `${job.raw_path}/track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`
: '<temp>/trackNN.cdda.wav';
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
const selectedMetadata = {
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
year: cdSelectedMeta?.year ?? job?.year ?? null,
mbId: resolvedCdMbId,
coverUrl: resolvedCdCoverUrl,
imdbId: job?.imdb_id || 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);
const inputPath = isPreRip const inputPath = isPreRip
@@ -467,18 +627,21 @@ 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),
lastState,
devicePath,
cdparanoiaCmd,
cdparanoiaCommandPreview,
cdRipConfig,
tracks: cdTracks,
inputPath, inputPath,
hasEncodableTitle, hasEncodableTitle,
reviewConfirmed, reviewConfirmed,
mode, mode,
sourceJobId: encodePlan?.sourceJobId || null, sourceJobId: encodePlan?.sourceJobId || null,
selectedMetadata: { selectedMetadata,
title: job?.title || job?.detected_title || null,
year: job?.year || null,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || null
},
mediaInfoReview: encodePlan, mediaInfoReview: encodePlan,
playlistAnalysis: analyzeContext.playlistAnalysis || null, playlistAnalysis: analyzeContext.playlistAnalysis || null,
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired), playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
@@ -502,6 +665,10 @@ 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)
? existingContext.tracks
: computedContext.tracks,
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata, selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
canRestartEncodeFromLastSettings: canRestartEncodeFromLastSettings:
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings, existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
@@ -515,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,
@@ -522,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
}; };
} }
@@ -547,6 +728,9 @@ export default function DashboardPage({
}; };
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null); const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdRipPanelJobId, setCdRipPanelJobId] = useState(null);
const [cancelCleanupDialog, setCancelCleanupDialog] = useState({ const [cancelCleanupDialog, setCancelCleanupDialog] = useState({
visible: false, visible: false,
jobId: null, jobId: null,
@@ -664,6 +848,24 @@ export default function DashboardPage({
} }
}, [pipeline?.state, metadataDialogVisible, metadataDialogContext?.jobId]); }, [pipeline?.state, metadataDialogVisible, metadataDialogContext?.jobId]);
// Auto-open CD metadata dialog when pipeline enters CD_METADATA_SELECTION
useEffect(() => {
const currentState = String(pipeline?.state || '').trim().toUpperCase();
if (currentState === 'CD_METADATA_SELECTION') {
const ctx = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : null;
if (ctx?.jobId && !cdMetadataDialogVisible) {
setCdMetadataDialogContext(ctx);
setCdMetadataDialogVisible(true);
}
}
if (currentState === 'CD_READY_TO_RIP') {
const ctx = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : null;
if (ctx?.jobId) {
setCdRipPanelJobId(ctx.jobId);
}
}
}, [pipeline?.state, pipeline?.context?.jobId]);
useEffect(() => { useEffect(() => {
setQueueState(normalizeQueue(pipeline?.queue)); setQueueState(normalizeQueue(pipeline?.queue));
}, [pipeline?.queue]); }, [pipeline?.queue]);
@@ -1119,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);
@@ -1151,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);
@@ -1173,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 {
@@ -1322,6 +1528,70 @@ export default function DashboardPage({
} }
}; };
const handleMusicBrainzSearch = async (query) => {
try {
const response = await api.searchMusicBrainz(query);
return response.results || [];
} catch (error) {
showError(error);
return [];
}
};
const handleMusicBrainzReleaseFetch = async (mbId) => {
try {
const response = await api.getMusicBrainzRelease(mbId);
return response?.release || null;
} catch (error) {
showError(error);
return null;
}
};
const handleCdMetadataSubmit = async (payload) => {
setBusy(true);
try {
await api.selectCdMetadata(payload);
await refreshPipeline();
await loadDashboardJobs();
setCdMetadataDialogVisible(false);
setCdMetadataDialogContext(null);
} catch (error) {
showError(error);
} finally {
setBusy(false);
}
};
const handleCdRipStart = async (jobId, ripConfig) => {
if (!jobId) {
return;
}
const normalizedJobId = normalizeJobId(jobId);
if (normalizedJobId) {
setJobBusy(normalizedJobId, true);
}
try {
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 loadDashboardJobs();
if (replacementJobId) {
setExpandedJobId(replacementJobId);
}
} catch (error) {
showError(error);
} finally {
if (normalizedJobId) {
setJobBusy(normalizedJobId, false);
}
}
};
const device = lastDiscEvent || pipeline?.context?.device; const device = lastDiscEvent || pipeline?.context?.device;
const canReanalyze = state === 'ENCODING' const canReanalyze = state === 'ENCODING'
? Boolean(device) ? Boolean(device)
@@ -1656,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>
@@ -1994,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))
@@ -2034,6 +2317,32 @@ export default function DashboardPage({
disabled={busyJobIds.has(jobId)} disabled={busyJobIds.has(jobId)}
/> />
</div> </div>
{(() => {
if (isCdJob) {
return (
<>
{isCdJob ? (
<CdRipConfigPanel
pipeline={pipelineForJob}
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
onCancel={() => handleCancel(jobId, jobState)}
onRetry={() => handleRetry(jobId)}
onOpenMetadata={() => {
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
? pipelineForJob.context
: pipeline?.context || {};
setCdMetadataDialogContext({ ...ctx, jobId });
setCdMetadataDialogVisible(true);
}}
busy={busyJobIds.has(jobId)}
/>
) : null}
</>
);
}
return null;
})()}
{!isCdJob ? (
<PipelineStatusCard <PipelineStatusCard
pipeline={pipelineForJob} pipeline={pipelineForJob}
onAnalyze={handleAnalyze} onAnalyze={handleAnalyze}
@@ -2051,6 +2360,7 @@ export default function DashboardPage({
busy={busyJobIds.has(jobId)} busy={busyJobIds.has(jobId)}
liveJobLog={isCurrentSession ? liveJobLog : ''} liveJobLog={isCurrentSession ? liveJobLog : ''}
/> />
) : null}
</div> </div>
); );
} }
@@ -2165,6 +2475,19 @@ export default function DashboardPage({
busy={busy} busy={busy}
/> />
<CdMetadataDialog
visible={cdMetadataDialogVisible}
context={cdMetadataDialogContext || pipeline?.context || {}}
onHide={() => {
setCdMetadataDialogVisible(false);
setCdMetadataDialogContext(null);
}}
onSubmit={handleCdMetadataSubmit}
onSearch={handleMusicBrainzSearch}
onFetchRelease={handleMusicBrainzReleaseFetch}
busy={busy}
/>
<Dialog <Dialog
header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'} header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'}
visible={Boolean(cancelCleanupDialog.visible)} visible={Boolean(cancelCleanupDialog.visible)}

View File

@@ -42,6 +42,9 @@ 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'].includes(raw)) {
return 'cd';
}
} }
return 'other'; return 'other';
} }
@@ -467,7 +470,7 @@ export default function DatabasePage() {
const handleImportOrphanRaw = async (row) => { const handleImportOrphanRaw = async (row) => {
const target = row?.rawPath || row?.folderName || '-'; const target = row?.rawPath || row?.folderName || '-';
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`); const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen und direkt scannen?`);
if (!confirmed) { if (!confirmed) {
return; return;
} }
@@ -475,12 +478,32 @@ export default function DatabasePage() {
setOrphanImportBusyPath(row.rawPath); setOrphanImportBusyPath(row.rawPath);
try { try {
const response = await api.importOrphanRawFolder(row.rawPath); const response = await api.importOrphanRawFolder(row.rawPath);
const newJobId = response?.job?.id;
if (newJobId) {
try {
await api.reencodeJob(newJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Job angelegt & Scan gestartet',
detail: `Historieneintrag #${newJobId} erstellt, Mediainfo-Scan läuft.`,
life: 4000
});
} catch (scanError) {
toastRef.current?.show({
severity: 'info',
summary: 'Job angelegt',
detail: `Historieneintrag #${newJobId} erstellt. Scan konnte nicht automatisch gestartet werden: ${scanError.message}`,
life: 6000
});
}
} else {
toastRef.current?.show({ toastRef.current?.show({
severity: 'success', severity: 'success',
summary: 'Job angelegt', summary: 'Job angelegt',
detail: `Historieneintrag #${response?.job?.id || '-'} wurde erstellt.`, detail: `Historieneintrag wurde erstellt.`,
life: 3500 life: 3500
}); });
}
await load(); await load();
} catch (error) { } catch (error) {
toastRef.current?.show({ toastRef.current?.show({

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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 {
@@ -1095,6 +1115,125 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
.cd-rip-config-panel {
display: grid;
gap: 0.85rem;
}
.cd-meta-summary {
border: 1px solid var(--rip-border);
border-radius: 0.45rem;
background: var(--rip-panel-soft);
padding: 0.55rem 0.65rem;
display: grid;
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 {
display: grid;
gap: 0.35rem;
}
.cd-format-field label {
font-weight: 600;
}
.cd-format-field small {
color: var(--rip-muted);
}
.cd-track-selection {
border: 1px solid var(--rip-border);
border-radius: 0.5rem;
background: var(--rip-panel-soft);
padding: 0.65rem;
display: grid;
gap: 0.55rem;
}
.cd-track-list {
overflow-x: auto;
}
.cd-track-table {
width: 100%;
min-width: 44rem;
border-collapse: collapse;
}
.cd-track-table th,
.cd-track-table td {
border-bottom: 1px solid var(--rip-border);
padding: 0.45rem 0.5rem;
text-align: left;
vertical-align: middle;
}
.cd-track-table thead th {
color: var(--rip-muted);
font-size: 0.8rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.cd-track-table tbody tr.selected {
background: rgba(175, 114, 7, 0.09);
}
.cd-track-table td.check,
.cd-track-table th.check {
width: 4.5rem;
text-align: center;
}
.cd-track-table td.num,
.cd-track-table th.num {
width: 4rem;
white-space: nowrap;
}
.cd-track-table td.duration,
.cd-track-table th.duration {
width: 5.5rem;
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.title .p-inputtext {
width: 100%;
}
.device-meta { .device-meta {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1163,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;
@@ -1186,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;
@@ -1634,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%;
@@ -2046,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;
@@ -2199,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,
@@ -2208,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;
} }
@@ -2220,6 +2566,19 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
} }
.cd-track-table {
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;
} }
@@ -2291,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;
} }
@@ -2323,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;
} }

View File

@@ -12,7 +12,12 @@ const STATUS_LABELS = {
POST_ENCODE_SCRIPTS: 'Nachbearbeitung', POST_ENCODE_SCRIPTS: 'Nachbearbeitung',
FINISHED: 'Fertig', FINISHED: 'Fertig',
CANCELLED: 'Abgebrochen', CANCELLED: 'Abgebrochen',
ERROR: 'Fehler' ERROR: 'Fehler',
CD_ANALYZING: 'CD-Analyse',
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
CD_READY_TO_RIP: 'CD bereit zum Rippen',
CD_RIPPING: 'CD rippen',
CD_ENCODING: 'CD encodieren'
}; };
const PROCESS_STATUS_LABELS = { const PROCESS_STATUS_LABELS = {
@@ -46,6 +51,8 @@ export function getStatusSeverity(status, options = {}) {
if (normalized === 'ERROR') return 'danger'; if (normalized === 'ERROR') return 'danger';
if (normalized === 'READY_TO_START' || normalized === 'READY_TO_ENCODE') return 'info'; if (normalized === 'READY_TO_START' || normalized === 'READY_TO_ENCODE') return 'info';
if (normalized === 'WAITING_FOR_USER_DECISION') return 'warning'; if (normalized === 'WAITING_FOR_USER_DECISION') return 'warning';
if (normalized === 'CD_READY_TO_RIP') return 'info';
if (normalized === 'CD_METADATA_SELECTION') return 'warning';
if ( if (
normalized === 'RIPPING' normalized === 'RIPPING'
|| normalized === 'ENCODING' || normalized === 'ENCODING'
@@ -53,6 +60,9 @@ export function getStatusSeverity(status, options = {}) {
|| normalized === 'MEDIAINFO_CHECK' || normalized === 'MEDIAINFO_CHECK'
|| normalized === 'METADATA_SELECTION' || normalized === 'METADATA_SELECTION'
|| normalized === 'POST_ENCODE_SCRIPTS' || normalized === 'POST_ENCODE_SCRIPTS'
|| normalized === 'CD_ANALYZING'
|| normalized === 'CD_RIPPING'
|| normalized === 'CD_ENCODING'
) { ) {
return 'warning'; return 'warning';
} }
@@ -69,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' },

View File

@@ -143,7 +143,12 @@ install_makemkv() {
header "MakeMKV installieren" header "MakeMKV installieren"
if command_exists makemkvcon; then if command_exists makemkvcon; then
ok "makemkvcon bereits installiert ($(makemkvcon --version 2>&1 | head -1))" local mk_version_line
mk_version_line="$(makemkvcon --version 2>&1 | head -1 || true)"
if [[ -z "$mk_version_line" || "$mk_version_line" == *"unrecognized option"* ]]; then
mk_version_line="$(makemkvcon 2>&1 | head -1 || true)"
fi
ok "makemkvcon bereits installiert (${mk_version_line:-Version unbekannt})"
return return
fi fi
@@ -658,6 +663,15 @@ else
ok "Benutzer '$SERVICE_USER' angelegt" ok "Benutzer '$SERVICE_USER' angelegt"
fi 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 # Optisches Laufwerk: Benutzer zur cdrom/optical-Gruppe hinzufügen
for grp in cdrom optical disk; do for grp in cdrom optical disk; do
if getent group "$grp" &>/dev/null; then if getent group "$grp" &>/dev/null; then
@@ -767,22 +781,15 @@ chmod -R 755 "$INSTALL_DIR"
chmod 600 "$ENV_FILE" chmod 600 "$ENV_FILE"
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis. # MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
ACTUAL_USER="${SUDO_USER:-}" MAKEMKV_SERVICE_DIR="${SERVICE_HOME}/.MakeMKV"
if [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]]; then if [[ ! -d "$MAKEMKV_SERVICE_DIR" ]]; then
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" | cut -d: -f6)" mkdir -p "$MAKEMKV_SERVICE_DIR"
if [[ -z "$ACTUAL_HOME" ]]; then ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_SERVICE_DIR"
ACTUAL_HOME="/home/$ACTUAL_USER"
fi
MAKEMKV_USER_DIR="${ACTUAL_HOME}/.MakeMKV"
if [[ ! -d "$MAKEMKV_USER_DIR" ]]; then
mkdir -p "$MAKEMKV_USER_DIR"
ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_USER_DIR"
else else
info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_USER_DIR" info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_SERVICE_DIR"
fi
chown "$ACTUAL_USER:$ACTUAL_USER" "$MAKEMKV_USER_DIR" 2>/dev/null || true
chmod 700 "$MAKEMKV_USER_DIR" 2>/dev/null || true
fi 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 ------------------------------------------------- # --- Systemd-Dienst: Backend -------------------------------------------------
header "Systemd-Dienst (Backend) erstellen" header "Systemd-Dienst (Backend) erstellen"
@@ -807,6 +814,10 @@ StartLimitBurst=3
# Umgebung # Umgebung
Environment=NODE_ENV=production 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 EnvironmentFile=${INSTALL_DIR}/backend/.env
# Logging # Logging
@@ -820,7 +831,7 @@ SyslogIdentifier=ripster-backend
NoNewPrivileges=false NoNewPrivileges=false
ProtectSystem=full ProtectSystem=full
ProtectHome=read-only ProtectHome=read-only
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
PrivateTmp=true PrivateTmp=true
[Install] [Install]

View File

@@ -27,6 +27,7 @@
set -euo pipefail set -euo pipefail
REPO_URL="https://github.com/Mboehmlaender/ripster.git" REPO_URL="https://github.com/Mboehmlaender/ripster.git"
REPO_RAW_BASE="https://raw.githubusercontent.com/Mboehmlaender/ripster"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)"
BUNDLED_HANDBRAKE_CLI="${SCRIPT_DIR}/bin/HandBrakeCLI" BUNDLED_HANDBRAKE_CLI="${SCRIPT_DIR}/bin/HandBrakeCLI"
@@ -106,6 +107,23 @@ info "Frontend-Host: $FRONTEND_HOST"
command_exists() { command -v "$1" &>/dev/null; } command_exists() { command -v "$1" &>/dev/null; }
download_file() {
local url="$1"
local target="$2"
if command_exists curl; then
curl -fsSL "$url" -o "$target"
return 0
fi
if command_exists wget; then
wget -q "$url" -O "$target"
return 0
fi
return 1
}
install_node() { install_node() {
header "Node.js installieren" header "Node.js installieren"
local required_major=20 local required_major=20
@@ -234,13 +252,28 @@ install_handbrake_standard() {
install_handbrake_gpu_bundled() { install_handbrake_gpu_bundled() {
info "Installiere gebündeltes HandBrakeCLI mit NVDEC..." info "Installiere gebündeltes HandBrakeCLI mit NVDEC..."
local bundled_source="$BUNDLED_HANDBRAKE_CLI"
local downloaded_tmp=""
if [[ ! -f "$BUNDLED_HANDBRAKE_CLI" ]]; then if [[ ! -f "$bundled_source" ]]; then
fatal "Bundled Binary fehlt: ./bin/HandBrakeCLI (aufgelöst zu: $BUNDLED_HANDBRAKE_CLI)" local remote_url="${REPO_RAW_BASE}/${GIT_BRANCH}/bin/HandBrakeCLI"
downloaded_tmp=$(mktemp)
info "Lokale Binary fehlt lade aus Branch '$GIT_BRANCH' nach..."
if download_file "$remote_url" "$downloaded_tmp"; then
chmod 0755 "$downloaded_tmp"
bundled_source="$downloaded_tmp"
ok "Bundled HandBrakeCLI temporär heruntergeladen"
else
rm -f "$downloaded_tmp" 2>/dev/null || true
fatal "Bundled Binary fehlt lokal ($BUNDLED_HANDBRAKE_CLI) und Download schlug fehl: $remote_url"
fi
fi fi
install -m 0755 "$BUNDLED_HANDBRAKE_CLI" /usr/local/bin/HandBrakeCLI install -m 0755 "$bundled_source" /usr/local/bin/HandBrakeCLI
hash -r 2>/dev/null || true hash -r 2>/dev/null || true
if [[ -n "$downloaded_tmp" ]]; then
rm -f "$downloaded_tmp" 2>/dev/null || true
fi
ok "Bundled HandBrakeCLI installiert nach /usr/local/bin/HandBrakeCLI" ok "Bundled HandBrakeCLI installiert nach /usr/local/bin/HandBrakeCLI"
if command_exists HandBrakeCLI; then if command_exists HandBrakeCLI; then
@@ -351,7 +384,7 @@ apt_update
info "Installiere Basispakete..." info "Installiere Basispakete..."
apt-get install -y \ apt-get install -y \
curl wget git \ curl wget git jq \
mediainfo \ mediainfo \
util-linux udev \ util-linux udev \
ca-certificates gnupg \ ca-certificates gnupg \
@@ -359,6 +392,16 @@ apt-get install -y \
ok "Basispakete installiert" 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 install_node
if [[ "$SKIP_MAKEMKV" == false ]]; then if [[ "$SKIP_MAKEMKV" == false ]]; then
@@ -392,6 +435,15 @@ else
ok "Benutzer '$SERVICE_USER' angelegt" ok "Benutzer '$SERVICE_USER' angelegt"
fi 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 for grp in cdrom optical disk video render; do
if getent group "$grp" &>/dev/null; then if getent group "$grp" &>/dev/null; then
usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true
@@ -402,6 +454,13 @@ done
# --- Repository klonen / aktualisieren ---------------------------------------- # --- Repository klonen / aktualisieren ----------------------------------------
header "Repository holen (Git)" 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 [[ -d "$INSTALL_DIR/.git" ]]; then
if [[ "$REINSTALL" == true ]]; then if [[ "$REINSTALL" == true ]]; then
info "Aktualisiere bestehendes Repository..." info "Aktualisiere bestehendes Repository..."
@@ -414,8 +473,10 @@ if [[ -d "$INSTALL_DIR/.git" ]]; then
# safe.directory nötig wenn das Verzeichnis einem anderen User gehört # safe.directory nötig wenn das Verzeichnis einem anderen User gehört
# (z.B. ripster-Serviceuser nach erstem Install) # (z.B. ripster-Serviceuser nach erstem Install)
git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true 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" fetch --quiet origin
git -C "$INSTALL_DIR" checkout --quiet "$GIT_BRANCH" 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" git -C "$INSTALL_DIR" reset --hard "origin/$GIT_BRANCH"
ok "Repository aktualisiert auf Branch '$GIT_BRANCH'" ok "Repository aktualisiert auf Branch '$GIT_BRANCH'"
else else
@@ -434,6 +495,7 @@ mkdir -p "$INSTALL_DIR/backend/data"
mkdir -p "$INSTALL_DIR/backend/logs" mkdir -p "$INSTALL_DIR/backend/logs"
mkdir -p "$INSTALL_DIR/backend/data/output/raw" mkdir -p "$INSTALL_DIR/backend/data/output/raw"
mkdir -p "$INSTALL_DIR/backend/data/output/movies" mkdir -p "$INSTALL_DIR/backend/data/output/movies"
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
mkdir -p "$INSTALL_DIR/backend/data/logs" mkdir -p "$INSTALL_DIR/backend/data/logs"
# Gesicherte Daten zurückspielen # Gesicherte Daten zurückspielen
@@ -490,6 +552,11 @@ LOG_LEVEL=info
# CORS: Erlaube Anfragen vom Frontend (nginx) # CORS: Erlaube Anfragen vom Frontend (nginx)
CORS_ORIGIN=http://${FRONTEND_HOST} CORS_ORIGIN=http://${FRONTEND_HOST}
# Standard-Ausgabepfade (Fallback wenn in den Einstellungen kein Pfad gesetzt)
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
EOF EOF
ok "Backend .env erstellt" ok "Backend .env erstellt"
fi fi
@@ -510,25 +577,22 @@ if [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]]; then
"$INSTALL_DIR/backend/data/output" \ "$INSTALL_DIR/backend/data/output" \
"$INSTALL_DIR/backend/data/logs" "$INSTALL_DIR/backend/data/logs"
ok "Verzeichnisse $ACTUAL_USER:$SERVICE_USER (775) zugewiesen" ok "Verzeichnisse $ACTUAL_USER:$SERVICE_USER (775) zugewiesen"
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" | cut -d: -f6)"
if [[ -z "$ACTUAL_HOME" ]]; then
ACTUAL_HOME="/home/$ACTUAL_USER"
fi
MAKEMKV_USER_DIR="${ACTUAL_HOME}/.MakeMKV"
if [[ ! -d "$MAKEMKV_USER_DIR" ]]; then
mkdir -p "$MAKEMKV_USER_DIR"
ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_USER_DIR"
else
info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_USER_DIR"
fi
chown "$ACTUAL_USER:$ACTUAL_USER" "$MAKEMKV_USER_DIR" 2>/dev/null || true
chmod 700 "$MAKEMKV_USER_DIR" 2>/dev/null || true
else else
ok "Verzeichnisse bereits $SERVICE_USER gehörig (kein SUDO_USER erkannt)" ok "Verzeichnisse bereits $SERVICE_USER gehörig (kein SUDO_USER erkannt)"
fi 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 ------------------------------------------------- # --- Systemd-Dienst: Backend -------------------------------------------------
header "Systemd-Dienst (Backend) erstellen" header "Systemd-Dienst (Backend) erstellen"
@@ -550,6 +614,10 @@ StartLimitIntervalSec=60
StartLimitBurst=3 StartLimitBurst=3
Environment=NODE_ENV=production 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 EnvironmentFile=${INSTALL_DIR}/backend/.env
StandardOutput=journal StandardOutput=journal
@@ -566,7 +634,7 @@ SupplementaryGroups=video render cdrom disk
NoNewPrivileges=false NoNewPrivileges=false
ProtectSystem=full ProtectSystem=full
ProtectHome=read-only ProtectHome=read-only
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
PrivateTmp=true PrivateTmp=true
[Install] [Install]
@@ -676,6 +744,11 @@ missing_tools=()
command_exists makemkvcon || missing_tools+=("makemkvcon") command_exists makemkvcon || missing_tools+=("makemkvcon")
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI") command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
command_exists mediainfo || missing_tools+=("mediainfo") 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 if [[ ${#missing_tools[@]} -gt 0 ]]; then
echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:" echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:"

154
setup.sh Executable file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_OWNER="Mboehmlaender"
REPO_NAME="ripster"
REPO_RAW_BASE="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}"
BRANCHES_API_URL="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/branches?per_page=100"
usage() {
cat <<'EOF'
Verwendung:
bash setup.sh [Optionen]
Optionen (wie install.sh):
--branch <branch> Branch direkt setzen (ohne Auswahlmenue)
--dir <pfad> Installationsverzeichnis
--user <benutzer> Systembenutzer fuer den Dienst
--port <port> Backend-Port
--host <hostname> Hostname/IP fuer die Weboberflaeche
--no-makemkv MakeMKV-Installation ueberspringen
--no-handbrake HandBrake-Installation ueberspringen
--no-nginx Nginx-Einrichtung ueberspringen
--reinstall Vorhandene Installation aktualisieren
-h, --help Hilfe anzeigen
EOF
}
SELECTED_BRANCH=""
FORWARDED_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--branch)
[[ $# -ge 2 ]] || { echo "Fehlender Wert fuer --branch" >&2; exit 1; }
SELECTED_BRANCH="$2"
shift 2
;;
--dir|--user|--port|--host)
[[ $# -ge 2 ]] || { echo "Fehlender Wert fuer $1" >&2; exit 1; }
FORWARDED_ARGS+=("$1" "$2")
shift 2
;;
--no-makemkv|--no-handbrake|--no-nginx|--reinstall)
FORWARDED_ARGS+=("$1")
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unbekannter Parameter: $1" >&2
usage >&2
exit 1
;;
esac
done
fetch_url() {
local url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -qO- "$url"
return
fi
echo "Weder curl noch wget gefunden. Bitte eines davon installieren." >&2
exit 1
}
download_file() {
local url="$1"
local target="$2"
fetch_url "$url" > "$target"
}
select_branch() {
local branches_json
local -a branches
local selection
branches_json="$(fetch_url "$BRANCHES_API_URL")"
mapfile -t branches < <(
printf '%s\n' "$branches_json" \
| grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \
| sed -E 's/"name"[[:space:]]*:[[:space:]]*"([^"]+)"/\1/'
)
if [[ ${#branches[@]} -eq 0 ]]; then
echo "Keine Branches gefunden oder API-Antwort ungültig." >&2
exit 1
fi
if [[ -n "$SELECTED_BRANCH" ]]; then
local found=false
for branch in "${branches[@]}"; do
if [[ "$branch" == "$SELECTED_BRANCH" ]]; then
found=true
break
fi
done
if [[ "$found" == false ]]; then
echo "Branch '$SELECTED_BRANCH' nicht gefunden." >&2
exit 1
fi
return
fi
if [[ ! -t 0 ]]; then
echo "Kein interaktives Terminal für die Branch-Auswahl verfügbar." >&2
exit 1
fi
echo "Verfügbare Branches:"
for i in "${!branches[@]}"; do
printf " %2d) %s\n" "$((i + 1))" "${branches[$i]}"
done
while true; do
read -r -p "Bitte Branch auswählen [1-${#branches[@]}]: " selection
if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#branches[@]} )); then
SELECTED_BRANCH="${branches[$((selection - 1))]}"
return
fi
echo "Ungültige Auswahl."
done
}
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
select_branch
INSTALL_SCRIPT="${TMP_DIR}/install.sh"
INSTALL_URL="${REPO_RAW_BASE}/${SELECTED_BRANCH}/install.sh"
echo "Lade install.sh aus Branch '${SELECTED_BRANCH}'..."
download_file "$INSTALL_URL" "$INSTALL_SCRIPT"
chmod +x "$INSTALL_SCRIPT"
if [[ $EUID -eq 0 ]]; then
bash "$INSTALL_SCRIPT" --branch "$SELECTED_BRANCH" "${FORWARDED_ARGS[@]}"
else
if ! command -v sudo >/dev/null 2>&1; then
echo "sudo nicht gefunden. Bitte als root ausführen." >&2
exit 1
fi
sudo bash "$INSTALL_SCRIPT" --branch "$SELECTED_BRANCH" "${FORWARDED_ARGS[@]}"
fi