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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ The frontend also installs the shared Emberline design system directly from GitH
2. Optionally select which Plex libraries to scan under **Plex Libraries** in Settings.
3. Set your preferred leeway percentage (default: **5%**) and movie minimum difference (default: **5 min**).
4. Navigate to **Movie Duration Check** and click **Run Check**.
5. Movies 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. Radarr runtimes are cached in SQLite. Use **Force Refresh Cache** to re-fetch.
7. Select flagged movies and click **Redownload** to delete the existing file in Radarr and trigger a new automatic search.
5. Optional: enable **Deep scan** to run a full `ffmpeg` decode pass per movie before comparing durations. Plex metadata is used only as a fallback to locate a media part when needed.
6. Movies 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)`.
7. Radarr runtimes are cached in SQLite. Use **Force Refresh Cache** to re-fetch.
8. Select flagged movies and click **Redownload** to delete the existing file in Radarr and trigger a new automatic search.

## Video Quality Check

Expand Down
26 changes: 24 additions & 2 deletions backend/src/routes/movies.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const router = express.Router();
const { fetchPlexMovies } = require('../api/plex');
const { fetchRadarrMovies } = require('../api/radarr');
const {
DEEP_SCAN_SETTING_KEY,
getSetting,
getCachedRuntime,
upsertCachedRuntime,
Expand Down Expand Up @@ -32,6 +33,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;
}

// Normalize a title for fuzzy matching (lowercase, strip punctuation/articles)
function normalizeTitle(title) {
return (title || '')
Expand All @@ -51,11 +60,20 @@ router.get('/check', async (req, res) => {
}

const forceRefresh = req.query.force === 'true';
const deepScanSetting = parseBoolean(getSetting(DEEP_SCAN_SETTING_KEY)) || false;
const deepScan = parseBoolean(req.query.deep) ?? deepScanSetting;
console.log(`\n[Check] Starting duration check (forceRefresh=${forceRefresh})`);
console.log('[Check] Deep scan:', deepScan);

try {
// --- Step 1: Fetch Plex movies ---
const { movies: plexMovies, machineIdentifier } = await fetchPlexMovies(config.plexUrl, config.plexToken, config.selectedLibraryIds);
const {
movies: plexMovies,
machineIdentifier,
deepScanFallbacks,
deepScanMetadataLookups,
deepScanScanned,
} = await fetchPlexMovies(config.plexUrl, config.plexToken, config.selectedLibraryIds, { deepScan });
const plexWithTmdb = plexMovies.filter((m) => m.tmdbId).length;
console.log(`[Check] Plex: ${plexMovies.length} total, ${plexWithTmdb} have TMDB IDs, ${plexMovies.length - plexWithTmdb} missing TMDB IDs`);

Expand Down Expand Up @@ -204,6 +222,11 @@ router.get('/check', async (req, res) => {
plexUrl: config.plexUrl,
radarrUrl: config.radarrUrl,
plexMachineId: machineIdentifier,
deepScan,
deepScanFallbacks,
deepScanMetadataLookups,
deepScanScanned,
deepScanMode: deepScan ? 'ffmpeg_full_decode' : 'library_metadata',
},
flagged,
noMatch,
Expand Down Expand Up @@ -283,4 +306,3 @@ router.get('/cache-stats', (req, res) => {
});

module.exports = router;

15 changes: 13 additions & 2 deletions frontend/src/stores/movieDuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,22 @@ export const useMovieDurationStore = defineStore('movieDuration', () => {
const loading = ref(false)
const error = ref(null)

async function runCheck(force = false) {
async function runCheck(options = {}) {
loading.value = true
error.value = null
try {
const data = await apiGet(`/api/movies/check${force ? '?force=true' : ''}`)
const normalized = typeof options === 'boolean' ? { force: options } : options
const params = new URLSearchParams()

if (normalized.force) {
params.set('force', 'true')
}
if (normalized.deepScan !== undefined) {
params.set('deep', normalized.deepScan ? '1' : '0')
}

const path = params.size > 0 ? `/api/movies/check?${params.toString()}` : '/api/movies/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)
Expand Down
37 changes: 32 additions & 5 deletions frontend/src/views/MovieDurationView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,16 @@
</p>
</div>
<div class="header-actions">
<CindorButton type="button" :disabled="loading" @click="store.runCheck(false)">
<label class="inline-toggle" for="movie-duration-deep-scan">
<CindorCheckbox
id="movie-duration-deep-scan"
:model-value="deepScan"
:disabled="loading"
@update:model-value="deepScan = !!$event"
/>
<span>Deep scan</span>
</label>
<CindorButton type="button" :disabled="loading" @click="store.runCheck({ deepScan })">
<span class="button-content">
<AppIcon :name="loading ? 'loader-pinwheel' : 'play'" :size="16" />
<span>{{ loading ? 'Running…' : 'Run Check' }}</span>
Expand All @@ -19,7 +28,7 @@
variant="ghost"
:disabled="loading"
title="Clears the Radarr runtime cache and re-fetches all runtimes"
@click="store.runCheck(true)"
@click="store.runCheck({ force: true, deepScan })"
>
<span class="button-content">
<AppIcon name="refresh-cw" :size="16" />
Expand All @@ -33,6 +42,13 @@
{{ error }}
</CindorAlert>

<CindorAlert v-if="result?.summary?.deepScan" tone="neutral" class="page-alert">
Deep scan ran a full `ffmpeg` decode pass for each movie 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>

<div v-if="result" class="summary-row">
<CindorStatCard label="Total Movies" :value="String(result.summary.total)" tone="neutral" />
<CindorStatCard label="Flagged" :value="String(result.summary.flagged)" tone="negative" />
Expand Down Expand Up @@ -158,22 +174,24 @@

<div v-if="loading" class="loading-state">
<CindorSpinner />
<p>Fetching movie data from Plex and Radarr...</p>
<p>{{ deepScan ? 'Running ffmpeg deep scan on Plex movies...' : 'Fetching movie data from Plex and Radarr...' }}</p>
</div>
</div>
</template>

<script setup>
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useMovieDurationStore } from '../stores/movieDuration'
import { useSettingsStore } from '../stores/settings'
import { useAppToast } from '../composables/useAppToast'
import { CindorAlert, CindorButton, CindorSpinner, CindorStatCard } from 'cindor-ui-vue'
import { CindorAlert, CindorButton, CindorCheckbox, CindorSpinner, CindorStatCard } from 'cindor-ui-vue'
import AppDataTable from '../components/AppDataTable.vue'
import AppExternalLink from '../components/AppExternalLink.vue'
import AppIcon from '../components/AppIcon.vue'
import AppTag from '../components/AppTag.vue'

const store = useMovieDurationStore()
const settingsStore = useSettingsStore()
const toast = useAppToast()

const result = computed(() => store.result)
Expand All @@ -187,6 +205,7 @@ const radarrBaseUrl = computed(() => store.result?.summary?.radarrUrl || '')

const selectedMovies = ref([])
const redownloading = ref(false)
const deepScan = ref(false)

const flaggedColumns = [
{ key: 'title', label: 'Title', sortable: true },
Expand Down Expand Up @@ -232,6 +251,14 @@ async function redownload() {
}
}

onMounted(async () => {
const initialDeepScan = deepScan.value
await settingsStore.fetchSettings()
if (deepScan.value === initialDeepScan) {
deepScan.value = settingsStore.settings.plex_deep_scan === '1'
}
})

const lastRunLabel = computed(() => {
if (!store.lastRun) return null
const now = new Date()
Expand Down
Loading