Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions backend/src/routes/episodeDuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [];
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions backend/src/routes/sonarr.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/stores/episodeDuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 53 additions & 4 deletions frontend/src/views/EpisodeDurationView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
</p>
</div>
<div class="header-actions">
<CindorSelect v-model="selectedSeriesId" :disabled="loading || seriesLoading" class="show-select">
<CindorOption label="All shows" value="" />
<CindorOption
v-for="series in seriesOptions"
:key="series.id"
:label="seriesLabel(series)"
:value="String(series.id)"
/>
</CindorSelect>
<label class="inline-toggle" for="episode-duration-deep-scan">
<CindorCheckbox
id="episode-duration-deep-scan"
Expand All @@ -17,7 +26,7 @@
/>
<span>Deep scan</span>
</label>
<CindorButton type="button" :disabled="loading" @click="store.runCheck({ deepScan })">
<CindorButton type="button" :disabled="loading" @click="runCheck">
<span class="button-content">
<AppIcon :name="loading ? 'loader-pinwheel' : 'play'" :size="16" />
<span>{{ loading ? 'Running…' : 'Run Check' }}</span>
Expand All @@ -30,13 +39,21 @@
{{ error }}
</CindorAlert>

<CindorAlert v-if="seriesError" tone="neutral" class="page-alert">
{{ seriesError }}
</CindorAlert>

<CindorAlert v-if="result?.summary?.deepScan" tone="neutral" class="page-alert">
Deep scan ran a full `ffmpeg` decode pass for each episode in this run.
<span v-if="result.summary.deepScanScanned || result.summary.deepScanMetadataLookups || result.summary.deepScanFallbacks">
{{ result.summary.deepScanScanned || 0 }} decode(s), {{ result.summary.deepScanMetadataLookups || 0 }} metadata lookup(s), {{ result.summary.deepScanFallbacks || 0 }} fallback(s).
</span>
</CindorAlert>

<CindorAlert v-if="result?.summary?.selectedSeries" tone="neutral" class="page-alert">
Scan scope: {{ seriesLabel(result.summary.selectedSeries) }}
</CindorAlert>

<div v-if="result" class="summary-row">
<CindorStatCard label="Managed Episodes" :value="String(result.summary.total)" tone="neutral" />
<CindorStatCard label="Flagged" :value="String(result.summary.flagged)" tone="negative" />
Expand Down Expand Up @@ -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'
Expand All @@ -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 },
Expand Down Expand Up @@ -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()
Expand All @@ -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'
}
Expand All @@ -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}`
Expand Down Expand Up @@ -312,4 +357,8 @@ function plexLink(ratingKey) {
color: var(--fg-muted);
font-size: var(--text-sm);
}

.show-select {
min-width: 15rem;
}
</style>
Loading