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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user