From 6094a9550bbd5e3a46858ec8fb6dc2bfd61e6604 Mon Sep 17 00:00:00 2001 From: Zeno <288338471+zeno-codebot@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:07:37 +0000 Subject: [PATCH] feat: add per-show episode scan --- README.md | 2 +- backend/src/routes/episodeDuration.js | 32 ++++++++++++ backend/src/routes/sonarr.js | 41 ++++++++++++++++ frontend/src/stores/episodeDuration.js | 3 ++ frontend/src/views/EpisodeDurationView.vue | 57 ++++++++++++++++++++-- 5 files changed, 130 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4609ecf..60efa2c 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ 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**. +3. Navigate to **Episode Duration Check**, optionally choose a single show, and click **Run Check**. 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. diff --git a/backend/src/routes/episodeDuration.js b/backend/src/routes/episodeDuration.js index eb900b2..54c9ffc 100644 --- a/backend/src/routes/episodeDuration.js +++ b/backend/src/routes/episodeDuration.js @@ -37,6 +37,12 @@ function parseBoolean(value) { return null; } +function parseOptionalInteger(value) { + if (value == null || value === '') return null; + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) ? parsed : Number.NaN; +} + async function mapWithConcurrency(items, fn, limit) { const results = new Array(items.length); let nextIndex = 0; @@ -68,6 +74,11 @@ router.get('/check', async (req, res) => { try { const deepScanSetting = parseBoolean(getSetting(DEEP_SCAN_SETTING_KEY)) || false; const deepScan = parseBoolean(req.query.deep) ?? deepScanSetting; + const requestedSeriesId = parseOptionalInteger(req.query.seriesId); + + if (Number.isNaN(requestedSeriesId)) { + return res.status(400).json({ error: 'seriesId must be an integer when provided.' }); + } // --- Step 1: Fetch Plex TV episodes --- let plexEpisodes, machineIdentifier, deepScanFallbacks, deepScanMetadataLookups, deepScanScanned; @@ -119,6 +130,17 @@ router.get('/check', async (req, res) => { console.log(`[EpisodeDuration] Sonarr: ${allSonarrSeries.length} series, ${sonarrSeriesMap.size} with TVDB IDs`); + let selectedSeries = null; + if (requestedSeriesId != null) { + selectedSeries = allSonarrSeries.find((series) => series.id === requestedSeriesId) || null; + if (!selectedSeries) { + return res.status(404).json({ error: `Sonarr series ${requestedSeriesId} was not found.` }); + } + if (!selectedSeries.tvdbId) { + return res.status(400).json({ error: `Sonarr series "${selectedSeries.title}" does not have a TVDB ID, so Plex episodes cannot be matched.` }); + } + } + // --- Step 3: Compare durations --- const flagged = []; const ok = []; @@ -127,6 +149,7 @@ router.get('/check', async (req, res) => { for (const ep of plexEpisodes) { // Only process episodes from Sonarr-managed shows (matched by TVDB ID) if (!ep.tvdbId) continue; + if (selectedSeries && ep.tvdbId !== selectedSeries.tvdbId) continue; const sonarr = sonarrSeriesMap.get(ep.tvdbId); if (!sonarr) continue; // Show not in Sonarr — skip silently @@ -205,6 +228,15 @@ router.get('/check', async (req, res) => { deepScanMetadataLookups, deepScanScanned, deepScanMode: deepScan ? 'ffmpeg_full_decode' : 'library_metadata', + selectedSeries: selectedSeries + ? { + id: selectedSeries.id, + title: selectedSeries.title, + year: selectedSeries.year || null, + titleSlug: selectedSeries.titleSlug || '', + tvdbId: selectedSeries.tvdbId, + } + : null, }, flagged, noMatch, diff --git a/backend/src/routes/sonarr.js b/backend/src/routes/sonarr.js index 4576e80..d8e15dc 100644 --- a/backend/src/routes/sonarr.js +++ b/backend/src/routes/sonarr.js @@ -24,6 +24,10 @@ function validateSonarrConfig(config) { return missing; } +function compareSeriesTitles(a, b) { + return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }); +} + /** * Process an array of items with limited concurrency. * Returns an array of results in the same order as the input. @@ -176,6 +180,43 @@ router.get('/check', async (req, res) => { } }); +// GET /api/sonarr/series — lightweight series list for selectors +router.get('/series', async (req, res) => { + const config = getSonarrConfig(); + const missing = validateSonarrConfig(config); + if (missing.length > 0) { + return res.status(400).json({ + error: `Missing configuration: ${missing.join(', ')}. Please configure Sonarr in Settings.`, + }); + } + + try { + const allSeries = await fetchSonarrSeries(config.sonarrUrl, config.sonarrApiKey); + const series = allSeries + .filter((entry) => entry.tvdbId) + .map((entry) => ({ + id: entry.id, + title: entry.title, + year: entry.year || null, + titleSlug: entry.titleSlug || '', + monitored: Boolean(entry.monitored), + tvdbId: entry.tvdbId, + })) + .sort(compareSeriesTitles); + + res.json({ + summary: { + totalSeries: series.length, + sonarrUrl: config.sonarrUrl, + }, + series, + }); + } catch (err) { + console.error('[Sonarr] Failed to fetch series list:', err.message); + res.status(500).json({ error: err.message }); + } +}); + // POST /api/sonarr/monitor — enable monitoring for selected episodes/seasons router.post('/monitor', async (req, res) => { const { episodeIds, seasons } = req.body; diff --git a/frontend/src/stores/episodeDuration.js b/frontend/src/stores/episodeDuration.js index 95cfea4..8034fbe 100644 --- a/frontend/src/stores/episodeDuration.js +++ b/frontend/src/stores/episodeDuration.js @@ -53,6 +53,9 @@ export const useEpisodeDurationStore = defineStore('episodeDuration', () => { if (options.deepScan !== undefined) { params.set('deep', options.deepScan ? '1' : '0') } + if (options.seriesId != null && options.seriesId !== '') { + params.set('seriesId', String(options.seriesId)) + } const path = params.size > 0 ? `/api/episode-duration/check?${params.toString()}` : '/api/episode-duration/check' const data = await apiGet(path) diff --git a/frontend/src/views/EpisodeDurationView.vue b/frontend/src/views/EpisodeDurationView.vue index 4900fba..64c9954 100644 --- a/frontend/src/views/EpisodeDurationView.vue +++ b/frontend/src/views/EpisodeDurationView.vue @@ -8,6 +8,15 @@

+ + + + - + {{ loading ? 'Running…' : 'Run Check' }} @@ -30,6 +39,10 @@ {{ error }} + + {{ seriesError }} + + Deep scan ran a full `ffmpeg` decode pass for each episode in this run. @@ -37,6 +50,10 @@ + + Scan scope: {{ seriesLabel(result.summary.selectedSeries) }} + +
@@ -182,8 +199,8 @@ import { computed, onMounted, ref } from 'vue' import { useEpisodeDurationStore } from '../stores/episodeDuration' import { useSettingsStore } from '../stores/settings' import { useAppToast } from '../composables/useAppToast' -import { apiPost } from '../api/client' -import { CindorAlert, CindorButton, CindorCheckbox, CindorSpinner, CindorStatCard } from 'cindor-ui-vue' +import { apiGet, apiPost } from '../api/client' +import { CindorAlert, CindorButton, CindorCheckbox, CindorOption, CindorSelect, CindorSpinner, CindorStatCard } from 'cindor-ui-vue' import AppDataTable from '../components/AppDataTable.vue' import AppExternalLink from '../components/AppExternalLink.vue' import AppIcon from '../components/AppIcon.vue' @@ -204,6 +221,10 @@ const sonarrBaseUrl = computed(() => store.result?.summary?.sonarrUrl || '') const selectedEpisodes = ref([]) const redownloading = ref(false) const deepScan = ref(false) +const selectedSeriesId = ref('') +const seriesOptions = ref([]) +const seriesLoading = ref(false) +const seriesError = ref(null) const flaggedColumns = [ { key: 'showTitle', label: 'Show', sortable: true }, @@ -251,6 +272,26 @@ async function redownload() { } } +async function loadSeriesOptions() { + seriesLoading.value = true + seriesError.value = null + try { + const data = await apiGet('/api/sonarr/series') + seriesOptions.value = Array.isArray(data.series) ? data.series : [] + } catch (err) { + seriesError.value = `Could not load Sonarr shows: ${err.message}` + } finally { + seriesLoading.value = false + } +} + +function runCheck() { + return store.runCheck({ + deepScan: deepScan.value, + seriesId: selectedSeriesId.value ? Number.parseInt(selectedSeriesId.value, 10) : null, + }) +} + const lastRunLabel = computed(() => { if (!store.lastRun) return null const now = new Date() @@ -266,7 +307,7 @@ const lastRunLabel = computed(() => { onMounted(async () => { const initialDeepScan = deepScan.value - await settingsStore.fetchSettings() + await Promise.all([settingsStore.fetchSettings(), loadSeriesOptions()]) if (deepScan.value === initialDeepScan) { deepScan.value = settingsStore.settings.plex_deep_scan === '1' } @@ -279,6 +320,10 @@ function formatMinutes(min) { return h > 0 ? `${h}h ${m}m` : `${m}m` } +function seriesLabel(series) { + return series.year ? `${series.title} (${series.year})` : series.title +} + function plexLink(ratingKey) { const key = encodeURIComponent(`/library/metadata/${ratingKey}`) return `${plexBaseUrl.value}/web/index.html#!/server/${plexMachineId.value}/details?key=${key}` @@ -312,4 +357,8 @@ function plexLink(ratingKey) { color: var(--fg-muted); font-size: var(--text-sm); } + +.show-select { + min-width: 15rem; +}