feat(credentials+downloads): real credential Verify button + live download-activity polling
Operator-flagged 2026-05-28, two asks.
**1. Credential Verify (was missing vs GS — and now actually verifies).**
GS's Verify was a stub (`TODO: implement actual verification` — just
stamped last_verified). FC does a real check, which matters given the
recent auth pain (subscribestar age cookie, HF host-only PHPSESSID):
- GalleryDLService.verify(url, platform, cookies_path, auth_token) runs
gallery-dl in `--simulate --range 1-1` mode (no download) against the
URL with the materialized credentials, then reuses _categorize_error:
returncode 0 / NO_NEW_CONTENT → valid; AUTH_ERROR → invalid; other →
inconclusive (reason surfaced). 45s timeout.
- POST /api/credentials/<platform>/verify picks an enabled Source for
the platform to probe, runs verify, and on success stamps
credential.last_verified (new CredentialService.mark_verified).
Returns {valid: bool|null, reason, last_verified?}. valid=null means
untestable (no credential, or no enabled source to point at).
- CredentialCard gains a Verify button (on credentialed cards) + a
result chip (Verified ✓ / Failed / Untestable) and a toast with the
reason. SettingsTab reloads on @verified so last_verified refreshes.
**2. Live download-activity feedback.** The Downloads tab was static —
no way to tell if downloads were succeeding without manually hitting
Refresh. It now auto-polls: stats every 4s, and the event list too
while anything is queued/running. Polling pauses when the tab is
backgrounded (document.hidden) and the list reload is skipped on idle
ticks to stay light. A pulsing "● live" indicator next to the stat
chips shows when auto-refresh is active (queued+running > 0); honors
prefers-reduced-motion.
Tests: verify endpoint — untestable with no credential, untestable with
no enabled source, valid+stamped on success (gallery-dl mocked), and
auth-failure reported without stamping.
This commit is contained in:
@@ -55,7 +55,21 @@
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="px-3 pb-3 pt-0">
|
||||
<v-spacer />
|
||||
<v-chip
|
||||
v-if="verifyResult"
|
||||
:color="verifyResult.valid === true ? 'success' : (verifyResult.valid === false ? 'error' : 'grey')"
|
||||
size="x-small" variant="tonal" class="me-auto"
|
||||
:title="verifyResult.reason"
|
||||
>{{ verifyChipLabel }}</v-chip>
|
||||
<v-spacer v-else />
|
||||
<v-btn
|
||||
v-if="hasCredential"
|
||||
size="small" variant="text"
|
||||
:loading="verifying"
|
||||
@click="onVerify"
|
||||
>
|
||||
Verify
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="hasCredential"
|
||||
size="small" variant="text" color="error"
|
||||
@@ -76,14 +90,43 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import PlatformChip from './PlatformChip.vue'
|
||||
import { useCredentialsStore } from '../../stores/credentials.js'
|
||||
|
||||
const props = defineProps({
|
||||
platform: { type: Object, required: true },
|
||||
credential: { type: Object, default: null },
|
||||
})
|
||||
defineEmits(['replace', 'remove'])
|
||||
const emit = defineEmits(['replace', 'remove', 'verified'])
|
||||
|
||||
const credsStore = useCredentialsStore()
|
||||
const verifying = ref(false)
|
||||
const verifyResult = ref(null)
|
||||
|
||||
const verifyChipLabel = computed(() => {
|
||||
if (!verifyResult.value) return ''
|
||||
if (verifyResult.value.valid === true) return 'Verified ✓'
|
||||
if (verifyResult.value.valid === false) return 'Failed'
|
||||
return 'Untestable'
|
||||
})
|
||||
|
||||
async function onVerify() {
|
||||
verifying.value = true
|
||||
verifyResult.value = null
|
||||
try {
|
||||
const res = await credsStore.verify(props.platform.key)
|
||||
verifyResult.value = res
|
||||
const type = res.valid === true ? 'success' : (res.valid === false ? 'error' : 'info')
|
||||
window.__fcToast?.({ text: `${props.platform.name}: ${res.reason}`, type })
|
||||
if (res.valid === true) emit('verified')
|
||||
} catch (e) {
|
||||
verifyResult.value = { valid: false, reason: e.message }
|
||||
window.__fcToast?.({ text: `Verify failed: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hasCredential = computed(() => !!props.credential)
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<div>
|
||||
<div class="fc-dl__top">
|
||||
<DownloadStatChips :stats="store.stats" />
|
||||
<span v-if="liveActive" class="fc-dl__live" title="Auto-refreshing while downloads are active">
|
||||
<span class="fc-dl__live-dot" />
|
||||
live
|
||||
</span>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" icon @click="refresh">
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
@@ -74,7 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||
@@ -105,12 +109,39 @@ async function refresh() {
|
||||
])
|
||||
}
|
||||
|
||||
// Live auto-refresh: while any download is queued or running, poll the
|
||||
// stats + first page every 4s so the operator can watch events succeed/
|
||||
// fail in real time without hitting Refresh. Polling stops automatically
|
||||
// when the queue drains (nothing pending/running), and is paused while
|
||||
// the tab is backgrounded. Operator-flagged 2026-05-28: couldn't tell if
|
||||
// downloads were succeeding because the view was static.
|
||||
const POLL_MS = 4000
|
||||
const liveActive = computed(
|
||||
() => (store.stats.pending || 0) + (store.stats.running || 0) > 0,
|
||||
)
|
||||
let pollId = null
|
||||
function startPolling() {
|
||||
if (pollId) return
|
||||
pollId = setInterval(async () => {
|
||||
if (document.hidden) return
|
||||
// Refresh stats cheaply every tick; only reload the event list when
|
||||
// there's active work to reflect (keeps idle ticks light).
|
||||
await store.loadStats(24)
|
||||
if (liveActive.value) await store.loadFirst()
|
||||
}, POLL_MS)
|
||||
}
|
||||
function stopPolling() {
|
||||
if (pollId) { clearInterval(pollId); pollId = null }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.source_id) {
|
||||
filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) }
|
||||
}
|
||||
refresh()
|
||||
startPolling()
|
||||
})
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
// Client-side date filter on the loaded page (avoids a backend round-trip
|
||||
// for the date pickers; the existing /api/downloads endpoint can grow
|
||||
@@ -194,6 +225,23 @@ async function openDetail(id) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fc-dl__filter { margin-bottom: 12px; }
|
||||
.fc-dl__live {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: rgb(var(--v-theme-info));
|
||||
}
|
||||
.fc-dl__live-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: rgb(var(--v-theme-info));
|
||||
animation: fc-dl-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes fc-dl-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.7); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fc-dl__live-dot { animation: none; }
|
||||
}
|
||||
.fc-dl__loading, .fc-dl__empty {
|
||||
display: flex; justify-content: center; padding: 3rem 0;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
:credential="credentialsStore.byPlatform.get(p.key) || null"
|
||||
@replace="openUpload"
|
||||
@remove="confirmRemove"
|
||||
@verified="onSaved"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -38,6 +38,12 @@ export const useCredentialsStore = defineStore('credentials', () => {
|
||||
byPlatform.value.delete(platform)
|
||||
}
|
||||
|
||||
// Runs gallery-dl --simulate against an enabled source for the
|
||||
// platform. Returns {valid: bool|null, reason, last_verified?}.
|
||||
async function verify(platform) {
|
||||
return await api.post(`/api/credentials/${platform}/verify`)
|
||||
}
|
||||
|
||||
async function loadKey() {
|
||||
const body = await api.get('/api/settings/extension_api_key')
|
||||
extensionKey.value = body.key
|
||||
@@ -50,6 +56,6 @@ export const useCredentialsStore = defineStore('credentials', () => {
|
||||
|
||||
return {
|
||||
byPlatform, extensionKey, loading, error,
|
||||
loadAll, upload, remove, loadKey, rotateKey,
|
||||
loadAll, upload, remove, verify, loadKey, rotateKey,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user