From 9322c984fdcc4656cc520b1b78a8bf2700ff02a2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 13:02:24 -0400 Subject: [PATCH] feat(subs-hub): collapse /credentials + /downloads into /subscriptions hub with three GS-style subtabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the three top-level routes with a single `/subscriptions` parent owning the whole download-pipeline domain. Internal tab state via `?tab=` query param, mirroring ArtistView's pattern. TopNav auto-drops the two removed entries (route-driven via meta.title). Bookmark-safe redirects from `/credentials` and `/downloads` route into the appropriate subtab. **Subtab 1 — Subscriptions (default).** Carries over the existing artist-grouped expandable table; adds (a) status filter dropdown, (b) bulk-select column with Enable/Disable/Delete-all actions, (c) GS-style color-coded `PlatformChip` per distinct platform in the collapsed row. Reuses SourceRow, SourceHealthDot, SourceFormDialog, ArtistCreateDialog. **Subtab 2 — Downloads.** Full GS dashboard. Five colored stat chips up top (Queued/Running/Completed/Failed/Skipped, sourced from new `GET /api/downloads/stats?window_hours=`). Popover-style filter UI (Status/Source/FromDate/ToDate) with active-filter pills below. Maintenance menu wraps existing /api/import/retry-failed and /api/import/clear-stuck endpoints; Export-failed-logs item disabled with a "v2" tooltip. Per-row Retry preserved via existing DownloadEventRow. **Subtab 3 — Settings.** Four sections: ExtensionKeyBar (top), GS-style per-platform CredentialCard grid (md=6 v-row/v-col, dashed border if unset / accent border if set, expandable how-to panel), Downloader card (rate limit, validate_files), Schedule defaults card (default interval, event retention, failure warning threshold). The Downloader and Schedule sections were extracted out of components/settings/ImportFiltersForm.vue — SettingsView's Import tab now owns only image-import filters. **Backend:** new `GET /api/downloads/stats` returns {pending, running, ok, error, skipped} count grouped by status over the configurable window. Status keys stay raw from the ENUM; UI does the display-label mapping. Two integration tests pin the response shape + window_hours validation. **Util:** `frontend/src/utils/platformColor.js` — single source of truth for the six platforms' color + icon + label, mirroring GS's palette (patreon=red mdi-patreon, subscribestar=amber mdi-star, hentaifoundry=purple mdi-palette, discord=indigo mdi-discord, pixiv=blue mdi-alpha-p-box, deviantart=green mdi-deviantart). Unknown platform falls back to grey + mdi-web. Deferred (explicit non-goals): subscription import/export, "Trigger Due Now" scheduler-tick button (needs new backend endpoint), Export Failed Logs CSV dump. --- backend/app/api/downloads.py | 33 +- .../components/settings/ImportFiltersForm.vue | 70 +-- .../subscriptions/CredentialCard.vue | 148 +++++ .../subscriptions/DownloadStatChips.vue | 36 ++ .../subscriptions/DownloadsFilterPopover.vue | 120 ++++ .../components/subscriptions/DownloadsTab.vue | 123 +++++ .../subscriptions/MaintenanceMenu.vue | 62 +++ .../components/subscriptions/PlatformChip.vue | 25 + .../components/subscriptions/SettingsTab.vue | 196 +++++++ .../subscriptions/SubscriptionsTab.vue | 521 ++++++++++++++++++ frontend/src/router.js | 12 +- frontend/src/stores/downloads.js | 15 +- frontend/src/utils/platformColor.js | 43 ++ frontend/src/views/CredentialsView.vue | 79 --- frontend/src/views/DownloadsView.vue | 70 --- frontend/src/views/SubscriptionsView.vue | 439 ++------------- tests/test_api_downloads.py | 19 + 17 files changed, 1395 insertions(+), 616 deletions(-) create mode 100644 frontend/src/components/subscriptions/CredentialCard.vue create mode 100644 frontend/src/components/subscriptions/DownloadStatChips.vue create mode 100644 frontend/src/components/subscriptions/DownloadsFilterPopover.vue create mode 100644 frontend/src/components/subscriptions/DownloadsTab.vue create mode 100644 frontend/src/components/subscriptions/MaintenanceMenu.vue create mode 100644 frontend/src/components/subscriptions/PlatformChip.vue create mode 100644 frontend/src/components/subscriptions/SettingsTab.vue create mode 100644 frontend/src/components/subscriptions/SubscriptionsTab.vue create mode 100644 frontend/src/utils/platformColor.js delete mode 100644 frontend/src/views/CredentialsView.vue delete mode 100644 frontend/src/views/DownloadsView.vue diff --git a/backend/app/api/downloads.py b/backend/app/api/downloads.py index cac9015..ee28ff1 100644 --- a/backend/app/api/downloads.py +++ b/backend/app/api/downloads.py @@ -5,8 +5,10 @@ status/source/artist. Returns slim records. Detail view: full DownloadEvent including the metadata JSONB. """ +from datetime import datetime, timedelta, timezone + from quart import Blueprint, jsonify, request -from sqlalchemy import select +from sqlalchemy import func, select from ..extensions import get_session from ..models import Artist, DownloadEvent, Source @@ -95,6 +97,35 @@ async def list_downloads(): return jsonify([_list_record(e, s, a) for e, s, a in rows]) +@downloads_bp.route("/stats", methods=["GET"]) +async def downloads_stats(): + """Status-grouped count over download_event for the dashboard stat chips. + + `?window_hours=` (default 24) bounds by `started_at`. The full set of + statuses is always present in the response (zero for missing) so the + UI doesn't have to fill in defaults. + """ + try: + window_hours = int(request.args.get("window_hours", "24")) + except ValueError: + return jsonify({"error": "invalid_window_hours"}), 400 + if window_hours < 1 or window_hours > 24 * 365: + return jsonify({"error": "invalid_window_hours"}), 400 + + since = datetime.now(timezone.utc) - timedelta(hours=window_hours) + out = {"pending": 0, "running": 0, "ok": 0, "error": 0, "skipped": 0} + async with get_session() as session: + stmt = ( + select(DownloadEvent.status, func.count()) + .where(DownloadEvent.started_at >= since) + .group_by(DownloadEvent.status) + ) + for status, n in (await session.execute(stmt)).all(): + if status in out: + out[status] = int(n) + return jsonify(out) + + @downloads_bp.route("/", methods=["GET"]) async def get_download(event_id: int): async with get_session() as session: diff --git a/frontend/src/components/settings/ImportFiltersForm.vue b/frontend/src/components/settings/ImportFiltersForm.vue index 2628309..af06321 100644 --- a/frontend/src/components/settings/ImportFiltersForm.vue +++ b/frontend/src/components/settings/ImportFiltersForm.vue @@ -51,68 +51,6 @@ - -
Downloader (FC-3c)
- - - -
gallery-dl extractor.sleep. Higher = slower but safer.
-
- - - -
- - -
Download scheduling (FC-3d)
- - - -
- Used when a source has no per-source or per-artist override. - Default 28800 (8 hours). -
-
- - -
- Completed download events older than this are deleted nightly. - Default 90. -
-
- - -
- Source row badge turns red after this many consecutive - failures. Sources are never auto-disabled. Default 5. -
-
-
- {{ store.settingsError }} @@ -128,16 +66,14 @@ import { reactive, watch } from 'vue' import { useImportStore } from '../../stores/import.js' const store = useImportStore() +// Downloader + schedule-defaults fields moved to +// /subscriptions?tab=settings (operator decision 2026-05-27). This form +// now only owns image-import filters. const local = reactive({ min_width: 0, min_height: 0, skip_transparent: false, transparency_threshold: 0.9, skip_single_color: false, single_color_threshold: 0.95, phash_threshold: 10, - download_rate_limit_seconds: 3.0, - download_validate_files: true, - download_schedule_default_seconds: 28800, - download_event_retention_days: 90, - download_failure_warning_threshold: 5, }) watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true }) diff --git a/frontend/src/components/subscriptions/CredentialCard.vue b/frontend/src/components/subscriptions/CredentialCard.vue new file mode 100644 index 0000000..300b95e --- /dev/null +++ b/frontend/src/components/subscriptions/CredentialCard.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/frontend/src/components/subscriptions/DownloadStatChips.vue b/frontend/src/components/subscriptions/DownloadStatChips.vue new file mode 100644 index 0000000..ce5c975 --- /dev/null +++ b/frontend/src/components/subscriptions/DownloadStatChips.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/frontend/src/components/subscriptions/DownloadsFilterPopover.vue b/frontend/src/components/subscriptions/DownloadsFilterPopover.vue new file mode 100644 index 0000000..57b8253 --- /dev/null +++ b/frontend/src/components/subscriptions/DownloadsFilterPopover.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/components/subscriptions/DownloadsTab.vue b/frontend/src/components/subscriptions/DownloadsTab.vue new file mode 100644 index 0000000..7ced798 --- /dev/null +++ b/frontend/src/components/subscriptions/DownloadsTab.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/frontend/src/components/subscriptions/MaintenanceMenu.vue b/frontend/src/components/subscriptions/MaintenanceMenu.vue new file mode 100644 index 0000000..dc2d57e --- /dev/null +++ b/frontend/src/components/subscriptions/MaintenanceMenu.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend/src/components/subscriptions/PlatformChip.vue b/frontend/src/components/subscriptions/PlatformChip.vue new file mode 100644 index 0000000..e5b4586 --- /dev/null +++ b/frontend/src/components/subscriptions/PlatformChip.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/components/subscriptions/SettingsTab.vue b/frontend/src/components/subscriptions/SettingsTab.vue new file mode 100644 index 0000000..cc1be56 --- /dev/null +++ b/frontend/src/components/subscriptions/SettingsTab.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/frontend/src/components/subscriptions/SubscriptionsTab.vue b/frontend/src/components/subscriptions/SubscriptionsTab.vue new file mode 100644 index 0000000..96ac883 --- /dev/null +++ b/frontend/src/components/subscriptions/SubscriptionsTab.vue @@ -0,0 +1,521 @@ + + + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index b51f37f..f3b6cbe 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -7,8 +7,6 @@ import ArtistView from './views/ArtistView.vue' import SeriesManageView from './views/SeriesManageView.vue' import SeriesReaderView from './views/SeriesReaderView.vue' import SubscriptionsView from './views/SubscriptionsView.vue' -import CredentialsView from './views/CredentialsView.vue' -import DownloadsView from './views/DownloadsView.vue' import PostsView from './views/PostsView.vue' import ArtistsView from './views/ArtistsView.vue' @@ -34,10 +32,16 @@ const routes = [ { path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } }, // FC-3: subscription backbone + // /credentials and /downloads were folded into /subscriptions as subtabs + // 2026-05-27 (?tab=settings and ?tab=downloads). The hub view owns the + // whole download pipeline domain. { path: '/posts', name: 'posts', component: PostsView, meta: { title: 'Posts' } }, { path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } }, - { path: '/credentials', name: 'credentials', component: CredentialsView, meta: { title: 'Credentials' } }, - { path: '/downloads', name: 'downloads', component: DownloadsView, meta: { title: 'Downloads' } } + + // Bookmark/back-button safety net for the routes that got folded in + // (no meta.title — stay out of TopNav). + { path: '/credentials', redirect: '/subscriptions?tab=settings' }, + { path: '/downloads', redirect: '/subscriptions?tab=downloads' } ] // Browser uses HTML5 history; non-browser (Vitest/SSR) falls back to memory diff --git a/frontend/src/stores/downloads.js b/frontend/src/stores/downloads.js index d95f633..5dd76df 100644 --- a/frontend/src/stores/downloads.js +++ b/frontend/src/stores/downloads.js @@ -8,10 +8,14 @@ export const useDownloadsStore = defineStore('downloads', () => { const events = ref([]) const cursor = ref(null) const hasMore = ref(true) - const filter = ref({ status: null, source_id: null, artist_id: null }) + const filter = ref({ + status: null, source_id: null, artist_id: null, + from_date: null, to_date: null, + }) const selected = ref(null) const loading = ref(false) const error = ref(null) + const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 }) function _params(extra = {}) { const out = { limit: 50, ...extra } @@ -65,8 +69,13 @@ export const useDownloadsStore = defineStore('downloads', () => { selected.value = null } + async function loadStats(windowHours = 24) { + stats.value = await api.get('/api/downloads/stats', { params: { window_hours: windowHours } }) + return stats.value + } + return { - events, cursor, hasMore, filter, selected, loading, error, - loadFirst, loadMore, loadOne, applyFilter, closeDetail, + events, cursor, hasMore, filter, selected, loading, error, stats, + loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats, } }) diff --git a/frontend/src/utils/platformColor.js b/frontend/src/utils/platformColor.js new file mode 100644 index 0000000..dab041e --- /dev/null +++ b/frontend/src/utils/platformColor.js @@ -0,0 +1,43 @@ +// Single source of truth for platform → color + icon mapping. Used by +// PlatformChip and any other GS-style platform-tagged surface. The six +// platforms FC supports map 1:1 to the GS palette; unknown platforms fall +// back to grey + mdi-web. Operator-confirmed scope 2026-05-27. + +const ICONS = { + patreon: 'mdi-patreon', + subscribestar: 'mdi-star', + hentaifoundry: 'mdi-palette', + discord: 'mdi-discord', + pixiv: 'mdi-alpha-p-box', + deviantart: 'mdi-deviantart', +} + +const COLORS = { + patreon: 'red', + subscribestar: 'amber', + hentaifoundry: 'purple', + discord: 'indigo', + pixiv: 'blue', + deviantart: 'green', +} + +const LABELS = { + patreon: 'Patreon', + subscribestar: 'SubscribeStar', + hentaifoundry: 'HentaiFoundry', + discord: 'Discord', + pixiv: 'Pixiv', + deviantart: 'DeviantArt', +} + +export function platformIcon(platform) { + return ICONS[platform] || 'mdi-web' +} + +export function platformColor(platform) { + return COLORS[platform] || 'grey' +} + +export function platformLabel(platform) { + return LABELS[platform] || platform +} diff --git a/frontend/src/views/CredentialsView.vue b/frontend/src/views/CredentialsView.vue deleted file mode 100644 index 1be9334..0000000 --- a/frontend/src/views/CredentialsView.vue +++ /dev/null @@ -1,79 +0,0 @@ - - - diff --git a/frontend/src/views/DownloadsView.vue b/frontend/src/views/DownloadsView.vue deleted file mode 100644 index 4a2a612..0000000 --- a/frontend/src/views/DownloadsView.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - diff --git a/frontend/src/views/SubscriptionsView.vue b/frontend/src/views/SubscriptionsView.vue index 832aa84..67df3ab 100644 --- a/frontend/src/views/SubscriptionsView.vue +++ b/frontend/src/views/SubscriptionsView.vue @@ -1,416 +1,71 @@ diff --git a/tests/test_api_downloads.py b/tests/test_api_downloads.py index d64e649..253f1c5 100644 --- a/tests/test_api_downloads.py +++ b/tests/test_api_downloads.py @@ -107,3 +107,22 @@ async def test_detail_returns_full_metadata(client, seed): async def test_detail_404(client): resp = await client.get("/api/downloads/99999") assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_stats_returns_full_status_set(client, seed): + resp = await client.get("/api/downloads/stats") + assert resp.status_code == 200 + body = await resp.get_json() + assert set(body) == {"pending", "running", "ok", "error", "skipped"} + assert body["ok"] == 1 + assert body["error"] == 1 + assert body["pending"] == 0 + + +@pytest.mark.asyncio +async def test_stats_window_hours_rejects_out_of_range(client): + resp = await client.get("/api/downloads/stats?window_hours=0") + assert resp.status_code == 400 + resp = await client.get("/api/downloads/stats?window_hours=bogus") + assert resp.status_code == 400