From 47a4474a626894a529c07bae36d7bdeda8bfa6b5 Mon Sep 17 00:00:00 2001 From: Zeno <288338471+zeno-codebot@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:08:33 +0000 Subject: [PATCH 1/4] feat: add deep video scan option --- README.md | 7 +- backend/src/api/plex.js | 116 +++++++++++++++++------- backend/src/db/index.js | 1 + backend/src/routes/quality.js | 18 +++- backend/src/routes/settings.js | 8 +- frontend/src/stores/movieQuality.js | 10 +- frontend/src/stores/settings.js | 1 + frontend/src/views/MovieQualityView.vue | 36 +++++++- frontend/src/views/SettingsView.vue | 40 ++++++++ 9 files changed, 194 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index f3391a6..1c350d4 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 fetch per-movie Plex metadata for more accurate media details. This is 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 diff --git a/backend/src/api/plex.js b/backend/src/api/plex.js index 77f3ba9..d9a7882 100644 --- a/backend/src/api/plex.js +++ b/backend/src/api/plex.js @@ -1,6 +1,65 @@ const axios = require('axios'); const MS_PER_MINUTE = 60000; +const DEEP_SCAN_CONCURRENCY = 6; + +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, + }; +} + +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 fetchPlexMovieMetadata(baseUrl, headers, ratingKey) { + const res = await axios.get(`${baseUrl}/library/metadata/${ratingKey}`, { + headers, + params: { includeGuids: 1 }, + }); + + return res.data?.MediaContainer?.Metadata?.[0] || null; +} /** * Fetches all library sections from Plex. @@ -26,9 +85,10 @@ 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; // 1. Get server identity (machineIdentifier for deep links) console.log('[Plex] Fetching server identity...'); @@ -56,6 +116,7 @@ async function fetchPlexMovies(plexUrl, plexToken, selectedSectionKeys = null) { } const movies = []; + let deepScanFallbacks = 0; for (const section of movieSections) { console.log(`[Plex] Fetching movies from section "${section.title}" (key=${section.key})...`); @@ -67,46 +128,37 @@ async function fetchPlexMovies(plexUrl, plexToken, selectedSectionKeys = null) { const items = moviesRes.data.MediaContainer.Metadata || []; console.log(`[Plex] Section "${section.title}": ${items.length} items`); - 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); + const scanItems = deepScan + ? await mapWithConcurrency(items, DEEP_SCAN_CONCURRENCY, async (item) => { + try { + const detailedItem = await fetchPlexMovieMetadata(baseUrl, headers, item.ratingKey); + return detailedItem || item; + } catch (err) { + deepScanFallbacks += 1; + console.warn(`[Plex] Deep scan fallback for "${item.title}" (${item.ratingKey}): ${err.message}`); + return item; } - } + }) + : items; - if (tmdbId) withTmdb++; else withoutTmdb++; + let withTmdb = 0; + let withoutTmdb = 0; - 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 with ${deepScanFallbacks} fallback item(s)`); + } + console.log(`[Plex] Total movies fetched: ${movies.length}`); - return { movies, machineIdentifier }; + return { movies, machineIdentifier, deepScan, deepScanFallbacks }; } /** diff --git a/backend/src/db/index.js b/backend/src/db/index.js index d8a6fe0..16bdac8 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -53,6 +53,7 @@ function initSchema() { ['episode_min_diff_min', '3'], ['plex_library_ids', ''], ['quality_thresholds', '{}'], + ['quality_deep_scan', '0'], ]; const insert = db.prepare( 'INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)' diff --git a/backend/src/routes/quality.js b/backend/src/routes/quality.js index e4dc3fc..e643fcc 100644 --- a/backend/src/routes/quality.js +++ b/backend/src/routes/quality.js @@ -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('quality_deep_scan')) || 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 }, 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,8 @@ router.get('/check', async (req, res) => { plexUrl, plexMachineId: machineIdentifier, radarrUrl: radarrUrl || '', + deepScan, + deepScanFallbacks, }, flagged, overThreshold, diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index fa8087a..f15883d 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); const { 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', '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 || '{}', + quality_deep_scan: 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,6 +55,11 @@ router.post('/', (req, res) => { } } + if (key === 'quality_deep_scan' && value !== '0' && value !== '1') { + errors.push('quality_deep_scan must be 0 or 1'); + continue; + } + setSetting(key, value); } } 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..7d5bfef 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: '{}', + quality_deep_scan: '0', plex_token_set: false, radarr_api_key_set: false, sonarr_api_key_set: false, diff --git a/frontend/src/views/MovieQualityView.vue b/frontend/src/views/MovieQualityView.vue index 2a1fd86..9a58005 100644 --- a/frontend/src/views/MovieQualityView.vue +++ b/frontend/src/views/MovieQualityView.vue @@ -8,7 +8,16 @@