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'),
|
regenerateApiKey: () => api.post('/settings/api-key/regenerate'),
|
||||||
getLogs: (params = {}) => api.get('/settings/logs', { params }),
|
getLogs: (params = {}) => api.get('/settings/logs', { params }),
|
||||||
refreshStorageStats: () => api.post('/settings/storage-stats/refresh'),
|
refreshStorageStats: () => api.post('/settings/storage-stats/refresh'),
|
||||||
|
getLibraryValidation: () => api.get('/settings/library-validation'),
|
||||||
|
runLibraryValidation: () => api.post('/settings/library-validation/run'),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platforms API
|
// Platforms API
|
||||||
|
|||||||
@@ -58,6 +58,16 @@
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</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-row>
|
||||||
|
|
||||||
<v-divider class="my-6" />
|
<v-divider class="my-6" />
|
||||||
@@ -168,6 +178,152 @@
|
|||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</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 -->
|
<!-- Gallery-dl Configuration -->
|
||||||
<v-card class="mb-6">
|
<v-card class="mb-6">
|
||||||
<v-card-title>
|
<v-card-title>
|
||||||
@@ -438,10 +594,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { useNotificationStore } from '../stores/notifications'
|
import { useNotificationStore } from '../stores/notifications'
|
||||||
import { platformsApi } from '../services/api'
|
import { platformsApi, settingsApi } from '../services/api'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
@@ -470,6 +626,15 @@ const systemInfo = ref({
|
|||||||
})
|
})
|
||||||
const showAllPackages = ref(false)
|
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 sortedAllPackages = computed(() => {
|
||||||
const packages = systemInfo.value.all_packages || {}
|
const packages = systemInfo.value.all_packages || {}
|
||||||
const sorted = {}
|
const sorted = {}
|
||||||
@@ -505,6 +670,7 @@ async function loadAll() {
|
|||||||
loadPlatforms(),
|
loadPlatforms(),
|
||||||
checkApiHealth(),
|
checkApiHealth(),
|
||||||
loadSystemInfo(),
|
loadSystemInfo(),
|
||||||
|
loadLibraryReport(),
|
||||||
])
|
])
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -659,6 +825,87 @@ async function regenerateApiKey() {
|
|||||||
notifications.error(`Failed to regenerate: ${error.message}`)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user