diff --git a/Dockerfile b/Dockerfile index 3b27446..306022f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN npm run build FROM node:22-alpine AS backend WORKDIR /app/backend +RUN apk add --no-cache ffmpeg + COPY backend/package*.json ./ RUN npm ci --omit=dev diff --git a/README.md b/README.md index f3391a6..c6e1b64 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,10 @@ The frontend also installs the shared Emberline design system directly from GitH 2. Under **Plex Libraries** in Settings, select the libraries to scan and set a minimum resolution threshold per library (default: **1080p**). 3. Navigate to **Video Quality Check** and click **Run Check**. 4. Movies below their library's threshold are listed separately from movies above it, showing resolution, codec, and audio details for each. -5. Select movies in the **Below Threshold** list and click **Upgrade** to delete the current file in Radarr and queue a replacement search. -6. Select movies in the **Above Threshold** list and click **Downgrade** to delete the current file in Radarr and queue a replacement search for a smaller file. -7. Radarr still decides the replacement based on that movie's current quality profile, so a downgrade will only happen if the Radarr profile allows a lower-quality result. +5. Optional: enable **Deep scan** to run a full `ffmpeg` decode pass per movie for more trustworthy media details. Plex metadata is used only as a fallback to locate a media part when the library listing does not already expose one. This is much slower, especially on large libraries. +6. Select movies in the **Below Threshold** list and click **Upgrade** to delete the current file in Radarr and queue a replacement search. +7. Select movies in the **Above Threshold** list and click **Downgrade** to delete the current file in Radarr and queue a replacement search for a smaller file. +8. Radarr still decides the replacement based on that movie's current quality profile, so a downgrade will only happen if the Radarr profile allows a lower-quality result. ## Unmanaged Movies @@ -118,8 +119,9 @@ The frontend also installs the shared Emberline design system directly from GitH 1. Configure Plex and Sonarr URLs + credentials in **Settings**. 2. Set your preferred leeway percentage (default: **5%**) and episode minimum difference (default: **3 min**) under **Duration Tolerance** in Settings. 3. Navigate to **Episode Duration Check** and click **Run Check**. -4. Only episodes from shows managed by Sonarr (matched by TVDB ID) are compared. Episodes are flagged only when the runtime difference is greater than both the percentage threshold and the minimum difference, i.e. `|actual - expected| > max(expected * leeway%, min_diff_minutes)`. -5. Select flagged episodes and click **Redownload** to delete the existing file in Sonarr and trigger a new automatic episode search. +4. Optional: enable **Deep scan** to run a full `ffmpeg` decode pass per episode before comparing durations. Plex metadata is used only as a fallback to locate a media part when needed. +5. Only episodes from shows managed by Sonarr (matched by TVDB ID) are compared. Episodes are flagged only when the runtime difference is greater than both the percentage threshold and the minimum difference, i.e. `|actual - expected| > max(expected * leeway%, min_diff_minutes)`. +6. Select flagged episodes and click **Redownload** to delete the existing file in Sonarr and trigger a new automatic episode search. ## Data Storage diff --git a/backend/package.json b/backend/package.json index ad4d07f..a258b8b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,8 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "dev": "node --watch src/index.js" + "dev": "node --watch src/index.js", + "test": "node --test" }, "keywords": [], "author": "", diff --git a/backend/src/api/plex.js b/backend/src/api/plex.js index 77f3ba9..f1bc1dd 100644 --- a/backend/src/api/plex.js +++ b/backend/src/api/plex.js @@ -1,6 +1,375 @@ const axios = require('axios'); +const { spawn } = require('child_process'); const MS_PER_MINUTE = 60000; +const DEEP_SCAN_CONCURRENCY = Number(process.env.FFMPEG_DEEP_SCAN_CONCURRENCY || 2); +const DEEP_SCAN_TIMEOUT_MS = Number(process.env.FFMPEG_DEEP_SCAN_TIMEOUT_MS || 15 * 60 * 1000); +const FFMPEG_TERM_GRACE_MS = Number(process.env.FFMPEG_DEEP_SCAN_TERM_GRACE_MS || 5000); +const FFMPEG_LOG_LIMIT = 256 * 1024; +const SENSITIVE_URL_PARAMS = ['X-Plex-Token', 'plexToken', 'token']; + +let ffmpegAvailablePromise = null; + +function extractTmdbId(item) { + if (!item.Guid || !Array.isArray(item.Guid)) { + return null; + } + + const tmdbGuid = item.Guid.find((guid) => guid.id && guid.id.startsWith('tmdb://')); + if (!tmdbGuid) { + return null; + } + + return parseInt(tmdbGuid.id.replace('tmdb://', ''), 10); +} + +function mapMovieMetadata(item, section) { + const media = item.Media && item.Media[0]; + const durationMs = media ? media.duration : item.duration; + + return { + title: item.title, + year: item.year, + tmdbId: extractTmdbId(item), + ratingKey: item.ratingKey, + sectionKey: String(section.key), + sectionTitle: section.title, + durationMs: durationMs || 0, + durationMin: durationMs ? Math.round(durationMs / MS_PER_MINUTE) : 0, + videoResolution: (media && media.videoResolution) || '', + videoCodec: (media && media.videoCodec) || '', + audioCodec: (media && media.audioCodec) || '', + audioChannels: (media && media.audioChannels) || 0, + }; +} + +function mapChannelLayout(value) { + if (!value) return 0; + + const normalized = String(value).toLowerCase(); + if (normalized.includes('mono')) return 1; + if (normalized.includes('stereo')) return 2; + if (normalized.includes('2.1')) return 3; + if (normalized.includes('3.1')) return 4; + if (normalized.includes('4.1')) return 5; + if (normalized.includes('5.1')) return 6; + if (normalized.includes('6.1')) return 7; + if (normalized.includes('7.1')) return 8; + + const discrete = normalized.match(/(\d+)\s*channels?/); + if (discrete) { + return parseInt(discrete[1], 10); + } + + return 0; +} + +function normalizeResolutionLabel(width, height) { + const w = Number(width) || 0; + const h = Number(height) || 0; + const smallest = Math.min(w, h); + + if (smallest >= 2160) return '4k'; + if (smallest >= 1080) return '1080'; + if (smallest >= 720) return '720'; + if (smallest >= 480) return '480'; + return ''; +} + +function extractPrimaryAudioChannels(line) { + if (!line) return 0; + + const channelLayoutMatch = line.match(/,\s*([0-9]\.[0-9](?:\(side\)|\(wide\))?|mono|stereo)\b/i); + if (channelLayoutMatch) { + return mapChannelLayout(channelLayoutMatch[1]); + } + + const channelsMatch = line.match(/,\s*(\d+)\s*channels?\b/i); + if (channelsMatch) { + return parseInt(channelsMatch[1], 10); + } + + return 0; +} + +function parseFfmpegStreamMetadata(stderrText) { + const lines = String(stderrText || '').split(/\r?\n/); + const videoLine = lines.find((line) => line.includes(' Video: ')); + const audioLine = lines.find((line) => line.includes(' Audio: ')); + + const videoCodec = videoLine?.match(/Video:\s*([^,\s]+)/)?.[1] || ''; + const audioCodec = audioLine?.match(/Audio:\s*([^,\s]+)/)?.[1] || ''; + const resolutionMatch = videoLine?.match(/(\d{2,5})x(\d{2,5})/); + const width = resolutionMatch ? parseInt(resolutionMatch[1], 10) : 0; + const height = resolutionMatch ? parseInt(resolutionMatch[2], 10) : 0; + + return { + videoResolution: normalizeResolutionLabel(width, height), + videoCodec, + audioCodec, + audioChannels: extractPrimaryAudioChannels(audioLine), + }; +} + +function redactSensitiveUrlParams(text) { + let redacted = String(text || ''); + + for (const paramName of SENSITIVE_URL_PARAMS) { + const encodedName = paramName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + redacted = redacted.replace(new RegExp(`(${encodedName}=)[^&\\s"'|]+`, 'gi'), '$1[REDACTED]'); + } + + return redacted; +} + +function toSafeDeepScanMessage(err) { + if (err instanceof Error) { + return redactSensitiveUrlParams(err.message); + } + + return redactSensitiveUrlParams(err); +} + +function formatDeepScanTimeout(timeoutMs) { + if (timeoutMs < 1000) { + return `${timeoutMs}ms`; + } + + return `${Math.ceil(timeoutMs / 1000)}s`; +} + +async function mapWithConcurrency(items, limit, worker) { + const results = new Array(items.length); + let nextIndex = 0; + + async function runWorker() { + while (nextIndex < items.length) { + const currentIndex = nextIndex++; + results[currentIndex] = await worker(items[currentIndex], currentIndex); + } + } + + const workerCount = Math.min(limit, items.length); + await Promise.all(Array.from({ length: workerCount }, () => runWorker())); + return results; +} + +async function fetchPlexMovieDetails(baseUrl, headers, ratingKey) { + const res = await axios.get(`${baseUrl}/library/metadata/${ratingKey}`, { + headers, + params: { includeGuids: 1 }, + }); + + return res.data?.MediaContainer?.Metadata?.[0] || null; +} + +async function ensureFfmpegAvailable() { + if (!ffmpegAvailablePromise) { + ffmpegAvailablePromise = new Promise((resolve, reject) => { + const proc = spawn('ffmpeg', ['-version'], { stdio: ['ignore', 'ignore', 'ignore'] }); + + proc.on('error', (err) => { + reject(new Error(`Deep scan requires ffmpeg, but it could not be started: ${err.message}`)); + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`Deep scan requires ffmpeg, but \`ffmpeg -version\` exited with code ${code}.`)); + }); + }); + } + + return ffmpegAvailablePromise; +} + +function buildPlexPartStreamUrl(baseUrl, plexToken, mediaPart) { + const partKey = mediaPart && mediaPart.key; + if (!partKey) { + return null; + } + + const url = new URL(partKey, `${baseUrl}/`); + url.searchParams.set('download', '1'); + url.searchParams.set('X-Plex-Token', plexToken); + return url.toString(); +} + +async function resolveDeepScanSourceItem({ baseUrl, headers, item, fetchDetails = fetchPlexMovieDetails }) { + const mediaPart = item?.Media?.[0]?.Part?.[0]; + if (mediaPart?.key) { + return { sourceItem: item, usedMetadataLookup: false }; + } + + return { + sourceItem: await fetchDetails(baseUrl, headers, item.ratingKey), + usedMetadataLookup: true, + }; +} + +async function runFfmpegDeepScan(streamUrl, options = {}) { + const spawnImpl = options.spawnImpl || spawn; + const setTimeoutImpl = options.setTimeoutImpl || setTimeout; + const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout; + const timeoutMs = Number(options.timeoutMs || DEEP_SCAN_TIMEOUT_MS); + const termGraceMs = Number(options.termGraceMs || FFMPEG_TERM_GRACE_MS); + + return new Promise((resolve, reject) => { + const args = [ + '-hide_banner', + '-loglevel', + 'info', + '-nostdin', + '-xerror', + '-i', + streamUrl, + '-map', + '0:v:0', + '-map', + '0:a:0?', + '-f', + 'null', + '-', + ]; + const proc = spawnImpl('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + let timedOut = false; + let forcedKill = false; + let timeoutHandle = null; + let forceKillHandle = null; + + function clearTimers() { + if (timeoutHandle) { + clearTimeoutImpl(timeoutHandle); + timeoutHandle = null; + } + if (forceKillHandle) { + clearTimeoutImpl(forceKillHandle); + forceKillHandle = null; + } + } + + timeoutHandle = setTimeoutImpl(() => { + timedOut = true; + proc.kill('SIGTERM'); + forceKillHandle = setTimeoutImpl(() => { + forcedKill = true; + proc.kill('SIGKILL'); + }, termGraceMs); + }, timeoutMs); + + proc.on('error', (err) => { + clearTimers(); + reject(new Error(toSafeDeepScanMessage(err))); + }); + + proc.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + if (stderr.length > FFMPEG_LOG_LIMIT) { + stderr = stderr.slice(-FFMPEG_LOG_LIMIT); + } + }); + + proc.on('close', (code, signal) => { + clearTimers(); + + if (timedOut) { + const killDetail = forcedKill ? ` after SIGTERM+SIGKILL (${termGraceMs}ms grace)` : ' after SIGTERM'; + reject(new Error(`ffmpeg deep scan timed out after ${formatDeepScanTimeout(timeoutMs)}${killDetail}`)); + return; + } + + if (code === 0) { + resolve(stderr); + return; + } + + const detail = redactSensitiveUrlParams( + stderr.trim().split(/\r?\n/).slice(-3).join(' | ') || `exit=${code} signal=${signal || 'none'}` + ); + reject(new Error(`ffmpeg deep scan failed: ${detail}`)); + }); + }); +} + +function mergeDeepScannedMedia(item, sourceItem, scannedMedia) { + const itemMedia = item?.Media?.[0]; + const sourceMedia = sourceItem?.Media?.[0]; + + return { + ...item, + Media: [ + { + ...(itemMedia || sourceMedia || {}), + Part: itemMedia?.Part || sourceMedia?.Part || [], + videoResolution: scannedMedia.videoResolution || itemMedia?.videoResolution || sourceMedia?.videoResolution || '', + videoCodec: scannedMedia.videoCodec || itemMedia?.videoCodec || sourceMedia?.videoCodec || '', + audioCodec: scannedMedia.audioCodec || itemMedia?.audioCodec || sourceMedia?.audioCodec || '', + audioChannels: scannedMedia.audioChannels || itemMedia?.audioChannels || sourceMedia?.audioChannels || 0, + }, + ], + }; +} + +async function deepScanPlexVideoItem({ baseUrl, plexToken, headers, item, options = {} }) { + const { sourceItem, usedMetadataLookup } = await resolveDeepScanSourceItem({ + baseUrl, + headers, + item, + fetchDetails: options.fetchDetails, + }); + const media = sourceItem?.Media?.[0]; + const part = media?.Part?.[0]; + const streamUrl = buildPlexPartStreamUrl(baseUrl, plexToken, part); + + if (!streamUrl) { + throw new Error('missing Plex media part URL'); + } + + const stderrText = await runFfmpegDeepScan(streamUrl, options); + const scannedMedia = parseFfmpegStreamMetadata(stderrText); + if (!scannedMedia.videoResolution && !scannedMedia.videoCodec && !scannedMedia.audioCodec) { + throw new Error('ffmpeg deep scan completed without stream metadata'); + } + + return { + item: mergeDeepScannedMedia(item, sourceItem, scannedMedia), + usedMetadataLookup, + }; +} + +async function deepScanPlexVideoItems(items, context, options = {}) { + const stats = { + scanned: 0, + metadataLookups: 0, + fallbacks: 0, + }; + + const scannedItems = await mapWithConcurrency(items, options.concurrency || DEEP_SCAN_CONCURRENCY, async (item) => { + try { + const result = await deepScanPlexVideoItem({ ...context, item, options }); + stats.scanned += 1; + if (result.usedMetadataLookup) { + stats.metadataLookups += 1; + } + return result.item; + } catch (err) { + stats.fallbacks += 1; + console.warn( + `[Plex] Deep scan fallback for "${item.title || item.grandparentTitle || item.ratingKey}" (${item.ratingKey}): ${toSafeDeepScanMessage(err)}` + ); + return item; + } + }); + + return { + items: scannedItems, + stats, + }; +} /** * Fetches all library sections from Plex. @@ -26,9 +395,14 @@ async function fetchPlexLibraries(plexUrl, plexToken) { * If selectedSectionKeys is a non-empty array, only scans those sections (still filtered to type=movie). * Returns { movies, machineIdentifier } */ -async function fetchPlexMovies(plexUrl, plexToken, selectedSectionKeys = null) { +async function fetchPlexMovies(plexUrl, plexToken, selectedSectionKeys = null, options = {}) { const headers = { 'X-Plex-Token': plexToken, Accept: 'application/json' }; const baseUrl = plexUrl.replace(/\/$/, ''); + const deepScan = options.deepScan === true; + + if (deepScan) { + await ensureFfmpegAvailable(); + } // 1. Get server identity (machineIdentifier for deep links) console.log('[Plex] Fetching server identity...'); @@ -56,6 +430,9 @@ async function fetchPlexMovies(plexUrl, plexToken, selectedSectionKeys = null) { } const movies = []; + let deepScanFallbacks = 0; + let deepScanMetadataLookups = 0; + let deepScanScanned = 0; for (const section of movieSections) { console.log(`[Plex] Fetching movies from section "${section.title}" (key=${section.key})...`); @@ -67,46 +444,33 @@ async function fetchPlexMovies(plexUrl, plexToken, selectedSectionKeys = null) { const items = moviesRes.data.MediaContainer.Metadata || []; console.log(`[Plex] Section "${section.title}": ${items.length} items`); + let scanItems = items; + if (deepScan) { + const batchResult = await deepScanPlexVideoItems(items, { baseUrl, plexToken, headers }); + scanItems = batchResult.items; + deepScanFallbacks += batchResult.stats.fallbacks; + deepScanMetadataLookups += batchResult.stats.metadataLookups; + deepScanScanned += batchResult.stats.scanned; + } + let withTmdb = 0; let withoutTmdb = 0; - for (const item of items) { - // Extract duration from the first media item - const media = item.Media && item.Media[0]; - const durationMs = media ? media.duration : item.duration; - - // Extract TMDB id from Guids — requires includeGuids=1 on the request - let tmdbId = null; - if (item.Guid && Array.isArray(item.Guid)) { - const tmdbGuid = item.Guid.find((g) => g.id && g.id.startsWith('tmdb://')); - if (tmdbGuid) { - tmdbId = parseInt(tmdbGuid.id.replace('tmdb://', ''), 10); - } - } - - if (tmdbId) withTmdb++; else withoutTmdb++; - - movies.push({ - title: item.title, - year: item.year, - tmdbId, - ratingKey: item.ratingKey, - sectionKey: String(section.key), - sectionTitle: section.title, - durationMs: durationMs || 0, - durationMin: durationMs ? Math.round(durationMs / MS_PER_MINUTE) : 0, - videoResolution: (media && media.videoResolution) || '', - videoCodec: (media && media.videoCodec) || '', - audioCodec: (media && media.audioCodec) || '', - audioChannels: (media && media.audioChannels) || 0, - }); + for (const item of scanItems) { + const movie = mapMovieMetadata(item, section); + if (movie.tmdbId) withTmdb++; else withoutTmdb++; + movies.push(movie); } console.log(`[Plex] Section "${section.title}": ${withTmdb} with TMDB ID, ${withoutTmdb} without`); } + if (deepScan) { + console.log(`[Plex] Deep scan completed: ${deepScanScanned} ffmpeg decode(s), ${deepScanMetadataLookups} metadata lookup(s), ${deepScanFallbacks} fallback item(s)`); + } + console.log(`[Plex] Total movies fetched: ${movies.length}`); - return { movies, machineIdentifier }; + return { movies, machineIdentifier, deepScan, deepScanFallbacks, deepScanMetadataLookups, deepScanScanned }; } /** @@ -114,9 +478,14 @@ async function fetchPlexMovies(plexUrl, plexToken, selectedSectionKeys = null) { * If selectedSectionKeys is a non-empty array, only scans those sections (still filtered to type=show). * Returns { episodes, machineIdentifier } where each episode includes tvdbId from its parent show. */ -async function fetchPlexTvEpisodes(plexUrl, plexToken, selectedSectionKeys = null) { +async function fetchPlexTvEpisodes(plexUrl, plexToken, selectedSectionKeys = null, options = {}) { const headers = { 'X-Plex-Token': plexToken, Accept: 'application/json' }; const baseUrl = plexUrl.replace(/\/$/, ''); + const deepScan = options.deepScan === true; + + if (deepScan) { + await ensureFfmpegAvailable(); + } // 1. Get server identity (machineIdentifier for deep links) console.log('[Plex] Fetching server identity...'); @@ -143,6 +512,9 @@ async function fetchPlexTvEpisodes(plexUrl, plexToken, selectedSectionKeys = nul } const episodes = []; + let deepScanFallbacks = 0; + let deepScanMetadataLookups = 0; + let deepScanScanned = 0; for (const section of tvSections) { console.log(`[Plex] Fetching shows from section "${section.title}" (key=${section.key})...`); @@ -177,7 +549,16 @@ async function fetchPlexTvEpisodes(plexUrl, plexToken, selectedSectionKeys = nul const epItems = episodesRes.data.MediaContainer.Metadata || []; console.log(`[Plex] Section "${section.title}": ${epItems.length} episodes`); - for (const ep of epItems) { + let scanItems = epItems; + if (deepScan) { + const batchResult = await deepScanPlexVideoItems(epItems, { baseUrl, plexToken, headers }); + scanItems = batchResult.items; + deepScanFallbacks += batchResult.stats.fallbacks; + deepScanMetadataLookups += batchResult.stats.metadataLookups; + deepScanScanned += batchResult.stats.scanned; + } + + for (const ep of scanItems) { const media = ep.Media && ep.Media[0]; const durationMs = media ? media.duration : ep.duration; const showInfo = showMap.get(String(ep.grandparentRatingKey)) || {}; @@ -198,8 +579,29 @@ async function fetchPlexTvEpisodes(plexUrl, plexToken, selectedSectionKeys = nul } } + if (deepScan) { + console.log(`[Plex] Episode deep scan completed: ${deepScanScanned} ffmpeg decode(s), ${deepScanMetadataLookups} metadata lookup(s), ${deepScanFallbacks} fallback item(s)`); + } + console.log(`[Plex] Total TV episodes fetched: ${episodes.length}`); - return { episodes, machineIdentifier }; + return { episodes, machineIdentifier, deepScan, deepScanFallbacks, deepScanMetadataLookups, deepScanScanned }; } -module.exports = { fetchPlexMovies, fetchPlexLibraries, fetchPlexTvEpisodes }; +module.exports = { + fetchPlexMovies, + fetchPlexLibraries, + fetchPlexTvEpisodes, + __test: { + buildPlexPartStreamUrl, + deepScanPlexVideoItem, + deepScanPlexVideoItems, + ensureFfmpegAvailable, + mergeDeepScannedMedia, + parseFfmpegStreamMetadata, + redactSensitiveUrlParams, + toSafeDeepScanMessage, + formatDeepScanTimeout, + resolveDeepScanSourceItem, + runFfmpegDeepScan, + }, +}; diff --git a/backend/src/db/index.js b/backend/src/db/index.js index d8a6fe0..4b0d643 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -4,6 +4,8 @@ const path = require('path'); const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../../data/plex-issue-finder.db'); let db; +const DEEP_SCAN_SETTING_KEY = 'plex_deep_scan'; +const LEGACY_DEEP_SCAN_SETTING_KEY = 'quality_deep_scan'; function getDb() { if (!db) { @@ -53,6 +55,7 @@ function initSchema() { ['episode_min_diff_min', '3'], ['plex_library_ids', ''], ['quality_thresholds', '{}'], + [DEEP_SCAN_SETTING_KEY, '0'], ]; const insert = db.prepare( 'INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)' @@ -61,6 +64,16 @@ function initSchema() { insert.run(key, value); } + const legacyDeepScan = db + .prepare('SELECT value FROM settings WHERE key = ?') + .get(LEGACY_DEEP_SCAN_SETTING_KEY); + if (legacyDeepScan) { + db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)') + .run(DEEP_SCAN_SETTING_KEY, legacyDeepScan.value); + db.prepare('DELETE FROM settings WHERE key = ?') + .run(LEGACY_DEEP_SCAN_SETTING_KEY); + } + // Seed from .env if values are present and settings are still empty const seeds = [ ['plex_url', process.env.PLEX_URL], @@ -120,6 +133,7 @@ function getCacheStats() { } module.exports = { + DEEP_SCAN_SETTING_KEY, getDb, getSetting, getAllSettings, diff --git a/backend/src/routes/episodeDuration.js b/backend/src/routes/episodeDuration.js index b511200..eb900b2 100644 --- a/backend/src/routes/episodeDuration.js +++ b/backend/src/routes/episodeDuration.js @@ -3,7 +3,7 @@ const axios = require('axios'); const router = express.Router(); const { fetchPlexTvEpisodes } = require('../api/plex'); const { fetchSonarrSeries } = require('../api/sonarr'); -const { getSetting } = require('../db'); +const { DEEP_SCAN_SETTING_KEY, getSetting } = require('../db'); const CONCURRENCY_LIMIT = 5; @@ -29,6 +29,14 @@ function validateConfig(config) { return missing; } +function parseBoolean(value) { + if (value == null) return null; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return null; +} + async function mapWithConcurrency(items, fn, limit) { const results = new Array(items.length); let nextIndex = 0; @@ -58,13 +66,23 @@ router.get('/check', async (req, res) => { console.log('\n[EpisodeDuration] Starting episode duration check'); try { + const deepScanSetting = parseBoolean(getSetting(DEEP_SCAN_SETTING_KEY)) || false; + const deepScan = parseBoolean(req.query.deep) ?? deepScanSetting; + // --- Step 1: Fetch Plex TV episodes --- - let plexEpisodes, machineIdentifier; + let plexEpisodes, machineIdentifier, deepScanFallbacks, deepScanMetadataLookups, deepScanScanned; try { - ({ episodes: plexEpisodes, machineIdentifier } = await fetchPlexTvEpisodes( + ({ + episodes: plexEpisodes, + machineIdentifier, + deepScanFallbacks, + deepScanMetadataLookups, + deepScanScanned, + } = await fetchPlexTvEpisodes( config.plexUrl, config.plexToken, - config.selectedLibraryIds + config.selectedLibraryIds, + { deepScan } )); } catch (plexErr) { console.error('[EpisodeDuration] Plex fetch FAILED:', plexErr.message); @@ -182,6 +200,11 @@ router.get('/check', async (req, res) => { plexUrl: config.plexUrl, sonarrUrl: config.sonarrUrl, plexMachineId: machineIdentifier, + deepScan, + deepScanFallbacks, + deepScanMetadataLookups, + deepScanScanned, + deepScanMode: deepScan ? 'ffmpeg_full_decode' : 'library_metadata', }, flagged, noMatch, diff --git a/backend/src/routes/quality.js b/backend/src/routes/quality.js index e4dc3fc..3da0308 100644 --- a/backend/src/routes/quality.js +++ b/backend/src/routes/quality.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); const { fetchPlexMovies } = require('../api/plex'); const { fetchRadarrMovies } = require('../api/radarr'); -const { getSetting } = require('../db'); +const { DEEP_SCAN_SETTING_KEY, getSetting } = require('../db'); const RESOLUTION_RANK = { '480p': 1, '720p': 2, '1080p': 3, '4k': 4 }; @@ -16,6 +16,14 @@ function parseResolution(raw) { return { label: raw, rank: 0 }; } +function parseBoolean(value) { + if (value == null) return null; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return null; +} + // GET /api/quality/check router.get('/check', async (req, res) => { const plexUrl = getSetting('plex_url'); @@ -37,12 +45,16 @@ router.get('/check', async (req, res) => { thresholds = {}; } + const deepScanSetting = parseBoolean(getSetting(DEEP_SCAN_SETTING_KEY)) || false; + const deepScan = parseBoolean(req.query.deep) ?? deepScanSetting; + console.log('\n[Quality] Starting quality check'); console.log('[Quality] Thresholds by section:', thresholds); + console.log('[Quality] Deep scan:', deepScan); try { - const [{ movies, machineIdentifier }, radarrMap] = await Promise.all([ - fetchPlexMovies(plexUrl, plexToken, selectedLibraryIds), + const [{ movies, machineIdentifier, deepScanFallbacks, deepScanMetadataLookups, deepScanScanned }, radarrMap] = await Promise.all([ + fetchPlexMovies(plexUrl, plexToken, selectedLibraryIds, { deepScan }), radarrUrl && radarrApiKey ? fetchRadarrMovies(radarrUrl, radarrApiKey) : Promise.resolve(new Map()), ]); console.log(`[Quality] Fetched ${movies.length} movies from Plex, ${radarrMap.size} from Radarr`); @@ -95,6 +107,11 @@ router.get('/check', async (req, res) => { plexUrl, plexMachineId: machineIdentifier, radarrUrl: radarrUrl || '', + deepScan, + deepScanFallbacks, + deepScanMetadataLookups, + deepScanScanned, + deepScanMode: deepScan ? 'ffmpeg_full_decode' : 'library_metadata', }, flagged, overThreshold, diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index fa8087a..831a83c 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -1,8 +1,8 @@ const express = require('express'); const router = express.Router(); -const { getAllSettings, setSetting } = require('../db'); +const { DEEP_SCAN_SETTING_KEY, getAllSettings, setSetting } = require('../db'); -const ALLOWED_KEYS = ['plex_url', 'plex_token', 'radarr_url', 'radarr_api_key', 'sonarr_url', 'sonarr_api_key', 'leeway_percent', 'movie_min_diff_min', 'episode_min_diff_min', 'plex_library_ids', 'quality_thresholds']; +const ALLOWED_KEYS = ['plex_url', 'plex_token', 'radarr_url', 'radarr_api_key', 'sonarr_url', 'sonarr_api_key', 'leeway_percent', 'movie_min_diff_min', 'episode_min_diff_min', 'plex_library_ids', 'quality_thresholds', DEEP_SCAN_SETTING_KEY, 'quality_deep_scan']; // GET /api/settings router.get('/', (req, res) => { @@ -20,6 +20,7 @@ router.get('/', (req, res) => { episode_min_diff_min: settings.episode_min_diff_min || '3', plex_library_ids: settings.plex_library_ids || '', quality_thresholds: settings.quality_thresholds || '{}', + plex_deep_scan: settings[DEEP_SCAN_SETTING_KEY] || settings.quality_deep_scan || '0', plex_token_set: !!settings.plex_token, radarr_api_key_set: !!settings.radarr_api_key, sonarr_api_key_set: !!settings.sonarr_api_key, @@ -54,7 +55,12 @@ router.post('/', (req, res) => { } } - setSetting(key, value); + if ((key === DEEP_SCAN_SETTING_KEY || key === 'quality_deep_scan') && value !== '0' && value !== '1') { + errors.push('plex_deep_scan must be 0 or 1'); + continue; + } + + setSetting(key === 'quality_deep_scan' ? DEEP_SCAN_SETTING_KEY : key, value); } } diff --git a/backend/test/api/plex.test.js b/backend/test/api/plex.test.js new file mode 100644 index 0000000..9edce82 --- /dev/null +++ b/backend/test/api/plex.test.js @@ -0,0 +1,187 @@ +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const test = require('node:test'); + +const { __test } = require('../../src/api/plex'); + +function createMockProcess() { + const proc = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.killSignals = []; + proc.kill = (signal) => { + proc.killSignals.push(signal); + return true; + }; + return proc; +} + +function createTimerHarness() { + let nextId = 1; + const timers = []; + + function setTimeoutImpl(fn, ms) { + const id = nextId++; + timers.push({ id, fn, ms }); + return id; + } + + function clearTimeoutImpl(id) { + const index = timers.findIndex((timer) => timer.id === id); + if (index >= 0) { + timers.splice(index, 1); + } + } + + function fireByDelay(ms) { + const index = timers.findIndex((timer) => timer.ms === ms); + assert.notEqual(index, -1, `expected a timer with delay ${ms}`); + const [{ fn }] = timers.splice(index, 1); + fn(); + } + + return { setTimeoutImpl, clearTimeoutImpl, fireByDelay }; +} + +test('deepScanPlexVideoItems scans movie and episode batches with ffmpeg-derived metadata', async () => { + const ffmpegOutputs = [ + [ + 'Input #0, matroska,webm, from "movie":', + ' Stream #0:0: Video: hevc, yuv420p, 3840x2160', + ' Stream #0:1: Audio: eac3, 48000 Hz, 5.1, fltp', + ].join('\n'), + [ + 'Input #0, mov,mp4,m4a,3gp,3g2,mj2, from "episode":', + ' Stream #0:0: Video: h264, yuv420p, 1280x720', + ' Stream #0:1: Audio: aac, 48000 Hz, stereo, fltp', + ].join('\n'), + ]; + let spawnCount = 0; + let detailLookups = 0; + + const movieItem = { + ratingKey: 'movie-1', + title: 'Movie One', + Media: [{ Part: [{ key: '/library/parts/movie-1/file.mkv' }], videoResolution: '480', videoCodec: 'mpeg4', audioCodec: 'mp3', audioChannels: 2 }], + }; + const episodeItem = { + ratingKey: 'episode-1', + grandparentTitle: 'Show One', + title: 'Episode One', + Media: [{ videoResolution: '480', videoCodec: 'mpeg4', audioCodec: 'mp3', audioChannels: 2 }], + }; + + const result = await __test.deepScanPlexVideoItems( + [movieItem, episodeItem], + { baseUrl: 'http://plex.local:32400', plexToken: 'token', headers: {} }, + { + concurrency: 2, + spawnImpl() { + const proc = createMockProcess(); + const stderrText = ffmpegOutputs[spawnCount++]; + process.nextTick(() => { + proc.stderr.emit('data', Buffer.from(stderrText)); + proc.emit('close', 0, null); + }); + return proc; + }, + async fetchDetails() { + detailLookups += 1; + return { + ...episodeItem, + Media: [{ Part: [{ key: '/library/parts/episode-1/file.mkv' }], videoResolution: '480', videoCodec: 'mpeg4', audioCodec: 'mp3', audioChannels: 2 }], + }; + }, + } + ); + + assert.equal(result.stats.scanned, 2); + assert.equal(result.stats.metadataLookups, 1); + assert.equal(result.stats.fallbacks, 0); + assert.equal(detailLookups, 1); + assert.equal(result.items[0].Media[0].videoResolution, '4k'); + assert.equal(result.items[0].Media[0].videoCodec, 'hevc'); + assert.equal(result.items[0].Media[0].audioCodec, 'eac3'); + assert.equal(result.items[0].Media[0].audioChannels, 6); + assert.equal(result.items[1].Media[0].videoResolution, '720'); + assert.equal(result.items[1].Media[0].videoCodec, 'h264'); + assert.equal(result.items[1].Media[0].audioCodec, 'aac'); + assert.equal(result.items[1].Media[0].audioChannels, 2); +}); + +test('runFfmpegDeepScan times out with SIGTERM before SIGKILL when ffmpeg exits during grace period', async () => { + const proc = createMockProcess(); + const timers = createTimerHarness(); + + const promise = __test.runFfmpegDeepScan('http://plex.local/video', { + spawnImpl() { + return proc; + }, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + timeoutMs: 10, + termGraceMs: 5, + }); + + timers.fireByDelay(10); + assert.deepEqual(proc.killSignals, ['SIGTERM']); + proc.emit('close', null, 'SIGTERM'); + + await assert.rejects(promise, /timed out after 10ms after SIGTERM/); + assert.deepEqual(proc.killSignals, ['SIGTERM']); +}); + +test('runFfmpegDeepScan escalates to SIGKILL when ffmpeg ignores SIGTERM', async () => { + const proc = createMockProcess(); + const timers = createTimerHarness(); + + const promise = __test.runFfmpegDeepScan('http://plex.local/video', { + spawnImpl() { + return proc; + }, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + timeoutMs: 10, + termGraceMs: 5, + }); + + timers.fireByDelay(10); + timers.fireByDelay(5); + assert.deepEqual(proc.killSignals, ['SIGTERM', 'SIGKILL']); + proc.emit('close', null, 'SIGKILL'); + + await assert.rejects(promise, /SIGTERM\+SIGKILL/); +}); + +test('runFfmpegDeepScan redacts Plex tokens from stderr-derived error messages', async () => { + const proc = createMockProcess(); + + const promise = __test.runFfmpegDeepScan('http://plex.local/video', { + spawnImpl() { + process.nextTick(() => { + proc.stderr.emit('data', Buffer.from('http://plex.local/library/parts/1/file.mkv?X-Plex-Token=super-secret-token\n')); + proc.emit('close', 1, null); + }); + return proc; + }, + }); + + await assert.rejects( + promise, + (err) => { + assert.match(err.message, /X-Plex-Token=\[REDACTED\]/); + assert.doesNotMatch(err.message, /super-secret-token/); + return true; + } + ); +}); + +test('toSafeDeepScanMessage redacts common Plex token parameter names', () => { + const message = __test.toSafeDeepScanMessage( + 'ffmpeg failed for http://plex.local/file.mkv?X-Plex-Token=secret&plexToken=other&token=third' + ); + + assert.match(message, /X-Plex-Token=\[REDACTED\]/); + assert.match(message, /plexToken=\[REDACTED\]/); + assert.match(message, /token=\[REDACTED\]/); + assert.doesNotMatch(message, /secret|other|third/); +}); diff --git a/frontend/src/stores/episodeDuration.js b/frontend/src/stores/episodeDuration.js index ca3ee7c..95cfea4 100644 --- a/frontend/src/stores/episodeDuration.js +++ b/frontend/src/stores/episodeDuration.js @@ -45,11 +45,17 @@ export const useEpisodeDurationStore = defineStore('episodeDuration', () => { const loading = ref(false) const error = ref(null) - async function runCheck() { + async function runCheck(options = {}) { loading.value = true error.value = null try { - const data = await apiGet('/api/episode-duration/check') + const params = new URLSearchParams() + if (options.deepScan !== undefined) { + params.set('deep', options.deepScan ? '1' : '0') + } + + const path = params.size > 0 ? `/api/episode-duration/check?${params.toString()}` : '/api/episode-duration/check' + const data = await apiGet(path) // markRaw prevents Vue from making the large result arrays deeply reactive, // which keeps the table views from doing unnecessary work on large result sets. result.value = markRaw(data) diff --git a/frontend/src/stores/movieQuality.js b/frontend/src/stores/movieQuality.js index 1fb152e..6256825 100644 --- a/frontend/src/stores/movieQuality.js +++ b/frontend/src/stores/movieQuality.js @@ -26,11 +26,17 @@ export const useMovieQualityStore = defineStore('movieQuality', () => { } catch { /* ignore */ } } - async function runCheck() { + async function runCheck(options = {}) { loading.value = true error.value = null try { - const data = await apiGet('/api/quality/check') + const params = new URLSearchParams() + if (options.deepScan !== undefined) { + params.set('deep', options.deepScan ? '1' : '0') + } + + const path = params.size > 0 ? `/api/quality/check?${params.toString()}` : '/api/quality/check' + const data = await apiGet(path) result.value = markRaw(data) lastRun.value = new Date() saveToStorage() diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index f9479e9..3b9687d 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -15,6 +15,7 @@ export const useSettingsStore = defineStore('settings', () => { episode_min_diff_min: '3', plex_library_ids: '', quality_thresholds: '{}', + plex_deep_scan: '0', plex_token_set: false, radarr_api_key_set: false, sonarr_api_key_set: false, diff --git a/frontend/src/views/EpisodeDurationView.vue b/frontend/src/views/EpisodeDurationView.vue index 753b7df..4900fba 100644 --- a/frontend/src/views/EpisodeDurationView.vue +++ b/frontend/src/views/EpisodeDurationView.vue @@ -8,7 +8,16 @@