feat(settings): library-validation UI and validate_files toggle

Adds a 'Library Validation' card to the Settings page that wraps the
backend sweep API: trigger button, last-run timestamp, scanned/suspect
counts, by-format breakdown, and an expandable suspect-paths table.
Polls every 10s for up to 5 minutes after a scan is queued so the
report shows up without manual refreshing.

Also adds a download.validate_files switch to the Download Settings
section so the live per-file validation can be toggled from the UI
instead of via direct DB edits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 23:56:18 -04:00
parent 6d875662fa
commit b43c35483f
2 changed files with 251 additions and 2 deletions
+2
View File
@@ -62,6 +62,8 @@ export const settingsApi = {
regenerateApiKey: () => api.post('/settings/api-key/regenerate'),
getLogs: (params = {}) => api.get('/settings/logs', { params }),
refreshStorageStats: () => api.post('/settings/storage-stats/refresh'),
getLibraryValidation: () => api.get('/settings/library-validation'),
runLibraryValidation: () => api.post('/settings/library-validation/run'),
}
// Platforms API
+249 -2
View File
@@ -58,6 +58,16 @@
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-switch
v-model="settings['download.validate_files']"
label="Validate downloaded files"
color="primary"
hint="Detect truncated/incomplete files at write time and quarantine them. Adds a tiny per-file cost (16-byte head/tail check)."
persistent-hint
/>
</v-col>
</v-row>
<v-divider class="my-6" />
@@ -168,6 +178,152 @@
</v-card-actions>
</v-card>
<!-- Library Validation -->
<v-card class="mb-6">
<v-card-title>
<v-icon class="mr-2">mdi-shield-search</v-icon>
Library Validation
</v-card-title>
<v-card-text>
<p class="text-body-2 mb-4">
Walks the downloads tree and reports files that fail magic-byte
validation same checks the live download path uses, but applied to
your existing library to surface silently-truncated files. Reports
only; pre-existing files aren't moved.
</p>
<v-alert
v-if="!libraryReport && !libraryLoading"
type="info"
variant="tonal"
density="compact"
class="mb-4"
>
No scan has been run yet.
</v-alert>
<v-alert
v-if="libraryRunning"
type="info"
variant="tonal"
density="compact"
class="mb-4"
>
<v-icon start>mdi-progress-clock</v-icon>
Scan queued — refreshing report automatically.
</v-alert>
<template v-if="libraryReport">
<v-row dense class="mb-2">
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">Last completed</div>
<div>{{ formatTimestamp(libraryReport.completed_at) }}</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">Files scanned</div>
<div>{{ libraryReport.scanned.toLocaleString() }}</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">Suspect files</div>
<v-chip
:color="libraryReport.suspect_count > 0 ? 'warning' : 'success'"
size="small"
variant="tonal"
>
{{ libraryReport.suspect_count.toLocaleString() }}
</v-chip>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">Root</div>
<code class="text-caption">{{ libraryReport.root }}</code>
</v-col>
</v-row>
<div
v-if="libraryReport.suspect_count > 0"
class="mt-4"
>
<h4 class="text-subtitle-2 mb-2">By format</h4>
<v-chip
v-for="(count, fmt) in libraryReport.by_format"
:key="fmt"
size="small"
class="mr-2 mb-2"
variant="outlined"
>
{{ fmt }}: {{ count }}
</v-chip>
<v-expansion-panels class="mt-3" v-if="libraryReport.suspect_paths.length">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon class="mr-2">mdi-format-list-bulleted</v-icon>
Suspect paths
<v-chip class="ml-2" size="x-small">
{{ libraryReport.suspect_paths.length }}
<span v-if="libraryReport.truncated"> of {{ libraryReport.suspect_count }}</span>
</v-chip>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-alert
v-if="libraryReport.truncated"
type="warning"
density="compact"
variant="tonal"
class="mb-3"
>
Showing the first {{ libraryReport.suspect_paths.length }} suspect files.
The full list is capped to keep the persisted report small.
</v-alert>
<v-table density="compact">
<thead>
<tr>
<th>Path</th>
<th width="100">Format</th>
<th>Reason</th>
<th width="100">Size</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, i) in libraryReport.suspect_paths" :key="i">
<td><code class="text-caption">{{ item.path }}</code></td>
<td>{{ item.format || '' }}</td>
<td class="text-caption">{{ item.reason }}</td>
<td class="text-caption">{{ formatFileSize(item.size) }}</td>
</tr>
</tbody>
</v-table>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
variant="text"
@click="loadLibraryReport"
:disabled="libraryLoading"
:loading="libraryLoading"
>
<v-icon start>mdi-refresh</v-icon>
Refresh
</v-btn>
<v-btn
color="primary"
@click="runLibraryScan"
:disabled="libraryRunning"
:loading="libraryRunning"
>
<v-icon start>mdi-play</v-icon>
Run scan
</v-btn>
</v-card-actions>
</v-card>
<!-- Gallery-dl Configuration -->
<v-card class="mb-6">
<v-card-title>
@@ -438,10 +594,10 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useSettingsStore } from '../stores/settings'
import { useNotificationStore } from '../stores/notifications'
import { platformsApi } from '../services/api'
import { platformsApi, settingsApi } from '../services/api'
import api from '../services/api'
const settingsStore = useSettingsStore()
@@ -470,6 +626,15 @@ const systemInfo = ref({
})
const showAllPackages = ref(false)
// Library validation state. libraryRunning is local-only — there's no
// backend "is scan in progress" flag yet, so we use a 5-minute auto-poll
// window after the user clicks Run, after which they refresh manually.
const libraryReport = ref(null)
const libraryLoading = ref(false)
const libraryRunning = ref(false)
const libraryRunStartedAt = ref(null)
let libraryPollHandle = null
const sortedAllPackages = computed(() => {
const packages = systemInfo.value.all_packages || {}
const sorted = {}
@@ -505,6 +670,7 @@ async function loadAll() {
loadPlatforms(),
checkApiHealth(),
loadSystemInfo(),
loadLibraryReport(),
])
} finally {
loading.value = false
@@ -659,6 +825,87 @@ async function regenerateApiKey() {
notifications.error(`Failed to regenerate: ${error.message}`)
}
}
async function loadLibraryReport() {
libraryLoading.value = true
try {
const response = await settingsApi.getLibraryValidation()
const next = response.data
// Detect "scan finished since we kicked it off" by completed_at moving
// forward past when the user clicked Run. That's our cue to stop polling.
if (
libraryRunning.value &&
next &&
libraryRunStartedAt.value &&
new Date(next.completed_at) >= libraryRunStartedAt.value
) {
stopLibraryPoll()
notifications.success(
`Scan complete — ${next.suspect_count} suspect file${next.suspect_count === 1 ? '' : 's'} of ${next.scanned} scanned`
)
}
libraryReport.value = next
} catch (error) {
console.error('Failed to load library validation report:', error)
} finally {
libraryLoading.value = false
}
}
async function runLibraryScan() {
libraryRunning.value = true
libraryRunStartedAt.value = new Date()
try {
await settingsApi.runLibraryValidation()
notifications.success('Library scan queued')
// Poll every 10s for up to 5 min so the user doesn't manually refresh.
// Big libraries can take a while; if it hasn't completed, the user can
// still click Refresh later.
let polls = 0
const MAX_POLLS = 30
libraryPollHandle = setInterval(async () => {
polls += 1
await loadLibraryReport()
if (polls >= MAX_POLLS) stopLibraryPoll()
}, 10000)
} catch (error) {
libraryRunning.value = false
libraryRunStartedAt.value = null
notifications.error(`Failed to queue scan: ${error.message}`)
}
}
function stopLibraryPoll() {
if (libraryPollHandle) {
clearInterval(libraryPollHandle)
libraryPollHandle = null
}
libraryRunning.value = false
libraryRunStartedAt.value = null
}
function formatTimestamp(iso) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString()
} catch {
return iso
}
}
function formatFileSize(bytes) {
if (!bytes && bytes !== 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let n = bytes
let i = 0
while (n >= 1024 && i < units.length - 1) {
n /= 1024
i += 1
}
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
onUnmounted(() => stopLibraryPoll())
</script>
<style scoped>