0.13.0 DVD Series
This commit is contained in:
@@ -23,6 +23,7 @@ module.exports = {
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
|
||||
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
||||
defaultSeriesDir: resolveOutputPath(process.env.DEFAULT_SERIES_DIR, 'output', 'series'),
|
||||
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'),
|
||||
defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'),
|
||||
defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'),
|
||||
|
||||
+152
-9
@@ -838,6 +838,18 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
||||
required: 0,
|
||||
description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
|
||||
validation_json: '{}'
|
||||
},
|
||||
{
|
||||
key: 'output_template_dvd_series_episode',
|
||||
required: 1,
|
||||
description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.',
|
||||
validation_json: '{"minLength":1}'
|
||||
},
|
||||
{
|
||||
key: 'output_template_dvd_series_multi_episode',
|
||||
required: 1,
|
||||
description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.',
|
||||
validation_json: '{"minLength":1}'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -917,6 +929,16 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'`
|
||||
);
|
||||
|
||||
// depends_on-Spalte zu settings_schema hinzufügen, bevor neue abhängige
|
||||
// Settings per INSERT OR IGNORE angelegt werden.
|
||||
{
|
||||
const cols = await db.all(`PRAGMA table_info(settings_schema)`);
|
||||
if (!cols.some((c) => c.name === 'depends_on')) {
|
||||
await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`);
|
||||
logger.info('migrate:settings-schema-add-depends_on');
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -986,6 +1008,87 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_plugin_enabled', 'Features', 'DVD Serien-Plugin aktiviert', 'boolean', 1, 'Aktiviert die additive Serienerkennung für DVDs. Wenn deaktiviert, bleibt der bestehende DVD-Film-Flow unverändert.', 'false', '[]', '{}', 145)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_plugin_enabled', 'false')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||
VALUES ('dvd_series_prescan_enabled', 'Features', 'DVD HandBrake-Prescan aktiviert', 'boolean', 1, 'Führt nach der DVD-Erkennung einen zusätzlichen HandBrake-Prescan für die Serienheuristik aus.', 'true', '[]', '{}', 146, 'dvd_series_plugin_enabled')`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_prescan_enabled', 'true')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"minLength":1}', 537)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true')`);
|
||||
|
||||
await db.run(`DELETE FROM settings_values WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
|
||||
await db.run(`DELETE FROM settings_schema WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
|
||||
|
||||
// Converter-Pfade und Eigentümer
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
@@ -1077,15 +1180,6 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`);
|
||||
|
||||
// depends_on-Spalte zu settings_schema hinzufügen (falls noch nicht vorhanden)
|
||||
{
|
||||
const cols = await db.all(`PRAGMA table_info(settings_schema)`);
|
||||
if (!cols.some((c) => c.name === 'depends_on')) {
|
||||
await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`);
|
||||
logger.info('migrate:settings-schema-add-depends_on');
|
||||
}
|
||||
}
|
||||
|
||||
await db.run(`
|
||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -1093,6 +1187,55 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await db.run(`
|
||||
CREATE TABLE IF NOT EXISTS dvd_series_disc_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
provider_series_name TEXT,
|
||||
season_number INTEGER,
|
||||
season_type TEXT NOT NULL DEFAULT 'dvd',
|
||||
disc_label TEXT,
|
||||
disc_serial TEXT,
|
||||
disc_number INTEGER,
|
||||
language TEXT,
|
||||
disc_signature TEXT NOT NULL,
|
||||
fingerprint_json TEXT,
|
||||
episode_map_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature)`);
|
||||
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number)`);
|
||||
await db.run(`UPDATE dvd_series_disc_profiles SET provider = 'tmdb' WHERE provider = 'tvdb'`);
|
||||
|
||||
await db.run(`
|
||||
CREATE TABLE IF NOT EXISTS dvd_series_title_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_job_id INTEGER NOT NULL,
|
||||
disc_profile_id INTEGER,
|
||||
title_index INTEGER NOT NULL,
|
||||
title_kind TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0,
|
||||
selected INTEGER NOT NULL DEFAULT 1,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
season_number INTEGER,
|
||||
episode_start INTEGER,
|
||||
episode_end INTEGER,
|
||||
episode_ids_json TEXT,
|
||||
mapping_json TEXT,
|
||||
created_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 CASCADE,
|
||||
FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index)`);
|
||||
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number)`);
|
||||
await db.run(`UPDATE dvd_series_title_mappings SET provider = 'tmdb' WHERE provider = 'tvdb'`);
|
||||
}
|
||||
|
||||
async function backfillJobOutputFolders(db) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
'use strict';
|
||||
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const dvdSeriesScanService = require('../services/dvdSeriesScanService');
|
||||
const tmdbService = require('../services/tmdbService');
|
||||
|
||||
/**
|
||||
* Companion-Plugin für Serien-DVDs.
|
||||
*
|
||||
* Dieses Plugin ersetzt den bestehenden DVDPlugin-Flow nicht. Es wird in einem
|
||||
* späteren Schritt explizit aus dem DVD-Flow aufgerufen, sobald ein Prescan eine
|
||||
* serientypische Titelstruktur vermuten lässt.
|
||||
*/
|
||||
class DVDSeriesPlugin extends SourcePlugin {
|
||||
get id() {
|
||||
return 'dvd_series';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'DVD Series';
|
||||
}
|
||||
|
||||
detect() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async analyze(_devicePath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'DVDSeriesPlugin.js'
|
||||
});
|
||||
|
||||
const scanText = String(ctx.extra?.scanText || '').trim();
|
||||
if (!scanText) {
|
||||
return {
|
||||
parsedScan: null,
|
||||
seriesAnalysis: null,
|
||||
providerConfigured: await tmdbService.isConfigured()
|
||||
};
|
||||
}
|
||||
|
||||
const result = dvdSeriesScanService.analyzeHandBrakeScan(scanText, ctx.extra?.seriesOptions || {});
|
||||
const seriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([
|
||||
{
|
||||
value: result?.parsed?.discTitle || '',
|
||||
source: 'handbrake_disc_title'
|
||||
},
|
||||
{
|
||||
value: ctx.extra?.detectedTitle || '',
|
||||
source: 'detected_title'
|
||||
}
|
||||
]);
|
||||
return {
|
||||
parsedScan: result.parsed,
|
||||
seriesAnalysis: result.analysis,
|
||||
seriesLookupHint,
|
||||
providerConfigured: await tmdbService.isConfigured()
|
||||
};
|
||||
}
|
||||
|
||||
async review(_job, ctx) {
|
||||
ctx.markExecution('review', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'DVDSeriesPlugin.js'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async rip() {
|
||||
throw new Error('DVDSeriesPlugin.rip() ist ein Companion-Plugin und wird nicht direkt ausgeführt.');
|
||||
}
|
||||
|
||||
async encode() {
|
||||
throw new Error('DVDSeriesPlugin.encode() ist ein Companion-Plugin und wird nicht direkt ausgeführt.');
|
||||
}
|
||||
|
||||
async searchSeries(query, options = {}) {
|
||||
return tmdbService.searchSeries(query, options);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
|
||||
return tmdbService.searchSeriesWithSeason(query, seasonNumber, options);
|
||||
}
|
||||
|
||||
getSettingsSchema() {
|
||||
return [
|
||||
{
|
||||
key: 'dvd_series_plugin_enabled',
|
||||
category: 'Features',
|
||||
label: 'DVD Serien-Plugin aktiviert',
|
||||
type: 'boolean',
|
||||
required: 1,
|
||||
description: 'Aktiviert die additive Serienerkennung für DVDs.',
|
||||
default_value: 'false',
|
||||
options_json: '[]',
|
||||
validation_json: '{}',
|
||||
order_index: 145
|
||||
},
|
||||
{
|
||||
key: 'tmdb_api_read_access_token',
|
||||
category: 'Metadaten',
|
||||
label: 'TMDb Read Access Token',
|
||||
type: 'string',
|
||||
required: 0,
|
||||
description: 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).',
|
||||
default_value: null,
|
||||
options_json: '[]',
|
||||
validation_json: '{}',
|
||||
order_index: 401
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DVDSeriesPlugin };
|
||||
@@ -161,7 +161,9 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
cmd: scanConfig.cmd,
|
||||
args: scanConfig.args,
|
||||
collectLines: scanLines,
|
||||
collectStderrLines: false,
|
||||
// HandBrake scan details (duration/chapters/audio/subtitles) are often
|
||||
// emitted on stderr. Collect both streams for reliable disc heuristics.
|
||||
collectStderrLines: true,
|
||||
silent: reviewSilent
|
||||
});
|
||||
} catch (cmdError) {
|
||||
@@ -179,6 +181,7 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
cmd: scanConfig.cmd,
|
||||
args: scanConfig.args,
|
||||
onStdoutLine: (line) => scanLines.push(line),
|
||||
onStderrLine: (line) => scanLines.push(line),
|
||||
context: { jobId: job?.id, source: `${this.id}Plugin.review` }
|
||||
});
|
||||
}
|
||||
@@ -219,6 +222,7 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
deviceInfo,
|
||||
selectedTitleId = null,
|
||||
backupOutputBase = null,
|
||||
disableMinLengthFilter = false,
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
@@ -237,7 +241,8 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
selectedTitleId,
|
||||
mediaProfile: this.mediaProfile,
|
||||
settingsMap: settings,
|
||||
backupOutputBase
|
||||
backupOutputBase,
|
||||
disableMinLengthFilter
|
||||
});
|
||||
|
||||
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
|
||||
@@ -249,7 +254,8 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
args: ripConfig.args,
|
||||
ripMode,
|
||||
rawJobDir,
|
||||
selectedTitleId
|
||||
selectedTitleId,
|
||||
disableMinLengthFilter
|
||||
});
|
||||
|
||||
ctx.emitProgress(0, ripMode === 'backup'
|
||||
@@ -443,7 +449,7 @@ function _assertNotCancelled(isCancelled) {
|
||||
}
|
||||
}
|
||||
|
||||
async function _runCommandCaptured({ cmd, args, onStdoutLine, context }) {
|
||||
async function _runCommandCaptured({ cmd, args, onStdoutLine, onStderrLine, context }) {
|
||||
const lines = [];
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
@@ -454,7 +460,12 @@ async function _runCommandCaptured({ cmd, args, onStdoutLine, context }) {
|
||||
onStdoutLine(line);
|
||||
}
|
||||
},
|
||||
onStderrLine: () => {},
|
||||
onStderrLine: (line) => {
|
||||
lines.push(line);
|
||||
if (typeof onStderrLine === 'function') {
|
||||
onStderrLine(line);
|
||||
}
|
||||
},
|
||||
context: context || {}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,13 +18,15 @@ router.get(
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean);
|
||||
const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase());
|
||||
const includeChildren = ['1', 'true', 'yes'].includes(String(req.query.includeChildren || '').toLowerCase());
|
||||
logger.info('get:jobs', {
|
||||
reqId: req.reqId,
|
||||
status: req.query.status,
|
||||
statuses: statuses.length > 0 ? statuses : null,
|
||||
search: req.query.search,
|
||||
limit,
|
||||
lite
|
||||
lite,
|
||||
includeChildren
|
||||
});
|
||||
|
||||
const jobs = await historyService.getJobs({
|
||||
@@ -32,7 +34,8 @@ router.get(
|
||||
statuses,
|
||||
search: req.query.search,
|
||||
limit,
|
||||
includeFsChecks: !lite
|
||||
includeFsChecks: !lite,
|
||||
includeChildren
|
||||
});
|
||||
|
||||
res.json({ jobs });
|
||||
@@ -108,6 +111,16 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/error/ack',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
logger.info('post:job:error:ack', { reqId: req.reqId, id });
|
||||
const job = await historyService.acknowledgeJobError(id);
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/delete-files',
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -155,6 +155,17 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tmdb/series/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = req.query.q || '';
|
||||
const seasonNumber = req.query.season || null;
|
||||
logger.info('get:tmdb:series-search', { reqId: req.reqId, query, seasonNumber });
|
||||
const results = await pipelineService.searchTmdbSeries(String(query), seasonNumber);
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/cd/musicbrainz/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -341,7 +352,26 @@ router.get(
|
||||
router.post(
|
||||
'/select-metadata',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist, selectedHandBrakeTitleId } = req.body;
|
||||
const {
|
||||
jobId,
|
||||
title,
|
||||
year,
|
||||
imdbId,
|
||||
poster,
|
||||
fromOmdb,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
metadataKind,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
episodeCount,
|
||||
episodes,
|
||||
discNumber
|
||||
} = req.body;
|
||||
|
||||
if (!jobId) {
|
||||
const error = new Error('jobId fehlt.');
|
||||
@@ -358,7 +388,13 @@ router.post(
|
||||
poster,
|
||||
fromOmdb,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
seasonNumber,
|
||||
discNumber
|
||||
});
|
||||
|
||||
const job = await pipelineService.selectMetadata({
|
||||
@@ -369,7 +405,17 @@ router.post(
|
||||
poster,
|
||||
fromOmdb,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
metadataKind,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
episodeCount,
|
||||
episodes,
|
||||
discNumber
|
||||
});
|
||||
|
||||
res.json({ job });
|
||||
@@ -391,7 +437,9 @@ router.post(
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
|
||||
const selectedEncodeTitleIds = req.body?.selectedEncodeTitleIds ?? null;
|
||||
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
|
||||
const episodeAssignments = req.body?.episodeAssignments ?? null;
|
||||
const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds;
|
||||
const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds;
|
||||
const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds;
|
||||
@@ -403,7 +451,9 @@ router.post(
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
selectedEncodeTitleId,
|
||||
selectedEncodeTitleIdsCount: Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds.length : 0,
|
||||
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
|
||||
episodeAssignmentsProvided: Boolean(episodeAssignments && typeof episodeAssignments === 'object'),
|
||||
skipPipelineStateUpdate,
|
||||
selectedUserPresetId,
|
||||
selectedHandBrakePreset,
|
||||
@@ -422,7 +472,9 @@ router.post(
|
||||
});
|
||||
const job = await pipelineService.confirmEncodeReview(jobId, {
|
||||
selectedEncodeTitleId,
|
||||
selectedEncodeTitleIds,
|
||||
selectedTrackSelection,
|
||||
episodeAssignments,
|
||||
selectedPostEncodeScriptIds,
|
||||
selectedPreEncodeScriptIds,
|
||||
selectedPostEncodeChainIds,
|
||||
@@ -525,8 +577,19 @@ router.post(
|
||||
const jobId = Number(req.params.jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
logger.info('post:restart-encode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId, { keepBoth, deleteFolders });
|
||||
const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all';
|
||||
logger.info('post:restart-encode', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
keepBoth,
|
||||
restartMode,
|
||||
deleteFolderCount: deleteFolders.length
|
||||
});
|
||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId, {
|
||||
keepBoth,
|
||||
deleteFolders,
|
||||
restartMode
|
||||
});
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ function isSensitiveSettingKey(key) {
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return /(token|password|secret|api_key|registration_key|pushover_user)/i.test(normalized);
|
||||
return /(token|password|secret|api_key|registration_key|pushover_user|subscriber_pin)/i.test(normalized);
|
||||
}
|
||||
|
||||
router.get(
|
||||
@@ -471,6 +471,50 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/drives/force-unlock',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { devicePath, all } = req.body || {};
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const expertMode = ['1', 'true', 'yes', 'on'].includes(String(settings?.ui_expert_mode || '').toLowerCase());
|
||||
if (!expertMode) {
|
||||
const error = new Error('Expertenmodus erforderlich.');
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const devices = await diskDetectionService.getBlockDeviceInfo();
|
||||
const drives = devices
|
||||
.filter((d) => d.type === 'rom')
|
||||
.map((d) => {
|
||||
const name = String(d.name || '');
|
||||
return d.path || (name ? `/dev/${name}` : null);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const activeLockPaths = (diskDetectionService.getActiveLocks?.() || [])
|
||||
.map((entry) => String(entry?.path || '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const targetPaths = all
|
||||
? Array.from(new Set([...drives, ...activeLockPaths]))
|
||||
: [String(devicePath || '').trim()].filter(Boolean);
|
||||
if (targetPaths.length === 0) {
|
||||
const error = new Error('Kein Laufwerk angegeben.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const target of targetPaths) {
|
||||
const result = await pipelineService.forceUnlockDrive(target, { reason: 'settings_force_unlock' });
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
// User preferences (UI state, persisted per-installation)
|
||||
router.get(
|
||||
'/prefs/:key',
|
||||
|
||||
@@ -374,13 +374,67 @@ class DiskDetectionService extends EventEmitter {
|
||||
return String(devicePath || '').trim();
|
||||
}
|
||||
|
||||
lockDevice(devicePath, owner = null) {
|
||||
_resolveDeviceRealPath(devicePath) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized || !normalized.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (fs.realpathSync && typeof fs.realpathSync.native === 'function') {
|
||||
return fs.realpathSync.native(normalized);
|
||||
}
|
||||
return fs.realpathSync(normalized);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_deviceBaseName(devicePath) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
return String(parts[parts.length - 1] || '').trim();
|
||||
}
|
||||
|
||||
_isSameDevicePath(leftPath, rightPath) {
|
||||
const left = this.normalizeDevicePath(leftPath);
|
||||
const right = this.normalizeDevicePath(rightPath);
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
const leftReal = this._resolveDeviceRealPath(left);
|
||||
const rightReal = this._resolveDeviceRealPath(right);
|
||||
if (leftReal && rightReal && leftReal === rightReal) {
|
||||
return true;
|
||||
}
|
||||
const leftBase = this._deviceBaseName(left);
|
||||
const rightBase = this._deviceBaseName(right);
|
||||
if (leftBase && rightBase && leftBase === rightBase && /^sr\d+$/i.test(leftBase)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_resolveLockKey(devicePath) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return this._resolveDeviceRealPath(normalized) || normalized;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(normalized) || {
|
||||
lockDevice(devicePath, owner = null) {
|
||||
const lockKey = this._resolveLockKey(devicePath);
|
||||
if (!lockKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(lockKey) || {
|
||||
count: 0,
|
||||
owners: []
|
||||
};
|
||||
@@ -389,16 +443,16 @@ class DiskDetectionService extends EventEmitter {
|
||||
if (owner) {
|
||||
entry.owners.push(owner);
|
||||
}
|
||||
this.deviceLocks.set(normalized, entry);
|
||||
this.deviceLocks.set(lockKey, entry);
|
||||
|
||||
logger.info('lock:add', {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
count: entry.count,
|
||||
owner
|
||||
});
|
||||
|
||||
return {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
owner
|
||||
};
|
||||
}
|
||||
@@ -409,24 +463,33 @@ class DiskDetectionService extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(normalized);
|
||||
const directKey = this._resolveLockKey(normalized);
|
||||
const candidateKeys = Array.from(this.deviceLocks.keys());
|
||||
const lockKey = (directKey && this.deviceLocks.has(directKey))
|
||||
? directKey
|
||||
: (candidateKeys.find((key) => this._isSameDevicePath(key, normalized)) || null);
|
||||
if (!lockKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(lockKey);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.count = Math.max(0, entry.count - 1);
|
||||
if (entry.count === 0) {
|
||||
this.deviceLocks.delete(normalized);
|
||||
this.deviceLocks.delete(lockKey);
|
||||
logger.info('lock:remove', {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
owner
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.deviceLocks.set(normalized, entry);
|
||||
this.deviceLocks.set(lockKey, entry);
|
||||
logger.info('lock:decrement', {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
count: entry.count,
|
||||
owner
|
||||
});
|
||||
@@ -438,20 +501,26 @@ class DiskDetectionService extends EventEmitter {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(normalized);
|
||||
if (!entry) {
|
||||
const candidateKeys = Array.from(this.deviceLocks.keys())
|
||||
.filter((key) => this._isSameDevicePath(key, normalized));
|
||||
if (candidateKeys.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const previousCount = Math.max(0, Number(entry.count) || 0);
|
||||
this.deviceLocks.delete(normalized);
|
||||
logger.warn('lock:force-remove', {
|
||||
devicePath: normalized,
|
||||
previousCount,
|
||||
reason: String(options?.reason || '').trim() || null,
|
||||
deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : []
|
||||
});
|
||||
return previousCount;
|
||||
let removed = 0;
|
||||
for (const lockKey of candidateKeys) {
|
||||
const entry = this.deviceLocks.get(lockKey);
|
||||
const previousCount = Math.max(0, Number(entry?.count) || 0);
|
||||
removed += previousCount;
|
||||
this.deviceLocks.delete(lockKey);
|
||||
logger.warn('lock:force-remove', {
|
||||
devicePath: lockKey,
|
||||
previousCount,
|
||||
reason: String(options?.reason || '').trim() || null,
|
||||
deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : []
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
isDeviceLocked(devicePath) {
|
||||
@@ -459,7 +528,11 @@ class DiskDetectionService extends EventEmitter {
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return this.deviceLocks.has(normalized);
|
||||
const directKey = this._resolveLockKey(normalized);
|
||||
if (directKey && this.deviceLocks.has(directKey)) {
|
||||
return true;
|
||||
}
|
||||
return Array.from(this.deviceLocks.keys()).some((key) => this._isSameDevicePath(key, normalized));
|
||||
}
|
||||
|
||||
getActiveLocks() {
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
'use strict';
|
||||
|
||||
const TITLE_KIND = Object.freeze({
|
||||
EPISODE_CANDIDATE: 'episode_candidate',
|
||||
PLAY_ALL: 'play_all',
|
||||
EXTRA: 'extra',
|
||||
DUPLICATE: 'duplicate',
|
||||
SHORT: 'short',
|
||||
UNKNOWN: 'unknown'
|
||||
});
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
minEpisodeMinutes: 18,
|
||||
maxEpisodeMinutes: 75,
|
||||
minChapterCount: 3,
|
||||
shortTitleMinutes: 5
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function parseDurationToSeconds(rawValue) {
|
||||
const text = String(rawValue || '').trim();
|
||||
const match = text.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
|
||||
if (!match) {
|
||||
return 0;
|
||||
}
|
||||
return (Number(match[1]) * 3600) + (Number(match[2]) * 60) + Number(match[3]);
|
||||
}
|
||||
|
||||
function roundToStep(value, step) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || step <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(num / step) * step;
|
||||
}
|
||||
|
||||
function median(values = []) {
|
||||
const nums = (Array.isArray(values) ? values : [])
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.sort((left, right) => left - right);
|
||||
if (nums.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const middle = Math.floor(nums.length / 2);
|
||||
if (nums.length % 2 === 1) {
|
||||
return nums[middle];
|
||||
}
|
||||
return (nums[middle - 1] + nums[middle]) / 2;
|
||||
}
|
||||
|
||||
function normalizeLanguageCode(rawCode, fallbackLabel = null) {
|
||||
const raw = String(rawCode || '').trim().toLowerCase();
|
||||
if (raw && raw.length === 3) {
|
||||
return raw;
|
||||
}
|
||||
const label = String(fallbackLabel || '').trim().toLowerCase();
|
||||
if (label.startsWith('de')) return 'deu';
|
||||
if (label.startsWith('en')) return 'eng';
|
||||
if (label.startsWith('fr')) return 'fra';
|
||||
if (label.startsWith('es')) return 'spa';
|
||||
if (label.startsWith('nl')) return 'nld';
|
||||
return raw || 'und';
|
||||
}
|
||||
|
||||
function normalizeSeriesLookupTitle(rawValue) {
|
||||
return String(rawValue || '')
|
||||
.replace(/[_./]+/g, ' ')
|
||||
.replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ')
|
||||
.replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ')
|
||||
.replace(/\b\d{1,2}x\b/gi, ' ')
|
||||
.replace(/\b(?:complete|collection|boxset)\b/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function deriveSeriesLookupHint(inputs = []) {
|
||||
const normalizedInputs = (Array.isArray(inputs) ? inputs : [inputs])
|
||||
.map((entry) => {
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
return {
|
||||
value: String(entry.value || '').trim(),
|
||||
source: String(entry.source || '').trim() || 'unknown'
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: String(entry || '').trim(),
|
||||
source: 'unknown'
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.value);
|
||||
|
||||
for (const entry of normalizedInputs) {
|
||||
const compactValue = entry.value.replace(/[_./]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!compactValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonMatch = compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i)
|
||||
|| compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i);
|
||||
if (!seasonMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonNumber = Number(seasonMatch[1] || 0);
|
||||
if (!Number.isFinite(seasonNumber) || seasonNumber <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const discMatch = compactValue.match(/(?:^|\s)(?:disc|dvd|cd)\s*(\d{1,2})(?=\s|$)/i);
|
||||
const discNumber = discMatch
|
||||
? Number(discMatch[1] || 0) || null
|
||||
: null;
|
||||
|
||||
const baseTitle = normalizeSeriesLookupTitle(compactValue);
|
||||
if (!baseTitle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
query: baseTitle,
|
||||
seriesTitle: baseTitle,
|
||||
seasonNumber,
|
||||
discNumber,
|
||||
source: entry.source,
|
||||
sourceLabel: entry.value,
|
||||
confidence: discNumber ? 'high' : 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTitleRecord(title = {}) {
|
||||
const durationSeconds = Number(title.durationSeconds || 0);
|
||||
const chapterCount = Number(title.chapterCount || 0);
|
||||
const roundedDurationBucket = roundToStep(durationSeconds, 30);
|
||||
const audioLanguages = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
|
||||
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const subtitleLanguages = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
|
||||
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
index: Number(title.index || 0),
|
||||
durationSeconds,
|
||||
durationLabel: String(title.durationLabel || '').trim() || null,
|
||||
chapterCount,
|
||||
chapterDurationsMs: Array.isArray(title.chapterDurationsMs) ? title.chapterDurationsMs : [],
|
||||
aspectRatio: String(title.aspectRatio || '').trim() || null,
|
||||
audioTracks: Array.isArray(title.audioTracks) ? title.audioTracks : [],
|
||||
subtitleTracks: Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [],
|
||||
flags: Array.isArray(title.flags) ? title.flags : [],
|
||||
roundedDurationBucket,
|
||||
signature: JSON.stringify({
|
||||
duration: roundedDurationBucket,
|
||||
chapterCount,
|
||||
audioLanguages,
|
||||
subtitleLanguages
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function buildBestEpisodeCluster(titles = [], options = {}) {
|
||||
const minEpisodeSeconds = Math.max(60, Math.round(Number(options.minEpisodeMinutes || DEFAULTS.minEpisodeMinutes) * 60));
|
||||
const maxEpisodeSeconds = Math.max(minEpisodeSeconds, Math.round(Number(options.maxEpisodeMinutes || DEFAULTS.maxEpisodeMinutes) * 60));
|
||||
const minChapterCount = Math.max(1, Number(options.minChapterCount || DEFAULTS.minChapterCount));
|
||||
const candidates = titles.filter((title) =>
|
||||
title.durationSeconds >= minEpisodeSeconds
|
||||
&& title.durationSeconds <= maxEpisodeSeconds
|
||||
&& title.chapterCount >= minChapterCount
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
titles: [],
|
||||
medianDurationSeconds: 0,
|
||||
medianChapterCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
let bestCluster = [];
|
||||
for (const anchor of candidates) {
|
||||
const durationTolerance = Math.max(90, Math.round(anchor.durationSeconds * 0.12));
|
||||
const cluster = candidates.filter((candidate) => {
|
||||
const durationGap = Math.abs(candidate.durationSeconds - anchor.durationSeconds);
|
||||
const chapterGap = Math.abs(candidate.chapterCount - anchor.chapterCount);
|
||||
return durationGap <= durationTolerance && chapterGap <= 2;
|
||||
});
|
||||
|
||||
if (cluster.length > bestCluster.length) {
|
||||
bestCluster = cluster;
|
||||
continue;
|
||||
}
|
||||
if (cluster.length === bestCluster.length) {
|
||||
const clusterMedian = median(cluster.map((item) => item.durationSeconds));
|
||||
const bestMedian = median(bestCluster.map((item) => item.durationSeconds));
|
||||
if (clusterMedian > bestMedian) {
|
||||
bestCluster = cluster;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
titles: bestCluster,
|
||||
medianDurationSeconds: median(bestCluster.map((item) => item.durationSeconds)),
|
||||
medianChapterCount: median(bestCluster.map((item) => item.chapterCount))
|
||||
};
|
||||
}
|
||||
|
||||
function classifyTitles(titles = [], cluster = {}, options = {}) {
|
||||
const shortTitleSeconds = Math.max(60, Math.round(Number(options.shortTitleMinutes || DEFAULTS.shortTitleMinutes) * 60));
|
||||
const clusterTitleIds = new Set((cluster.titles || []).map((item) => Number(item.index)));
|
||||
const duplicateFirstBySignature = new Map();
|
||||
|
||||
for (const title of titles) {
|
||||
if (!duplicateFirstBySignature.has(title.signature)) {
|
||||
duplicateFirstBySignature.set(title.signature, title.index);
|
||||
}
|
||||
}
|
||||
|
||||
const playAllCandidate = (() => {
|
||||
if (!Array.isArray(cluster.titles) || cluster.titles.length < 2 || !cluster.medianDurationSeconds) {
|
||||
return null;
|
||||
}
|
||||
const minPlayAllSeconds = cluster.medianDurationSeconds * Math.max(2, cluster.titles.length - 1);
|
||||
return titles
|
||||
.filter((title) =>
|
||||
!clusterTitleIds.has(Number(title.index))
|
||||
&& title.durationSeconds >= minPlayAllSeconds * 0.75
|
||||
&& title.chapterCount >= Math.max(6, Math.round(cluster.medianChapterCount * cluster.titles.length * 0.6))
|
||||
)
|
||||
.sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null;
|
||||
})();
|
||||
|
||||
return titles.map((title) => {
|
||||
const isFirstWithSignature = duplicateFirstBySignature.get(title.signature) === title.index;
|
||||
const isShort = title.durationSeconds > 0 && title.durationSeconds <= shortTitleSeconds;
|
||||
const isEpisodeCandidate = clusterTitleIds.has(Number(title.index));
|
||||
const isPlayAll = playAllCandidate && Number(playAllCandidate.index) === Number(title.index);
|
||||
const kind = isPlayAll
|
||||
? TITLE_KIND.PLAY_ALL
|
||||
: isEpisodeCandidate
|
||||
? TITLE_KIND.EPISODE_CANDIDATE
|
||||
: !isFirstWithSignature
|
||||
? TITLE_KIND.DUPLICATE
|
||||
: isShort
|
||||
? TITLE_KIND.SHORT
|
||||
: (title.durationSeconds > 0 ? TITLE_KIND.EXTRA : TITLE_KIND.UNKNOWN);
|
||||
|
||||
let confidence = 0.2;
|
||||
if (kind === TITLE_KIND.EPISODE_CANDIDATE) confidence = 0.8;
|
||||
if (kind === TITLE_KIND.PLAY_ALL) confidence = 0.95;
|
||||
if (kind === TITLE_KIND.DUPLICATE) confidence = 0.85;
|
||||
if (kind === TITLE_KIND.SHORT) confidence = 0.9;
|
||||
if (kind === TITLE_KIND.EXTRA) confidence = 0.55;
|
||||
|
||||
return {
|
||||
...title,
|
||||
kind,
|
||||
confidence,
|
||||
duplicateOfTitleIndex: kind === TITLE_KIND.DUPLICATE
|
||||
? duplicateFirstBySignature.get(title.signature)
|
||||
: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildDiscSignature(parsedScan = {}) {
|
||||
return JSON.stringify({
|
||||
discTitle: String(parsedScan.discTitle || '').trim() || null,
|
||||
discSerial: String(parsedScan.discSerial || '').trim() || null,
|
||||
titleCount: Number(parsedScan.titleCount || 0),
|
||||
durations: (Array.isArray(parsedScan.titles) ? parsedScan.titles : [])
|
||||
.map((title) => ({
|
||||
index: Number(title.index || 0),
|
||||
durationBucket: roundToStep(title.durationSeconds || 0, 30),
|
||||
chapterCount: Number(title.chapterCount || 0)
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeSeriesLikelihood(classifiedTitles = [], cluster = {}) {
|
||||
const episodeCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length;
|
||||
const playAllCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length;
|
||||
const duplicateCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length;
|
||||
const extrasCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length;
|
||||
|
||||
const reasons = [];
|
||||
let confidence = 'low';
|
||||
let seriesLike = false;
|
||||
|
||||
if (episodeCount >= 3) {
|
||||
seriesLike = true;
|
||||
confidence = playAllCount > 0 ? 'high' : 'medium';
|
||||
reasons.push(`${episodeCount} ähnlich lange Episodenkandidaten erkannt.`);
|
||||
} else if (episodeCount === 2) {
|
||||
seriesLike = true;
|
||||
confidence = 'medium';
|
||||
reasons.push('Mindestens zwei ähnlich lange Episodenkandidaten erkannt.');
|
||||
}
|
||||
|
||||
if (playAllCount > 0) {
|
||||
reasons.push('Ein langer Play-All-Titel wurde erkannt.');
|
||||
}
|
||||
if (duplicateCount > 0) {
|
||||
reasons.push(`${duplicateCount} alternative/duplizierte Titel gefunden.`);
|
||||
}
|
||||
if (cluster.medianDurationSeconds > 0) {
|
||||
reasons.push(`Typische Episodenlänge ca. ${Math.round(cluster.medianDurationSeconds / 60)} Minuten.`);
|
||||
}
|
||||
if (extrasCount > 0) {
|
||||
reasons.push(`${extrasCount} Titel als Extras oder Sonderfälle klassifiziert.`);
|
||||
}
|
||||
|
||||
return {
|
||||
seriesLike,
|
||||
confidence,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
|
||||
function parseHandBrakeScanText(rawOutput) {
|
||||
const lines = String(rawOutput || '').split(/\r?\n/);
|
||||
const parsed = {
|
||||
source: 'handbrake_scan_text',
|
||||
generatedAt: nowIso(),
|
||||
discTitle: null,
|
||||
discSerial: null,
|
||||
titleCount: 0,
|
||||
rawLineCount: lines.length,
|
||||
titles: []
|
||||
};
|
||||
|
||||
let currentTitle = null;
|
||||
const ensureCurrentTitle = (index) => {
|
||||
const normalizedIndex = Number(index);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (currentTitle && Number(currentTitle.index) === normalizedIndex) {
|
||||
return currentTitle;
|
||||
}
|
||||
const existing = parsed.titles.find((title) => Number(title.index) === normalizedIndex);
|
||||
if (existing) {
|
||||
currentTitle = existing;
|
||||
return currentTitle;
|
||||
}
|
||||
currentTitle = {
|
||||
index: normalizedIndex,
|
||||
durationLabel: null,
|
||||
durationSeconds: 0,
|
||||
chapterCount: 0,
|
||||
chapterDurationsMs: [],
|
||||
audioTracks: [],
|
||||
subtitleTracks: [],
|
||||
aspectRatio: null,
|
||||
flags: []
|
||||
};
|
||||
parsed.titles.push(currentTitle);
|
||||
return currentTitle;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
let match = line.match(/libdvdnav:\s+DVD Title:\s+(.+)\s*$/i);
|
||||
if (match) {
|
||||
parsed.discTitle = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/libdvdnav:\s+DVD Serial Number:\s+(.+)\s*$/i);
|
||||
if (match) {
|
||||
parsed.discSerial = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+DVD has\s+(\d+)\s+title/i);
|
||||
if (match) {
|
||||
parsed.titleCount = Number(match[1] || 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+scanning title\s+(\d+)/i);
|
||||
if (match) {
|
||||
ensureCurrentTitle(match[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.durationLabel = match[1];
|
||||
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i);
|
||||
if (match) {
|
||||
const title = ensureCurrentTitle(match[1]);
|
||||
if (title) {
|
||||
title.chapterCount = Number(match[2] || 0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.chapterDurationsMs.push(Number(match[1] || 0));
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+id=0x[0-9a-f]+,\s+lang=(.+?)\s+\([^)]+\),\s+3cc=([a-z]{3})/i);
|
||||
if (match && currentTitle) {
|
||||
const track = {
|
||||
languageLabel: String(match[1] || '').trim() || null,
|
||||
languageCode: normalizeLanguageCode(match[2], match[1])
|
||||
};
|
||||
if (/\[VOBSUB\]/i.test(line)) {
|
||||
currentTitle.subtitleTracks.push(track);
|
||||
} else {
|
||||
currentTitle.audioTracks.push(track);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+aspect\s+=\s+(.+)$/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.aspectRatio = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+ignoring title \(too short\)/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.flags.push('ignored_too_short');
|
||||
}
|
||||
}
|
||||
|
||||
parsed.titles.sort((left, right) => Number(left.index || 0) - Number(right.index || 0));
|
||||
if (!parsed.titleCount) {
|
||||
parsed.titleCount = parsed.titles.length;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function analyzeParsedScan(parsedScan = {}, options = {}) {
|
||||
const titles = (Array.isArray(parsedScan.titles) ? parsedScan.titles : []).map(normalizeTitleRecord);
|
||||
const cluster = buildBestEpisodeCluster(titles, options);
|
||||
const classifiedTitles = classifyTitles(titles, cluster, options);
|
||||
const summary = summarizeSeriesLikelihood(classifiedTitles, cluster);
|
||||
|
||||
return {
|
||||
source: parsedScan.source || 'handbrake_scan_text',
|
||||
generatedAt: nowIso(),
|
||||
discTitle: parsedScan.discTitle || null,
|
||||
discSerial: parsedScan.discSerial || null,
|
||||
titleCount: Number(parsedScan.titleCount || titles.length || 0),
|
||||
discSignature: buildDiscSignature({
|
||||
discTitle: parsedScan.discTitle,
|
||||
discSerial: parsedScan.discSerial,
|
||||
titleCount: parsedScan.titleCount,
|
||||
titles
|
||||
}),
|
||||
summary: {
|
||||
...summary,
|
||||
titleCount: classifiedTitles.length,
|
||||
episodeCandidateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length,
|
||||
playAllCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length,
|
||||
duplicateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length,
|
||||
extraCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length,
|
||||
typicalEpisodeMinutes: cluster.medianDurationSeconds
|
||||
? Number((cluster.medianDurationSeconds / 60).toFixed(2))
|
||||
: 0
|
||||
},
|
||||
titles: classifiedTitles
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeHandBrakeScan(rawOutput, options = {}) {
|
||||
const parsed = parseHandBrakeScanText(rawOutput);
|
||||
const analysis = analyzeParsedScan(parsed, options);
|
||||
return {
|
||||
parsed,
|
||||
analysis
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TITLE_KIND,
|
||||
parseHandBrakeScanText,
|
||||
analyzeParsedScan,
|
||||
analyzeHandBrakeScan,
|
||||
deriveSeriesLookupHint
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
+3497
-262
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ const { setLogRootDir } = require('./logPathService');
|
||||
const {
|
||||
defaultRawDir: DEFAULT_RAW_DIR,
|
||||
defaultMovieDir: DEFAULT_MOVIE_DIR,
|
||||
defaultSeriesDir: DEFAULT_SERIES_DIR,
|
||||
defaultCdDir: DEFAULT_CD_DIR,
|
||||
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR,
|
||||
@@ -38,6 +39,7 @@ const HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS = new Set([
|
||||
const SENSITIVE_SETTING_KEYS = new Set([
|
||||
'makemkv_registration_key',
|
||||
'omdb_api_key',
|
||||
'tmdb_api_read_access_token',
|
||||
'pushover_token',
|
||||
'pushover_user'
|
||||
]);
|
||||
@@ -62,18 +64,30 @@ const PROFILED_SETTINGS = {
|
||||
cd: 'raw_dir_cd_owner',
|
||||
audiobook: 'raw_dir_audiobook_owner'
|
||||
},
|
||||
series_raw_dir: {
|
||||
dvd: 'raw_dir_dvd_series'
|
||||
},
|
||||
series_raw_dir_owner: {
|
||||
dvd: 'raw_dir_dvd_series_owner'
|
||||
},
|
||||
movie_dir: {
|
||||
bluray: 'movie_dir_bluray',
|
||||
dvd: 'movie_dir_dvd',
|
||||
cd: 'movie_dir_cd',
|
||||
audiobook: 'movie_dir_audiobook'
|
||||
},
|
||||
series_dir: {
|
||||
dvd: 'series_dir_dvd'
|
||||
},
|
||||
movie_dir_owner: {
|
||||
bluray: 'movie_dir_bluray_owner',
|
||||
dvd: 'movie_dir_dvd_owner',
|
||||
cd: 'movie_dir_cd_owner',
|
||||
audiobook: 'movie_dir_audiobook_owner'
|
||||
},
|
||||
series_dir_owner: {
|
||||
dvd: 'series_dir_dvd_owner'
|
||||
},
|
||||
mediainfo_extra_args: {
|
||||
bluray: 'mediainfo_extra_args_bluray',
|
||||
dvd: 'mediainfo_extra_args_dvd'
|
||||
@@ -111,8 +125,12 @@ const PROFILED_SETTINGS = {
|
||||
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
||||
'raw_dir',
|
||||
'raw_dir_owner',
|
||||
'series_raw_dir',
|
||||
'series_raw_dir_owner',
|
||||
'movie_dir',
|
||||
'movie_dir_owner'
|
||||
'movie_dir_owner',
|
||||
'series_dir',
|
||||
'series_dir_owner'
|
||||
]);
|
||||
|
||||
function applyRuntimeLogDirSetting(rawValue) {
|
||||
@@ -813,6 +831,8 @@ class SettingsService {
|
||||
} else {
|
||||
resolvedValue = DEFAULT_RAW_DIR;
|
||||
}
|
||||
} else if (legacyKey === 'series_dir') {
|
||||
resolvedValue = DEFAULT_SERIES_DIR;
|
||||
} else if (legacyKey === 'movie_dir') {
|
||||
if (normalizedRequestedProfile === 'cd') {
|
||||
resolvedValue = DEFAULT_CD_DIR;
|
||||
@@ -858,7 +878,7 @@ class SettingsService {
|
||||
const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook');
|
||||
return {
|
||||
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
|
||||
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
||||
dvd: { raw: dvd.raw_dir, seriesRaw: dvd.series_raw_dir || dvd.raw_dir, movies: dvd.movie_dir, series: dvd.series_dir },
|
||||
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
||||
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
||||
downloads: { path: bluray.download_dir },
|
||||
@@ -870,6 +890,7 @@ class SettingsService {
|
||||
defaults: {
|
||||
raw: DEFAULT_RAW_DIR,
|
||||
movies: DEFAULT_MOVIE_DIR,
|
||||
series: DEFAULT_SERIES_DIR,
|
||||
cd: DEFAULT_CD_DIR,
|
||||
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
audiobookMovies: DEFAULT_AUDIOBOOK_DIR,
|
||||
@@ -1116,6 +1137,7 @@ class SettingsService {
|
||||
: 'mkv';
|
||||
const sourceArg = this.resolveSourceArg(map, deviceInfo);
|
||||
const rawSelectedTitleId = normalizeNonNegativeInteger(options?.selectedTitleId);
|
||||
const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter);
|
||||
const parsedExtra = splitArgs(map.makemkv_rip_extra_args);
|
||||
let extra = [];
|
||||
let baseArgs = [];
|
||||
@@ -1141,6 +1163,9 @@ class SettingsService {
|
||||
const minLength = Number(map.makemkv_min_length_minutes || 60);
|
||||
const hasExplicitTitle = rawSelectedTitleId !== null;
|
||||
const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all';
|
||||
const minLengthSeconds = Number.isFinite(minLength) && minLength > 0
|
||||
? Math.round(minLength * 60)
|
||||
: 0;
|
||||
if (hasExplicitTitle) {
|
||||
baseArgs = [
|
||||
'-r', '--progress=-same',
|
||||
@@ -1150,9 +1175,12 @@ class SettingsService {
|
||||
rawJobDir
|
||||
];
|
||||
} else {
|
||||
const minLengthArgs = (!disableMinLengthFilter && minLengthSeconds > 0)
|
||||
? [`--minlength=${minLengthSeconds}`]
|
||||
: [];
|
||||
baseArgs = [
|
||||
'-r', '--progress=-same',
|
||||
'--minlength=' + Math.round(minLength * 60),
|
||||
...minLengthArgs,
|
||||
'mkv',
|
||||
sourceArg,
|
||||
targetTitle,
|
||||
@@ -1166,6 +1194,7 @@ class SettingsService {
|
||||
ripMode,
|
||||
rawJobDir,
|
||||
deviceInfo,
|
||||
disableMinLengthFilter: ripMode === 'mkv' ? disableMinLengthFilter : false,
|
||||
selectedTitleId: ripMode === 'mkv' && Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0
|
||||
? Math.trunc(rawSelectedTitleId)
|
||||
: null
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
'use strict';
|
||||
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('TMDB');
|
||||
|
||||
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
||||
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
|
||||
const TMDB_TIMEOUT_MS = 10000;
|
||||
|
||||
class TmdbService {
|
||||
normalizeNameList(values = [], options = {}) {
|
||||
const maxItems = Math.max(1, Number(options.maxItems || 10));
|
||||
const source = Array.isArray(values) ? values : [];
|
||||
const output = [];
|
||||
const seen = new Set();
|
||||
for (const item of source) {
|
||||
const name = String(item?.name || item || '').trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(name);
|
||||
if (output.length >= maxItems) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
formatRuntimeLabel(value) {
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((entry) => Number(entry))
|
||||
.filter((entry) => Number.isFinite(entry) && entry > 0)
|
||||
.map((entry) => Math.trunc(entry));
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
return min === max ? `${min} min` : `${min}-${max} min`;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return `${Math.trunc(numeric)} min`;
|
||||
}
|
||||
const text = String(value || '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return {
|
||||
readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null,
|
||||
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE'
|
||||
};
|
||||
}
|
||||
|
||||
async isConfigured() {
|
||||
const config = await this.getConfig();
|
||||
return Boolean(config.readAccessToken);
|
||||
}
|
||||
|
||||
async resolveLanguage(explicitLanguage = null) {
|
||||
const config = await this.getConfig();
|
||||
return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE';
|
||||
}
|
||||
|
||||
async request(pathName, options = {}) {
|
||||
const config = await this.getConfig();
|
||||
if (!config.readAccessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(1000, Number(options.timeoutMs || TMDB_TIMEOUT_MS));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const normalizedPath = String(pathName || '').replace(/^\/+/, '');
|
||||
const url = new URL(normalizedPath, `${TMDB_BASE_URL}/`);
|
||||
if (options.query && typeof options.query === 'object') {
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'Authorization': `Bearer ${config.readAccessToken}`,
|
||||
'User-Agent': 'Ripster/1.0'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`TMDb request failed (${response.status})`);
|
||||
error.statusCode = response.status;
|
||||
error.url = url.toString();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
buildImageUrl(imagePath, size = 'w342') {
|
||||
const normalizedPath = String(imagePath || '').trim();
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
return `${TMDB_IMAGE_BASE_URL}/${String(size || 'w342').trim()}/${normalizedPath.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
async searchSeries(query, options = {}) {
|
||||
const normalizedQuery = String(query || '').trim();
|
||||
if (!normalizedQuery || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
|
||||
const data = await this.request('/search/tv', {
|
||||
query: {
|
||||
query: normalizedQuery,
|
||||
first_air_date_year: options.year || undefined,
|
||||
language,
|
||||
page: options.page || 1,
|
||||
include_adult: false
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('search:failed', {
|
||||
query: normalizedQuery,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
const rows = Array.isArray(data?.results) ? data.results : [];
|
||||
return rows
|
||||
.map((row) => ({
|
||||
id: Number(row?.id || 0) || null,
|
||||
title: String(row?.name || row?.original_name || '').trim() || null,
|
||||
originalTitle: String(row?.original_name || '').trim() || null,
|
||||
year: Number(String(row?.first_air_date || '').slice(0, 4)) || null,
|
||||
overview: String(row?.overview || '').trim() || null,
|
||||
posterPath: String(row?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(row?.poster_path, 'w342'),
|
||||
backdropPath: String(row?.backdrop_path || '').trim() || null,
|
||||
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
|
||||
originalLanguage: String(row?.original_language || '').trim() || null,
|
||||
popularity: Number(row?.popularity || 0) || 0
|
||||
}))
|
||||
.filter((row) => row.id && row.title);
|
||||
}
|
||||
|
||||
async getSeriesDetails(seriesId, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
const appendToResponse = Array.isArray(options.appendToResponse)
|
||||
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
return this.request(`/tv/${Math.trunc(id)}`, {
|
||||
query: {
|
||||
language,
|
||||
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('series:details:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async getEpisodeGroups(seriesId) {
|
||||
const id = Number(seriesId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const response = await this.request(`/tv/${Math.trunc(id)}/episode_groups`).catch((error) => {
|
||||
logger.warn('series:episode-groups:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
return Array.isArray(response?.results) ? response.results : [];
|
||||
}
|
||||
|
||||
async getEpisodeGroupDetails(groupId, options = {}) {
|
||||
const normalizedId = String(groupId || '').trim();
|
||||
if (!normalizedId || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/tv/episode_group/${normalizedId}`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('episode-group:details:failed', {
|
||||
groupId: normalizedId,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async getSeasonDetails(seriesId, seasonNumber, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
const season = Number(seasonNumber);
|
||||
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
async getSeasonCredits(seriesId, seasonNumber, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
const season = Number(seasonNumber);
|
||||
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}/credits`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('season:credits:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
seasonNumber: Math.trunc(season),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
buildSeasonSummary(seasonDetails = null) {
|
||||
const details = seasonDetails && typeof seasonDetails === 'object' ? seasonDetails : {};
|
||||
const episodes = Array.isArray(details.episodes) ? details.episodes : [];
|
||||
return {
|
||||
seasonNumber: Number(details.season_number || details.seasonNumber || 0) || null,
|
||||
name: String(details.name || '').trim() || null,
|
||||
overview: String(details.overview || '').trim() || null,
|
||||
posterPath: String(details.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(details.poster_path, 'w342'),
|
||||
episodeCount: episodes.length,
|
||||
episodes: episodes.map((episode) => ({
|
||||
id: Number(episode?.id || 0) || null,
|
||||
number: Number(episode?.episode_number || episode?.episodeNumber || 0) || null,
|
||||
seasonNumber: Number(episode?.season_number || details.season_number || 0) || null,
|
||||
name: String(episode?.name || '').trim() || null,
|
||||
overview: String(episode?.overview || '').trim() || null,
|
||||
runtime: Number(episode?.runtime || 0) || null,
|
||||
airDate: String(episode?.air_date || '').trim() || null,
|
||||
stillPath: String(episode?.still_path || '').trim() || null,
|
||||
still: this.buildImageUrl(episode?.still_path, 'w300')
|
||||
})).filter((episode) => episode.id && episode.number)
|
||||
};
|
||||
}
|
||||
|
||||
buildSeriesDetailsSummary(seriesDetails = null) {
|
||||
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
|
||||
const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : [];
|
||||
const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : [];
|
||||
const creators = Array.isArray(details?.created_by) ? details.created_by : [];
|
||||
const genres = Array.isArray(details?.genres) ? details.genres : [];
|
||||
const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time);
|
||||
const directorNames = this.normalizeNameList(
|
||||
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
|
||||
{ maxItems: 5 }
|
||||
);
|
||||
const creatorNames = this.normalizeNameList(creators, { maxItems: 5 });
|
||||
const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
|
||||
const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
|
||||
const voteAverageRaw = Number(details?.vote_average || 0);
|
||||
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
|
||||
? Number(voteAverageRaw.toFixed(1))
|
||||
: null;
|
||||
const voteCount = Number(details?.vote_count || 0);
|
||||
const imdbId = String(details?.external_ids?.imdb_id || '').trim() || null;
|
||||
|
||||
return {
|
||||
director: directorNames.length > 0
|
||||
? directorNames.join(', ')
|
||||
: (creatorNames.length > 0 ? creatorNames.join(', ') : null),
|
||||
actors: actorNames.length > 0 ? actorNames.join(', ') : null,
|
||||
runtime: runtimeLabel,
|
||||
runtimeLabel,
|
||||
genre: genreNames.length > 0 ? genreNames.join(', ') : null,
|
||||
imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null,
|
||||
voteAverage,
|
||||
voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null,
|
||||
rottenTomatoes: null,
|
||||
imdbId,
|
||||
tmdbId: Number(details?.id || 0) || null,
|
||||
firstAirDate: String(details?.first_air_date || '').trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
buildSeriesMetadataCandidate(series = {}, options = {}) {
|
||||
const season = series?.season && typeof series.season === 'object'
|
||||
? series.season
|
||||
: null;
|
||||
const seasonNumber = Number(options.seasonNumber || season?.seasonNumber || 0) || null;
|
||||
const providerId = seasonNumber
|
||||
? `tmdb:${series.id}:season:${seasonNumber}`
|
||||
: `tmdb:${series.id}`;
|
||||
|
||||
return {
|
||||
provider: 'tmdb',
|
||||
providerId,
|
||||
metadataKind: seasonNumber ? 'season' : 'series',
|
||||
tmdbId: Number(series?.id || 0) || null,
|
||||
title: String(series?.title || '').trim() || null,
|
||||
originalTitle: String(series?.originalTitle || '').trim() || null,
|
||||
year: Number(series?.year || 0) || null,
|
||||
overview: String(series?.overview || '').trim() || null,
|
||||
poster: series?.poster || this.buildImageUrl(series?.posterPath, 'w342'),
|
||||
backdrop: series?.backdrop || this.buildImageUrl(series?.backdropPath, 'w780'),
|
||||
seasonNumber,
|
||||
seasonName: String(season?.name || '').trim() || null,
|
||||
seasonOverview: String(season?.overview || '').trim() || null,
|
||||
seasonPoster: season?.poster || this.buildImageUrl(season?.posterPath, 'w342'),
|
||||
episodeCount: Number(season?.episodeCount || 0) || 0,
|
||||
episodes: Array.isArray(season?.episodes) ? season.episodes : []
|
||||
};
|
||||
}
|
||||
|
||||
buildSeasonSummariesFromSeriesDetails(seriesDetails = null) {
|
||||
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
|
||||
const seasons = Array.isArray(details.seasons) ? details.seasons : [];
|
||||
return seasons
|
||||
.map((season) => ({
|
||||
seasonNumber: Number(season?.season_number || season?.seasonNumber || 0) || null,
|
||||
name: String(season?.name || '').trim() || null,
|
||||
overview: String(season?.overview || '').trim() || null,
|
||||
posterPath: String(season?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(season?.poster_path, 'w342'),
|
||||
episodeCount: Number(season?.episode_count || season?.episodeCount || 0) || 0,
|
||||
episodes: []
|
||||
}))
|
||||
.filter((season) => season.seasonNumber !== null && season.episodeCount > 0);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeasons(query, options = {}) {
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
|
||||
const expanded = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const details = await this.getSeriesDetails(candidate.id, options);
|
||||
const seasons = this.buildSeasonSummariesFromSeriesDetails(details);
|
||||
if (seasons.length === 0) {
|
||||
return [this.buildSeriesMetadataCandidate(candidate)];
|
||||
}
|
||||
return seasons.map((season) => this.buildSeriesMetadataCandidate({
|
||||
...candidate,
|
||||
season
|
||||
}, {
|
||||
seasonNumber: season.seasonNumber
|
||||
}));
|
||||
}));
|
||||
|
||||
return expanded.flat().filter((item) => item?.tmdbId && item?.title);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
|
||||
const normalizedSeason = Number(seasonNumber);
|
||||
if (!Number.isFinite(normalizedSeason) || normalizedSeason < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
const withSeasons = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const seasonDetails = await this.getSeasonDetails(candidate.id, normalizedSeason, options);
|
||||
if (!seasonDetails) {
|
||||
return {
|
||||
...candidate,
|
||||
season: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
...candidate,
|
||||
season: this.buildSeasonSummary(seasonDetails)
|
||||
};
|
||||
}));
|
||||
|
||||
return withSeasons
|
||||
.filter((candidate) => candidate.season && candidate.season.episodeCount > 0)
|
||||
.map((candidate) => this.buildSeriesMetadataCandidate(candidate, {
|
||||
seasonNumber: normalizedSeason
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TmdbService();
|
||||
@@ -45,13 +45,44 @@ function sanitizeFileNameWithExtension(input) {
|
||||
|
||||
function renderTemplate(template, values) {
|
||||
const rawSource = String(template || '${title} (${year})');
|
||||
const parseToken = (rawToken) => {
|
||||
const token = String(rawToken || '').trim();
|
||||
if (!token) {
|
||||
return { format: '', key: '' };
|
||||
}
|
||||
const separatorIndex = token.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return { format: '', key: token };
|
||||
}
|
||||
return {
|
||||
format: token.slice(0, separatorIndex).trim(),
|
||||
key: token.slice(separatorIndex + 1).trim()
|
||||
};
|
||||
};
|
||||
const applyFormat = (value, format) => {
|
||||
const normalizedFormat = String(format || '').trim().toLowerCase();
|
||||
if (!normalizedFormat) {
|
||||
return String(value);
|
||||
}
|
||||
// {0:key} => number with leading zero (width 2), e.g. 2 -> 02
|
||||
if (normalizedFormat === '0') {
|
||||
const raw = String(value);
|
||||
const match = raw.match(/^(-?)(\d+)$/);
|
||||
if (match) {
|
||||
const sign = match[1] || '';
|
||||
const digits = String(match[2] || '');
|
||||
return `${sign}${digits.padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
const resolveToken = (rawKey) => {
|
||||
const key = String(rawKey || '').trim();
|
||||
const { format, key } = parseToken(rawKey);
|
||||
const val = values[key];
|
||||
if (val === undefined || val === null || val === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
return String(val);
|
||||
return applyFormat(val, format);
|
||||
};
|
||||
|
||||
const source = (
|
||||
|
||||
Reference in New Issue
Block a user