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:
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -163,6 +163,19 @@ class CredentialService:
|
||||
return None
|
||||
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:
|
||||
"""Delegate to the platform's `augment_cookies` hook if one is
|
||||
|
||||
@@ -658,3 +658,64 @@ class GalleryDLService:
|
||||
Path(temp_config_path).unlink() # noqa: ASYNC240
|
||||
except Exception:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user