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:
2026-05-28 07:52:20 -04:00
parent bf8eb4468f
commit 56970fb66d
8 changed files with 312 additions and 6 deletions
+53
View File
@@ -124,3 +124,56 @@ async def delete_credential(platform: str):
except LookupError: except LookupError:
return _bad("not_found", status=404) return _bad("not_found", status=404)
return "", 204 return "", 204
@credentials_bp.route("/<platform>/verify", methods=["POST"])
async def verify_credential(platform: str):
"""Test the stored credential by running gallery-dl --simulate
against one of the platform's enabled sources. On success stamps
last_verified. Returns {valid: bool|null, reason, last_verified?}.
valid=null means "couldn't test" (no credential, or no enabled
source to point at)."""
from ..models import Artist, Source
from ..services.gallery_dl import GalleryDLService, SourceConfig
async with get_session() as session:
if not await _ext_key_ok(session):
return _bad("unauthorized", status=401)
svc = CredentialService(session, _get_crypto())
record = await svc.get(platform)
if record is None:
return jsonify({"valid": None, "reason": "No credential stored for this platform."})
# Pick an enabled source for this platform to point the probe at.
row = (await session.execute(
select(Source, Artist)
.join(Artist, Artist.id == Source.artist_id)
.where(Source.platform == platform, Source.enabled.is_(True))
.order_by(Source.id.asc())
)).first()
if row is None:
return jsonify({
"valid": None,
"reason": "No enabled source for this platform to verify against — add a subscription first.",
})
source, artist = row
cookies_path = await svc.get_cookies_path(platform)
auth_token = await svc.get_token(platform)
gdl = GalleryDLService(images_root=Path("/images"))
ok, message = await gdl.verify(
url=source.url,
artist_slug=artist.slug,
platform=platform,
source_config=SourceConfig.from_dict(source.config_overrides or {}),
cookies_path=str(cookies_path) if cookies_path else None,
auth_token=auth_token,
)
last_verified = None
if ok:
async with get_session() as session:
ts = await CredentialService(session, _get_crypto()).mark_verified(platform)
last_verified = ts.isoformat() if ts else None
return jsonify({"valid": ok, "reason": message, "last_verified": last_verified})
+14 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import json import json
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
@@ -163,6 +163,19 @@ class CredentialService:
return None return None
return self.crypto.decrypt(row.encrypted_blob) return self.crypto.decrypt(row.encrypted_blob)
async def mark_verified(self, platform: str) -> datetime | None:
"""Stamp last_verified=now after a successful verify. Returns the
timestamp, or None if the credential is gone."""
row = (await self.session.execute(
select(Credential).where(Credential.platform == platform)
)).scalar_one_or_none()
if row is None:
return None
ts = datetime.now(UTC)
row.last_verified = ts
await self.session.commit()
return ts
def _augment_cookies(platform: str, netscape: str) -> str: def _augment_cookies(platform: str, netscape: str) -> str:
"""Delegate to the platform's `augment_cookies` hook if one is """Delegate to the platform's `augment_cookies` hook if one is
+61
View File
@@ -658,3 +658,64 @@ class GalleryDLService:
Path(temp_config_path).unlink() # noqa: ASYNC240 Path(temp_config_path).unlink() # noqa: ASYNC240
except Exception: except Exception:
pass pass
async def verify(
self,
url: str,
artist_slug: str,
platform: str,
source_config: SourceConfig | None = None,
cookies_path: str | None = None,
auth_token: str | None = None,
timeout: float = 45.0,
) -> tuple[bool, str]:
"""Test that credentials authenticate against `url` WITHOUT
downloading anything. Runs gallery-dl in --simulate mode limited
to the first item; if auth is bad the extractor errors before it
can list, which _categorize_error flags as AUTH_ERROR. Returns
(ok, message). Used by the credential Verify button."""
if source_config is None:
source_config = SourceConfig()
config = self._build_config_for_source(platform, source_config, artist_slug)
if cookies_path:
config["extractor"]["cookies"] = cookies_path
if auth_token and platform == "discord":
config["extractor"].setdefault("discord", {})["token"] = auth_token
if auth_token and platform == "pixiv":
config["extractor"].setdefault("pixiv", {})["refresh-token"] = auth_token
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, dir=str(self._config_dir),
) as fh:
json.dump(config, fh, indent=2)
temp_config_path = fh.name
try:
cmd = [
sys.executable, "-m", "gallery_dl",
"--config", temp_config_path,
"--simulate", "--range", "1-1", "--verbose", url,
]
loop = asyncio.get_running_loop()
proc = await loop.run_in_executor(
None,
lambda: subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout,
),
)
etype, msg = self._categorize_error(proc.returncode, proc.stdout, proc.stderr)
if proc.returncode == 0 or etype == ErrorType.NO_NEW_CONTENT:
return True, "Credentials valid — the feed authenticated."
if etype == ErrorType.AUTH_ERROR:
return False, msg
# Network / not-found / rate-limit / unknown: inconclusive,
# not a definitive credential failure. Surface the reason.
return False, f"Could not confirm ({etype.value}): {msg}"
except subprocess.TimeoutExpired:
return False, f"Verification timed out after {timeout:.0f}s"
except Exception as exc: # noqa: BLE001
return False, f"Verification error: {exc}"
finally:
try:
Path(temp_config_path).unlink() # noqa: ASYNC240
except Exception:
pass
@@ -55,7 +55,21 @@
</v-card-text> </v-card-text>
<v-card-actions class="px-3 pb-3 pt-0"> <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-btn
v-if="hasCredential" v-if="hasCredential"
size="small" variant="text" color="error" size="small" variant="text" color="error"
@@ -76,14 +90,43 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
import PlatformChip from './PlatformChip.vue' import PlatformChip from './PlatformChip.vue'
import { useCredentialsStore } from '../../stores/credentials.js'
const props = defineProps({ const props = defineProps({
platform: { type: Object, required: true }, platform: { type: Object, required: true },
credential: { type: Object, default: null }, 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) const hasCredential = computed(() => !!props.credential)
@@ -2,6 +2,10 @@
<div> <div>
<div class="fc-dl__top"> <div class="fc-dl__top">
<DownloadStatChips :stats="store.stats" /> <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-spacer />
<v-btn variant="text" icon @click="refresh"> <v-btn variant="text" icon @click="refresh">
<v-icon>mdi-refresh</v-icon> <v-icon>mdi-refresh</v-icon>
@@ -74,7 +78,7 @@
</template> </template>
<script setup> <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 { useRoute } from 'vue-router'
import { useDownloadsStore } from '../../stores/downloads.js' 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(() => { onMounted(() => {
if (route.query.source_id) { if (route.query.source_id) {
filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) } filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) }
} }
refresh() refresh()
startPolling()
}) })
onUnmounted(stopPolling)
// Client-side date filter on the loaded page (avoids a backend round-trip // Client-side date filter on the loaded page (avoids a backend round-trip
// for the date pickers; the existing /api/downloads endpoint can grow // for the date pickers; the existing /api/downloads endpoint can grow
@@ -194,6 +225,23 @@ async function openDetail(id) {
flex-wrap: wrap; flex-wrap: wrap;
} }
.fc-dl__filter { margin-bottom: 12px; } .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 { .fc-dl__loading, .fc-dl__empty {
display: flex; justify-content: center; padding: 3rem 0; display: flex; justify-content: center; padding: 3rem 0;
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
@@ -18,6 +18,7 @@
:credential="credentialsStore.byPlatform.get(p.key) || null" :credential="credentialsStore.byPlatform.get(p.key) || null"
@replace="openUpload" @replace="openUpload"
@remove="confirmRemove" @remove="confirmRemove"
@verified="onSaved"
/> />
</v-col> </v-col>
</v-row> </v-row>
+7 -1
View File
@@ -38,6 +38,12 @@ export const useCredentialsStore = defineStore('credentials', () => {
byPlatform.value.delete(platform) 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() { async function loadKey() {
const body = await api.get('/api/settings/extension_api_key') const body = await api.get('/api/settings/extension_api_key')
extensionKey.value = body.key extensionKey.value = body.key
@@ -50,6 +56,6 @@ export const useCredentialsStore = defineStore('credentials', () => {
return { return {
byPlatform, extensionKey, loading, error, byPlatform, extensionKey, loading, error,
loadAll, upload, remove, loadKey, rotateKey, loadAll, upload, remove, verify, loadKey, rotateKey,
} }
}) })
+81
View File
@@ -143,3 +143,84 @@ async def test_extension_key_wrong_rejects(client, ext_key):
headers={"X-Extension-Key": "WRONG"}, headers={"X-Extension-Key": "WRONG"},
) )
assert resp.status_code == 401 assert resp.status_code == 401
@pytest.mark.asyncio
async def test_verify_no_credential_is_untestable(client):
resp = await client.post("/api/credentials/patreon/verify")
assert resp.status_code == 200
body = await resp.get_json()
assert body["valid"] is None
assert "No credential" in body["reason"]
@pytest.mark.asyncio
async def test_verify_no_enabled_source_is_untestable(client):
await client.post("/api/credentials", json={
"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE,
})
resp = await client.post("/api/credentials/patreon/verify")
body = await resp.get_json()
assert body["valid"] is None
assert "no enabled source" in body["reason"].lower()
@pytest.mark.asyncio
async def test_verify_runs_gallery_dl_and_stamps_on_success(client, db, monkeypatch):
from backend.app.models import Artist, Source
from backend.app.services import gallery_dl as gdl_mod
# Stub the gallery-dl probe so the test doesn't shell out / hit network.
async def _fake_verify(self, *args, **kwargs):
return (True, "Credentials valid — the feed authenticated.")
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _fake_verify)
await client.post("/api/credentials", json={
"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE,
})
artist = Artist(name="Maewix", slug="maewix")
db.add(artist)
await db.flush()
db.add(Source(
artist_id=artist.id, platform="patreon",
url="https://www.patreon.com/maewix", enabled=True, config_overrides={},
))
await db.commit()
resp = await client.post("/api/credentials/patreon/verify")
body = await resp.get_json()
assert body["valid"] is True
assert body["last_verified"] is not None
# The stamp is persisted on the credential record.
rec = await (await client.get("/api/credentials/patreon")).get_json()
assert rec["last_verified"] is not None
@pytest.mark.asyncio
async def test_verify_reports_auth_failure(client, db, monkeypatch):
from backend.app.models import Artist, Source
from backend.app.services import gallery_dl as gdl_mod
async def _fake_verify(self, *args, **kwargs):
return (False, "Authentication failed — cookies may be expired or invalid")
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _fake_verify)
await client.post("/api/credentials", json={
"platform": "hentaifoundry", "credential_type": "cookies", "data": _NETSCAPE,
})
artist = Artist(name="HolyMeh", slug="holymeh")
db.add(artist)
await db.flush()
db.add(Source(
artist_id=artist.id, platform="hentaifoundry",
url="https://www.hentai-foundry.com/user/HolyMeh", enabled=True,
config_overrides={},
))
await db.commit()
resp = await client.post("/api/credentials/hentaifoundry/verify")
body = await resp.get_json()
assert body["valid"] is False
assert "auth" in body["reason"].lower()
assert body["last_verified"] is None