Compare commits

..

9 Commits

Author SHA1 Message Date
bvandeusen 88e53e5b86 Merge pull request 'v26.05.27.1: subscriptions hub + post-card merge + sidecar audit' (#29) from dev into main 2026-05-27 17:12:48 -04:00
bvandeusen aa28bddeab fix(alembic 0025): qualify ambiguous post.id / post.source_id in fragment-group SELECT (post JOIN source — both have id) 2026-05-27 15:45:42 -04:00
bvandeusen b7b313cc05 fix(alembic 0025): include HF + Discord post_url backfill (no longer 'deferred to deep-scan')
Operator-flagged: the claim that 'a future deep-scan via the new parser
will fix HF and Discord post_url' was conditional on the operator
actually running a deep-scan, which they might not do for ages. Until
then HF posts stay at post_url=NULL (HF sidecars have no `url` field)
and Discord posts stay pointing at cdn.discordapp.com/attachments/...
(the file URL, not the message permalink).

The migration was already opening sidecar files for SubscribeStar.
Generalizing the loop to also handle HF and Discord is a tiny addition
that closes the gap without operator intervention.

Per-platform Part 1 logic now:
  subscribestar — read sidecar.post_id, overwrite external_post_id +
    post_url with the derived /posts/<post_id> permalink.
  hentaifoundry — read sidecar.user + .index, overwrite post_url with
    /pictures/user/<u>/<i>. external_post_id (= index) unchanged.
  discord — read sidecar.server_id + .channel_id + .message_id,
    overwrite post_url with the discord.com/channels/.../<m> triple.
    external_post_id (= message_id) unchanged.

Part 2 (SubscribeStar fragment merge) and Part 3 (pure-SQL Pixiv
post_url backfill) unchanged.

Posts whose related ImageRecord paths don't resolve on disk (orphan
filesystem state) are reported per-platform in the migration output —
those still need a future deep-scan, but the in-DB-with-on-disk-files
common case is now fully covered by the migration alone.
2026-05-27 15:38:18 -04:00
bvandeusen bd3f996582 fix(sidecar): correct external_post_id + post_url derivation for non-Patreon platforms
Audit of one sample sidecar per platform on the operator's
/mnt/Data/Patreon/ archive surfaced three parser bugs that have been
silently corrupting non-Patreon Posts since FC-3 shipped:

1. SubscribeStar `id` vs `post_id` confusion. gallery-dl puts the
   per-attachment id in `id` (e.g. 711509) and the actual post id in
   `post_id` (e.g. 360360). FC's external_post_id chain had `id`
   winning, so every multi-image SubscribeStar post was fragmented into
   N Post rows in the database. Reorder the chain to
   `("post_id", "id", "index", "message_id")` — Patreon/Pixiv (no
   `post_id`), HF (uses `index`), Discord (uses `message_id`) all
   unaffected.

2. Discord `message` field not captured. Discord posts put the body in
   `message`, not `content`. Append it to the description fallback chain
   `("content", "description", "caption", "message")`.

3. post_url is the file URL on SubscribeStar/Pixiv/HF/Discord. New
   `_derive_post_url(platform, data)` helper synthesizes proper
   permalinks from per-platform fields:
     subscribestar → https://www.subscribestar.com/posts/<post_id>
     pixiv         → https://www.pixiv.net/artworks/<id>
     hentaifoundry → https://www.hentai-foundry.com/pictures/user/<user>/<index>
     discord       → https://discord.com/channels/<server>/<channel>/<message>
   Patreon's bare `url` IS a real permalink and is used as-is. For the
   four file-URL platforms, the bare `url` is NEVER trusted: derive or
   return None rather than persist a CDN URL.

Tests:
- `test_parse_core_fields_and_id_priority` flipped to assert post_id
  wins over id.
- New `test_parse_id_used_when_no_post_id` covers the Patreon real
  shape.
- New `test_parse_message_used_as_description_fallback` covers Discord
  bodies.
- Five new tests cover per-platform post_url derivation
  (SubscribeStar/Pixiv/HF/Discord/Patreon-untouched + missing-fields →
  None).

Cleanup migration alembic 0025_fix_subscribestar_post_ids:
- For each SubscribeStar Post: find a related ImageRecord.path, walk to
  its sidecar JSON, read `post_id`, overwrite Post.external_post_id +
  post_url with the corrected values.
- After all updates, every group of Posts under one source sharing the
  same NEW external_post_id is a fragment-set — merge to a canonical
  row using the same ImageProvenance pre-delete + repoint dance as
  alembic 0022 (banked pattern).
- Pure-SQL backfill of Pixiv post_url: replace any `i.pximg.net`-shape
  url with the derived `/artworks/<id>` permalink.
- HF and Discord post_url backfills skipped — HF would need the `user`
  field (not stored on Post), Discord needs server/channel triple.
  Both will be corrected by a deep-scan re-applying sidecars through
  the new parser.

Idempotent: re-running on already-corrected data is a no-op.
2026-05-27 15:35:25 -04:00
bvandeusen ae8c78ae09 fix(sidecar): synthesize post_title from content first-line when title is empty (subscribestar)
SubscribeStar gallery-dl always writes `title: ""` and embeds the leading
sentence inside `content` HTML. Confirmed against the operator's
/mnt/Data/Patreon/Cheunart/subscribestar/ dump 2026-05-27: every
post's JSON has `title: ""` and a content like
`<div>Lets say hello to you guys with my Belle <br><br><br></div>`.
FC's sidecar parser, treating empty strings as missing, had been leaving
post_title NULL on every subscribestar post since FC-3 shipped.

Fix at two layers:

1. `backend/app/utils/sidecar.py` — new `_first_line_text(body, limit)`
   helper strips HTML tags, collapses whitespace, returns the first
   non-empty line truncated to 120 chars with ellipsis. `parse_sidecar`
   now falls back to this when `title` resolves to None and a
   `content`/`description`/`caption` value is present. Patreon's
   non-empty titles short-circuit the fallback so existing behavior is
   unchanged. Four new tests in test_sidecar_util.py pin: derivation
   from content, truncation at 120 chars, explicit-title precedence,
   no-content no-fallback.

2. `alembic 0024_backfill_post_title_from_description` — backfills the
   same logic across existing Post rows where `post_title IS NULL OR
   post_title = ''` AND description is present. Idempotent (re-running
   is a no-op once titles are populated). Downgrade is a no-op since
   there's no safe way to tell derived rows from genuine ones.

After deploy + migration: subscribestar posts will surface a meaningful
title in PostCard, post feed search, etc.
2026-05-27 14:44:09 -04:00
bvandeusen 4d2c464045 feat(post-card): absorb PostModal into PostCard with click-to-expand
PostCard and PostModal competed for the same data and rendered redundant
chrome (header twice, image grid twice, attachment list twice). The wider
PostCard layout we shipped 2026-05-27 has enough real estate to be the
canonical post surface, so collapse the two into one.

Compact (default) state is unchanged: hero + 3-cell rail + truncated
title + 3/5-line description + attachment count badge. Whole-card click
expands in place. Expanded state shows: full title, mosaic of ALL post
images via PostImageGrid (uncapped, lazy-loaded via getPostFull), full
sanitized-HTML description with paragraph wrapping, attachments as
downloadable pill links. Click the chevron in the header to collapse;
mosaic image clicks open ImageViewer scoped to the post (modalStore's
postImageIds path is preserved — only the comment changed).

Per-card local state — no global modal store. Each PostCard owns its
own expanded ref and lazy-loaded detail; collapsing a card discards
neither (so re-expand is instant after the first fetch).

Deleted: PostModal.vue, postModal.js store. Removed the App.vue mount.
2026-05-27 14:30:04 -04:00
bvandeusen b8ad17c68d fix(build): poll for ext-<version> release in tag-push build-web (race fix)
Cutting a release fires BOTH the push-to-main workflow AND the push-to-tag
workflow in parallel. main-push runs sign-extension (AMO round-trip 1-5min)
then publishes the ext-<version> Forgejo release; tag-push skips
sign-extension (gated to main) and races straight to build-web's Download
XPI step. Tag-push lost every time — got 404 from
releases/tags/ext-<version> before main-push had finished signing.

v26.05.27.0 hit this: tag-push build-web died on exit 22 because the
ext-1.0.4 release wasn't published yet (it arrived ~4min later).

Fix: wrap the release lookup in a 20-iteration sleep+retry loop, 30s
between attempts (10min total upper bound, generous for AMO). main-push's
signing eventually publishes the release; tag-push picks it up on a later
poll. No more manual rerun of the failed job after every release cut.

Banked the trap as reference_tag_push_main_push_race.md — same shape will
recur any time a tag-push workflow consumes a main-push-produced artifact.
2026-05-27 13:25:58 -04:00
bvandeusen 1fd54897d8 fix(api): ruff UP017 — use datetime.UTC alias in /api/downloads/stats 2026-05-27 13:11:44 -04:00
bvandeusen 9322c984fd feat(subs-hub): collapse /credentials + /downloads into /subscriptions hub with three GS-style subtabs
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.
2026-05-27 13:02:24 -04:00
27 changed files with 2249 additions and 934 deletions
+39 -12
View File
@@ -173,24 +173,51 @@ jobs:
# same source code as the preceding main-push build but with an
# immutable version tag — they need the XPI too, otherwise the
# versioned image ships without the signed extension.
#
# Tag-push vs main-push race (operator-flagged 2026-05-27 after
# v26.05.27.0 hit it): a release cut fires BOTH workflows almost
# simultaneously. Main-push runs sign-extension (1-5min AMO round
# trip) before publishing the ext-<version> release; tag-push
# skips sign-extension (gated to main) and races straight to
# this download step. Tag-push lost every time. Fix: poll the
# ext-<version> release endpoint with a sleep+retry loop (30s
# for up to 10min total) before giving up. Main-push's signing
# eventually wins and tag-push picks the release up on a later
# iteration.
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -eux
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
# Look up the ext-<version> release; extract the .xpi asset's
# browser_download_url (Forgejo's /releases/assets/<id> endpoint
# returns ASSET METADATA, not the binary blob — operator-flagged
# 2026-05-26: my prior code curl'd the metadata endpoint without
# -f and wrote the resulting 404-page-not-found text into
# fabledcurator-*.xpi, which Firefox then rejected as "corrupt").
# browser_download_url is the canonical binary endpoint and is
# also publicly accessible (no token needed) but we pass the
# token anyway for symmetry with private-repo support.
curl -sf -H "Authorization: token $TOKEN" \
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" \
-o release.json
# Poll for the ext-<version> release. main-push's sign-extension
# step (AMO round-trip, 1-5min) needs to finish + upload before
# tag-push can fetch. 30s * 20 = up to 10min wait, then hard-fail.
for attempt in $(seq 1 20); do
STATUS=$(curl -s -o release.json -w "%{http_code}" \
-H "Authorization: token $TOKEN" \
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000)
if [ "$STATUS" = "200" ]; then
echo "Found ext-$VERSION release on attempt $attempt"
break
fi
if [ "$attempt" = "20" ]; then
echo "ERROR: ext-$VERSION release not available after 10min of polling"
echo "Last HTTP status: $STATUS"
exit 1
fi
echo "Attempt $attempt: ext-$VERSION not yet published (HTTP $STATUS); sleeping 30s"
sleep 30
done
# Extract the .xpi asset's browser_download_url (Forgejo's
# /releases/assets/<id> endpoint returns ASSET METADATA, not
# the binary blob — operator-flagged 2026-05-26: my prior
# code curl'd the metadata endpoint without -f and wrote the
# resulting 404-page-not-found text into fabledcurator-*.xpi,
# which Firefox then rejected as "corrupt").
# browser_download_url is the canonical binary endpoint and
# is also publicly accessible (no token needed) but we pass
# the token anyway for symmetry with private-repo support.
DOWNLOAD_URL=$(python3 -c "import json; r=json.load(open('release.json')); xpis=[a for a in r.get('assets', []) if a.get('name','').endswith('.xpi')]; print(xpis[0]['browser_download_url'])")
test -n "$DOWNLOAD_URL"
echo "Downloading XPI from: $DOWNLOAD_URL"
@@ -0,0 +1,80 @@
"""backfill post.post_title from description first-line — 2026-05-27
Revision ID: 0024
Revises: 0023
Create Date: 2026-05-27
SubscribeStar gallery-dl always writes `title: ""` and embeds the leading
sentence inside `content` HTML. FC's sidecar parser was leaving
post_title NULL for every SubscribeStar post since FC-3 shipped. The
parser fix (sidecar._first_line_text fallback) now synthesizes a title
at parse time; this migration applies the same logic retroactively to
existing rows.
Operator-flagged 2026-05-27 after inspecting
/mnt/Data/Patreon/Cheunart/subscribestar/ sidecars.
Idempotent: only touches rows where post_title IS NULL or empty AND
description IS NOT NULL. Re-running the migration is a no-op.
"""
from __future__ import annotations
import re
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
revision: str = "0024"
down_revision: Union[str, None] = "0023"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"\s+")
def _first_line_text(body: str, limit: int = 120) -> str | None:
"""Mirror of sidecar._first_line_text. Kept inline so the migration
doesn't carry a runtime import dependency from app code that may
have moved by the time the migration is replayed years from now."""
if not body:
return None
text_ = _TAG_RE.sub(" ", body)
text_ = text_.replace("\xa0", " ")
for line in text_.splitlines():
line = _WS_RE.sub(" ", line).strip()
if line:
if len(line) > limit:
return line[: limit - 1].rstrip() + ""
return line
return None
def upgrade() -> None:
bind = op.get_bind()
rows = bind.execute(
text(
"SELECT id, description FROM post "
"WHERE (post_title IS NULL OR post_title = '') "
"AND description IS NOT NULL AND description <> ''"
)
).fetchall()
updated = 0
for row in rows:
derived = _first_line_text(row.description)
if not derived:
continue
bind.execute(
text("UPDATE post SET post_title = :t WHERE id = :id"),
{"t": derived, "id": row.id},
)
updated += 1
print(f"0024: backfilled post_title on {updated} row(s)")
def downgrade() -> None:
# No safe restore — we can't tell which post_titles were derived vs
# genuinely present. Leave the column alone on rollback.
pass
@@ -0,0 +1,288 @@
"""sidecar-audit followup: correct external_post_id + post_url across all platforms
Revision ID: 0025
Revises: 0024
Create Date: 2026-05-27
Closes the operator-flagged 2026-05-27 sidecar audit findings. Three
data-correctness bugs across non-Patreon platforms had been silently
corrupting Posts since FC-3 shipped; the parser fix (sidecar.py, same
commit) addresses new imports. This migration cleans up existing rows.
Per-platform actions:
subscribestar — gallery-dl wrote the per-attachment id in `id` and
the actual post id in `post_id`. FC's parser picked `id`, so every
multi-image SubscribeStar post was fragmented into N Post rows.
1. For each SubscribeStar Post, read its sidecar (via the related
ImageRecord's on-disk path), pull `post_id`, overwrite
external_post_id and post_url.
2. Merge groups of Posts under one source that now share an
external_post_id (fragments of the same actual post). Same
ImageProvenance pre-delete + repoint dance as alembic 0022.
hentaifoundry — sidecars have NO `url` field; `src` is the image
URL. FC's parser stored post_url=NULL. Read each HF Post's sidecar
for `user` + `index`, derive the canonical /pictures/user/<u>/<i>
permalink. external_post_id (= `index`) was already correct.
discord — gallery-dl wrote the CDN attachment URL in `url`. FC's
parser stored that as post_url. Read each Discord Post's sidecar
for the server/channel/message triple, derive the proper
discord.com/channels/.../<message> permalink. external_post_id (=
`message_id`) was already correct.
pixiv — pure-SQL backfill: replace any `i.pximg.net`-style URL on
Post.post_url with the derived `/artworks/<id>` permalink. Pixiv
external_post_id (= `id`) was already correct; no sidecar IO
needed.
Idempotent: re-running on already-corrected data is a no-op (skips
rows whose derived value matches what's already stored).
Posts whose related ImageRecord paths don't resolve on disk (orphaned
filesystem state) are skipped with a count in the migration output —
those will be picked up by a future deep-scan.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
revision: str = "0025"
down_revision: Union[str, None] = "0024"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Mirror of sidecar._NUMBERING_PREFIX. Kept inline so the migration is
# self-contained (the operator's banked rule:
# reference_postgres_enum_swap_drop_checks.md says migrations shouldn't
# import from runtime app code).
_NUMBERING_PREFIX = re.compile(r"^\d+_(.+)$")
def _find_sidecar(media_path: Path) -> Path | None:
"""gallery-dl writes the sidecar under the unprefixed stem
(`HOLLOW-ICHIGO.json`) while the media file gets a NN_ ordering
prefix (`01_HOLLOW-ICHIGO.png`). Try in order:
1. <stem>.json next to the media
2. <media>.json next to the media (full-name variant)
3. strip the NN_ prefix from the stem, then <stripped>.json
"""
if not media_path:
return None
cand = media_path.with_suffix(".json")
if cand.is_file():
return cand
cand = media_path.parent / f"{media_path.name}.json"
if cand.is_file():
return cand
m = _NUMBERING_PREFIX.match(media_path.stem)
if m:
cand = media_path.parent / f"{m.group(1)}.json"
if cand.is_file():
return cand
return None
def _str_id(v) -> str | None:
"""str() a JSON scalar id; reject bool (JSON booleans are ints in
Python's eyes but they aren't valid sidecar ids)."""
if isinstance(v, bool):
return None
if isinstance(v, (str, int)) and str(v).strip():
return str(v).strip()
return None
def _str_field(v) -> str | None:
if isinstance(v, str) and v.strip():
return v.strip()
return None
def upgrade() -> None:
conn = op.get_bind()
# ── PART 1: Per-platform corrections requiring filesystem IO ─────
# SubscribeStar, HentaiFoundry, Discord all need fields from the
# sidecar to construct the right post_url. We walk each Post's
# related ImageRecord.path to find the sidecar, read it, derive,
# and update.
targets = conn.execute(text("""
SELECT p.id, p.external_post_id, p.post_url, s.platform
FROM post p
JOIN source s ON s.id = p.source_id
WHERE s.platform IN ('subscribestar', 'hentaifoundry', 'discord')
""")).fetchall()
stats: dict[str, dict[str, int]] = {
plat: {"read": 0, "updated": 0, "no_sidecar": 0}
for plat in ("subscribestar", "hentaifoundry", "discord")
}
for post_row in targets:
plat = post_row.platform
path = _first_attachment_path(conn, post_row.id)
if not path:
stats[plat]["no_sidecar"] += 1
continue
sidecar = _find_sidecar(Path(path))
if sidecar is None:
stats[plat]["no_sidecar"] += 1
continue
try:
data = json.loads(sidecar.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
stats[plat]["no_sidecar"] += 1
continue
stats[plat]["read"] += 1
new_epid = post_row.external_post_id
new_url = None
if plat == "subscribestar":
pid = _str_id(data.get("post_id"))
if pid:
new_epid = pid
new_url = f"https://www.subscribestar.com/posts/{pid}"
elif plat == "hentaifoundry":
user = _str_field(data.get("user")) or _str_field(data.get("artist"))
idx = _str_id(data.get("index"))
if user and idx:
new_url = f"https://www.hentai-foundry.com/pictures/user/{user}/{idx}"
elif plat == "discord":
sid = _str_id(data.get("server_id"))
cid = _str_id(data.get("channel_id"))
mid = _str_id(data.get("message_id"))
if sid and cid and mid:
new_url = f"https://discord.com/channels/{sid}/{cid}/{mid}"
# Idempotent: skip if nothing changed.
if new_epid == post_row.external_post_id and new_url == post_row.post_url:
continue
conn.execute(
text("""
UPDATE post
SET external_post_id = :epid, post_url = :url
WHERE id = :id
"""),
{"epid": new_epid, "url": new_url, "id": post_row.id},
)
stats[plat]["updated"] += 1
for plat, s in stats.items():
print(
f"0025: {plat} — read {s['read']} sidecars, "
f"updated {s['updated']} Posts, "
f"{s['no_sidecar']} Posts had no resolvable sidecar"
)
# ── PART 2: Merge SubscribeStar fragments now sharing epid ───────
# After Part 1, each group of Posts under one source with the SAME
# new external_post_id is a fragment-set of the same actual post.
# Merge to one canonical row. Pre-handle the same ImageProvenance
# collision pattern as alembic 0022 (uq_image_provenance_image_post).
fragment_groups = conn.execute(text("""
SELECT p.source_id, p.external_post_id,
ARRAY_AGG(p.id ORDER BY p.id ASC) AS post_ids
FROM post p
JOIN source s ON s.id = p.source_id
WHERE s.platform = 'subscribestar'
AND p.external_post_id IS NOT NULL
GROUP BY p.source_id, p.external_post_id
HAVING COUNT(*) > 1
""")).fetchall()
merged = 0
for grp in fragment_groups:
post_ids = list(grp.post_ids)
keep_id, *drop_ids = post_ids
for drop_id in drop_ids:
# Pre-DELETE colliding ImageProvenance under drop_ that
# already exist under keep (alembic 0022 banked the pattern).
conn.execute(
text("""
DELETE FROM image_provenance
WHERE post_id = :drop_
AND image_record_id IN (
SELECT image_record_id FROM image_provenance
WHERE post_id = :keep
)
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_provenance SET post_id = :keep
WHERE post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_record SET primary_post_id = :keep
WHERE primary_post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE post_attachment SET post_id = :keep
WHERE post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("DELETE FROM post WHERE id = :drop_"),
{"drop_": drop_id},
)
merged += 1
print(f"0025: subscribestar — merged {merged} duplicate Post fragments")
# ── PART 3: Pixiv post_url backfill (pure SQL) ───────────────────
# Pixiv's external_post_id is already correct (gallery-dl's `id` is
# the post id). Only post_url needs derivation: replace anything
# under i.pximg.net (the file URL) with the /artworks/<id> permalink.
pixiv_updated = conn.execute(text("""
UPDATE post p
SET post_url = 'https://www.pixiv.net/artworks/' || p.external_post_id
FROM source s
WHERE p.source_id = s.id
AND s.platform = 'pixiv'
AND p.external_post_id IS NOT NULL
AND (p.post_url IS NULL
OR p.post_url LIKE 'https://i.pximg.net/%'
OR p.post_url LIKE 'http://i.pximg.net/%')
""")).rowcount
print(f"0025: pixiv — backfilled post_url on {pixiv_updated} Posts")
def _first_attachment_path(conn, post_id: int) -> str | None:
"""Return any ImageRecord.path attached to this post (via
ImageProvenance). Lowest-id row keeps the migration deterministic
so re-running on the same DB picks the same sidecar."""
row = conn.execute(
text("""
SELECT ir.path
FROM image_provenance ip
JOIN image_record ir ON ir.id = ip.image_record_id
WHERE ip.post_id = :pid
ORDER BY ip.id ASC
LIMIT 1
"""),
{"pid": post_id},
).first()
return row[0] if row else None
def downgrade() -> None:
# Lossy: external_post_id values were overwritten with the correct
# post_id; original per-attachment ids weren't preserved. Post-merge
# also deleted drop rows. No safe restore. To roll back the schema
# invariant, fork from 0024 and re-run sidecar imports.
pass
+32 -1
View File
@@ -5,8 +5,10 @@ status/source/artist. Returns slim records.
Detail view: full DownloadEvent including the metadata JSONB.
"""
from datetime import UTC, datetime, timedelta
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(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("/<int:event_id>", methods=["GET"])
async def get_download(event_id: int):
async with get_session() as session:
+102 -4
View File
@@ -55,6 +55,33 @@ def _first_str(data: dict, keys: tuple[str, ...]) -> str | None:
return None
# Strip HTML tags + collapse whitespace + take the first non-empty line.
# Used to derive a display title from a body when the platform doesn't
# expose a separate title field (subscribestar posts always write
# `title: ""` and put the leading sentence inside `content` as HTML).
# Truncated to 120 chars with an ellipsis if longer — long enough to be
# meaningful in a feed, short enough to fit a row.
_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"\s+")
def _first_line_text(body: str, limit: int = 120) -> str | None:
if not body:
return None
text = _TAG_RE.sub(" ", body)
text = text.replace("\xa0", " ")
# Split on hard line breaks first; the body-stripped HTML often
# collapses to one logical line, in which case the first sentence
# split is the next-best heuristic.
for line in text.splitlines():
line = _WS_RE.sub(" ", line).strip()
if line:
if len(line) > limit:
return line[: limit - 1].rstrip() + ""
return line
return None
def _parse_date(v) -> datetime | None:
if isinstance(v, bool):
return None
@@ -84,8 +111,16 @@ def parse_sidecar(data: dict) -> SidecarData:
cat = data.get("category")
platform = cat if isinstance(cat, str) and cat.strip() else None
# external_post_id lookup order: post_id MUST come before id.
# SubscribeStar gallery-dl writes the per-attachment id in `id`
# (e.g. 711509) and the actual post id in `post_id` (e.g. 360360);
# picking `id` first fragments every multi-image subscribestar post
# into N distinct Post rows in FC. Patreon/Pixiv have no `post_id`
# so `id` still wins for them; HF uses `index`, Discord uses
# `message_id` — all reached via the remaining chain entries.
# Operator-flagged 2026-05-27 during the sidecar audit.
external_post_id = None
for k in ("id", "post_id", "index", "message_id"):
for k in ("post_id", "id", "index", "message_id"):
v = data.get(k)
if isinstance(v, bool):
continue
@@ -111,13 +146,76 @@ def parse_sidecar(data: dict) -> SidecarData:
if post_date is not None:
break
# `message` is Discord gallery-dl's body field (no `content`); added
# 2026-05-27 to the description fallback chain.
description = _first_str(
data, ("content", "description", "caption", "message"),
)
# SubscribeStar posts always write `title: ""` and put the leading
# sentence inside `content` (confirmed against the operator's
# /mnt/Data/Patreon/Cheunart/subscribestar/ dump 2026-05-27). When
# no explicit title is present, synthesize one from the description
# body's first non-empty line. Patreon retains its explicit titles
# because they're non-empty and short-circuit the fallback.
post_title = _first_str(data, ("title",))
if post_title is None and description:
post_title = _first_line_text(description)
# post_url derivation: SubscribeStar/Pixiv/HF/Discord put the FILE
# download URL in `url`, not a post permalink. Synthesize the
# permalink from per-platform fields when possible. Patreon's `url`
# IS a permalink and is used as-is. For the four file-URL platforms,
# the bare `url` is NEVER trusted — derive or return None rather
# than persist a CDN URL in post.post_url.
if platform in _DERIVED_URL_PLATFORMS:
post_url = _derive_post_url(platform, data)
else:
post_url = _first_str(data, ("url", "post_url"))
return SidecarData(
platform=platform,
external_post_id=external_post_id,
post_url=_first_str(data, ("url", "post_url")),
post_title=_first_str(data, ("title",)),
description=_first_str(data, ("content", "description", "caption")),
post_url=post_url,
post_title=post_title,
description=description,
attachment_count=attachment_count,
post_date=post_date,
raw=data,
)
_DERIVED_URL_PLATFORMS = frozenset({
"subscribestar", "pixiv", "hentaifoundry", "discord",
})
def _derive_post_url(platform: str, data: dict) -> str | None:
"""Synthesize the post-permalink URL from per-platform metadata.
gallery-dl writes the file-download URL in `url` for these four
platforms; we need a real permalink for the PostCard "open original"
button. Returns None if the platform-specific fields are missing
(rare in well-formed sidecars but defensive).
"""
if platform == "subscribestar":
pid = data.get("post_id")
if isinstance(pid, (str, int)) and str(pid).strip():
return f"https://www.subscribestar.com/posts/{pid}"
elif platform == "pixiv":
pid = data.get("id")
if isinstance(pid, (str, int)) and not isinstance(pid, bool) and str(pid).strip():
return f"https://www.pixiv.net/artworks/{pid}"
elif platform == "hentaifoundry":
user = _first_str(data, ("user", "artist"))
idx = data.get("index")
if user and isinstance(idx, (str, int)) and not isinstance(idx, bool) and str(idx).strip():
return f"https://www.hentai-foundry.com/pictures/user/{user}/{idx}"
elif platform == "discord":
sid = data.get("server_id")
cid = data.get("channel_id")
mid = data.get("message_id")
if all(isinstance(v, (str, int)) and not isinstance(v, bool) and str(v).strip()
for v in (sid, cid, mid)):
return f"https://discord.com/channels/{sid}/{cid}/{mid}"
return None
-2
View File
@@ -4,7 +4,6 @@
<RouterView />
</AppShell>
<ImageViewer v-if="modal.isOpen" @close="modal.close()" />
<PostModal />
<AppSnackbar ref="snackbar" />
</v-app>
</template>
@@ -14,7 +13,6 @@ import { onMounted, ref } from 'vue'
import AppShell from './components/AppShell.vue'
import AppSnackbar from './components/AppSnackbar.vue'
import ImageViewer from './components/modal/ImageViewer.vue'
import PostModal from './components/posts/PostModal.vue'
import { useModalStore } from './stores/modal.js'
const modal = useModalStore()
+209 -45
View File
@@ -1,8 +1,8 @@
<template>
<v-card
class="fc-post-card"
:class="['fc-post-card', expanded && 'fc-post-card--expanded']"
variant="outlined"
tabindex="0"
:tabindex="expanded ? -1 : 0"
@click="onCardClick"
@keydown.enter="onCardClick"
>
@@ -14,6 +14,12 @@
@click.stop
>{{ post.artist.name }}</RouterLink>
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
<span v-if="expanded && images.length" class="fc-post-card__meta">
· {{ images.length }} image{{ images.length === 1 ? '' : 's' }}
</span>
<span v-if="expanded && attachments.length" class="fc-post-card__meta">
· {{ attachments.length }} attachment{{ attachments.length === 1 ? '' : 's' }}
</span>
<v-spacer />
<v-btn
v-if="post.post_url"
@@ -22,11 +28,18 @@
:aria-label="`open original post on ${post.source.platform}`"
@click.stop
/>
<v-btn
:icon="expanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
size="x-small" variant="text"
:aria-label="expanded ? 'Collapse post' : 'Expand post'"
@click.stop="toggleExpanded"
/>
</div>
<div class="fc-post-card__body">
<!-- Compact body: collapsed card. Hero + thumb rail + truncated text. -->
<div v-if="!expanded" class="fc-post-card__body">
<div class="fc-post-card__media">
<template v-if="post.thumbnails?.length">
<template v-if="images.length">
<div class="fc-post-card__hero">
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
</div>
@@ -63,28 +76,85 @@
</div>
</div>
</div>
<!-- Expanded body: title, full mosaic, full sanitized HTML description,
attachments. Lazy-loaded detail via getPostFull. -->
<div v-else class="fc-post-card__expanded">
<h2 v-if="post.post_title" class="fc-post-card__title-full">
{{ post.post_title }}
</h2>
<h2 v-else class="fc-post-card__title-full fc-post-card__title--missing">
Post {{ post.external_post_id }}
</h2>
<section v-if="images.length" class="fc-post-card__sec">
<PostImageGrid :thumbnails="images" />
<div v-if="!detailLoaded" class="fc-post-card__loading-hint">
Loading full image list
</div>
</section>
<section v-if="descriptionHtml" class="fc-post-card__sec">
<div class="fc-post-card__desc-full" v-html="descriptionHtml" />
</section>
<section v-else-if="detailLoaded" class="fc-post-card__sec">
<p class="fc-post-card__desc fc-post-card__desc--missing">(no description)</p>
</section>
<section v-if="attachments.length" class="fc-post-card__sec">
<h3 class="fc-post-card__h3">Attachments</h3>
<div class="fc-post-card__atts-full">
<a
v-for="att in attachments" :key="att.id"
:href="att.download_url" download
class="fc-post-card__att"
@click.stop
>
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
<span>{{ att.original_filename }}</span>
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
</a>
</div>
</section>
</div>
</v-card>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostModalStore } from '../../stores/postModal.js'
import { usePostsStore } from '../../stores/posts.js'
import { sanitizeHtml } from '../../utils/htmlSanitize.js'
import PostEmptyThumbs from './PostEmptyThumbs.vue'
import PostImageGrid from './PostImageGrid.vue'
const props = defineProps({
post: { type: Object, required: true },
})
const postModal = usePostModalStore()
const postsStore = usePostsStore()
// Per-card expand state. No global modal — each PostCard owns its own
// view-mode and lazy-loaded detail.
const expanded = ref(false)
const detail = ref(null)
const detailLoaded = ref(false)
const detailError = ref(null)
// When expanded + detail loaded, prefer the uncapped detail thumbnails +
// full description. Falls back to feed shape if detail fetch is in flight
// or failed.
const merged = computed(() => detail.value || props.post)
const images = computed(() => merged.value.thumbnails || [])
const attachments = computed(() => merged.value.attachments || [])
// Compact-view hero+rail derived from the feed-shape (capped 6).
const hero = computed(() => props.post.thumbnails?.[0])
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
const moreCount = computed(() => {
const more = props.post.thumbnails_more || 0
const railLen = rail.value.length
// If feed returned more than (1 hero + 3 rail = 4), extra spills into "+N".
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
return more + extraShown
})
@@ -101,8 +171,57 @@ const relativeDate = computed(() => {
return new Date(sortDateIso.value).toLocaleDateString()
})
function onCardClick () {
postModal.open(props.post)
const descriptionHtml = computed(() => {
// Detail endpoint returns description_full as plain text (the service
// uses html_to_plain on the stored description). Render plain text in
// <p> wrappers; sanitize defensively in case the backend ever returns
// raw HTML.
const raw = merged.value.description_full || merged.value.description_plain
if (!raw) return ''
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
const esc = raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return esc
.split(/\n\s*\n/)
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
})
async function loadDetailIfNeeded () {
if (detailLoaded.value || detail.value) return
try {
detail.value = await postsStore.getPostFull(props.post.id)
detailLoaded.value = true
} catch (e) {
detailError.value = e.message
// Leave merged on feed-shape; the card still renders the truncated
// body so the operator isn't staring at a blank panel.
}
}
function toggleExpanded () {
expanded.value = !expanded.value
if (expanded.value) loadDetailIfNeeded()
}
function onCardClick (e) {
// Inner interactive elements use @click.stop so they never reach here.
// Whole-card click expands a collapsed card; collapsing is chevron-only
// so a mosaic-image click on an expanded card can never accidentally
// collapse the surrounding card.
if (expanded.value) return
expanded.value = true
loadDetailIfNeeded()
}
function formatBytes (n) {
if (!n) return '0 B'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
}
</script>
@@ -110,17 +229,22 @@ function onCardClick () {
.fc-post-card {
padding: 1rem;
margin-bottom: 1rem;
cursor: pointer;
container-type: inline-size;
transition: border-color 0.15s ease;
}
.fc-post-card:hover {
.fc-post-card:not(.fc-post-card--expanded) {
cursor: pointer;
}
.fc-post-card:not(.fc-post-card--expanded):hover {
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 2px;
}
.fc-post-card--expanded {
border-color: rgb(var(--v-theme-accent) / 0.6);
}
.fc-post-card__head {
display: flex;
@@ -129,6 +253,7 @@ function onCardClick () {
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 12px;
flex-wrap: wrap;
}
.fc-post-card__artist {
color: rgb(var(--v-theme-on-surface));
@@ -136,8 +261,10 @@ function onCardClick () {
font-weight: 600;
}
.fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); }
.fc-post-card__date { white-space: nowrap; }
.fc-post-card__date,
.fc-post-card__meta { white-space: nowrap; }
/* ---- COMPACT BODY ---- */
.fc-post-card__body {
display: flex;
flex-direction: column;
@@ -148,13 +275,8 @@ function onCardClick () {
flex-direction: row;
gap: 24px;
}
.fc-post-card__media {
flex: 0 0 50%;
}
.fc-post-card__text {
flex: 1 1 0;
min-width: 0;
}
.fc-post-card__media { flex: 0 0 50%; }
.fc-post-card__text { flex: 1 1 0; min-width: 0; }
}
.fc-post-card__hero {
@@ -164,35 +286,24 @@ function onCardClick () {
border-radius: 6px;
}
.fc-post-card__hero img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
width: 100%; height: 100%;
object-fit: cover; display: block;
}
.fc-post-card__rail {
display: flex;
gap: 6px;
margin-top: 6px;
display: flex; gap: 6px; margin-top: 6px;
}
.fc-post-card__rail-cell {
width: 80px;
height: 80px;
overflow: hidden;
border-radius: 4px;
width: 80px; height: 80px;
overflow: hidden; border-radius: 4px;
}
.fc-post-card__rail-cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
width: 100%; height: 100%;
object-fit: cover; display: block;
}
.fc-post-card__rail-more {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
width: 80px; height: 80px;
display: flex; align-items: center; justify-content: center;
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
border-radius: 4px;
color: rgb(var(--v-theme-on-surface-variant));
@@ -201,8 +312,7 @@ function onCardClick () {
.fc-post-card__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px;
font-weight: 500;
font-size: 18px; font-weight: 500;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
display: -webkit-box;
@@ -238,9 +348,7 @@ function onCardClick () {
color: rgb(var(--v-theme-on-surface-variant));
}
@container (min-width: 800px) {
.fc-post-card__desc {
-webkit-line-clamp: 5;
}
.fc-post-card__desc { -webkit-line-clamp: 5; }
}
.fc-post-card__atts {
@@ -251,4 +359,60 @@ function onCardClick () {
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
/* ---- EXPANDED BODY ---- */
.fc-post-card__expanded {
display: flex;
flex-direction: column;
gap: 20px;
}
.fc-post-card__title-full {
font-family: 'Fraunces', Georgia, serif;
font-size: 22px;
font-weight: 500;
margin: 0;
color: rgb(var(--v-theme-on-surface));
}
@container (min-width: 800px) {
.fc-post-card__title-full { font-size: 26px; }
}
.fc-post-card__sec { margin: 0; }
.fc-post-card__h3 {
font-family: 'Fraunces', Georgia, serif;
font-size: 16px;
font-weight: 500;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-card__loading-hint {
margin-top: 8px;
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__desc-full {
font-size: 0.95rem;
line-height: 1.55;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-card__desc-full :deep(p) { margin: 0 0 12px 0; }
.fc-post-card__desc-full :deep(a) { color: rgb(var(--v-theme-accent)); }
.fc-post-card__atts-full {
display: flex; flex-wrap: wrap; gap: 8px;
}
.fc-post-card__att {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid rgb(var(--v-theme-on-surface-variant));
border-radius: 999px;
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-size: 0.85rem;
}
.fc-post-card__att:hover {
color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
-211
View File
@@ -1,211 +0,0 @@
<template>
<v-dialog
:model-value="store.isOpen"
max-width="1100"
scrollable
@update:model-value="(v) => { if (!v) close() }"
>
<v-card v-if="store.currentPost" class="fc-post-modal">
<div class="fc-post-modal__head">
<v-chip size="x-small" variant="tonal">{{ post.source.platform }}</v-chip>
<RouterLink
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
class="fc-post-modal__artist"
@click="close"
>{{ post.artist.name }}</RouterLink>
<span class="fc-post-modal__date" :title="absoluteDate">
{{ relativeDate }}
</span>
<span v-if="post.thumbnails?.length" class="fc-post-modal__meta">
· {{ post.thumbnails.length }} image{{ post.thumbnails.length === 1 ? '' : 's' }}
</span>
<span v-if="post.attachments?.length" class="fc-post-modal__meta">
· {{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
</span>
<v-spacer />
<v-btn
v-if="post.post_url"
:href="post.post_url" target="_blank" rel="noopener"
icon="mdi-open-in-new" size="small" variant="text"
:aria-label="`open original post on ${post.source.platform}`"
/>
<v-btn
icon="mdi-close" size="small" variant="text"
aria-label="Close"
@click="close"
/>
</div>
<v-card-text class="fc-post-modal__body">
<section v-if="post.thumbnails?.length" class="fc-post-modal__sec">
<PostImageGrid :thumbnails="post.thumbnails" />
<div v-if="!store.detailLoaded" class="fc-post-modal__loading-hint">
Loading full image list…
</div>
</section>
<section v-if="post.post_title" class="fc-post-modal__sec">
<h2 class="fc-post-modal__title">{{ post.post_title }}</h2>
</section>
<section v-if="descriptionHtml" class="fc-post-modal__sec">
<div class="fc-post-modal__desc" v-html="descriptionHtml" />
</section>
<section v-if="post.attachments?.length" class="fc-post-modal__sec">
<h3 class="fc-post-modal__h3">Attachments</h3>
<div class="fc-post-modal__atts">
<a
v-for="att in post.attachments" :key="att.id"
:href="att.download_url" download
class="fc-post-modal__att"
>
<v-icon size="small" class="fc-post-modal__att-icon">mdi-paperclip</v-icon>
<span class="fc-post-modal__att-name">{{ att.original_filename }}</span>
<span class="fc-post-modal__att-size">({{ formatBytes(att.size_bytes) }})</span>
</a>
</div>
</section>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostModalStore } from '../../stores/postModal.js'
import { sanitizeHtml } from '../../utils/htmlSanitize.js'
import PostImageGrid from './PostImageGrid.vue'
const store = usePostModalStore()
const post = computed(() => store.currentPost || {})
const sortDateIso = computed(() => post.value.post_date || post.value.downloaded_at)
const absoluteDate = computed(() => sortDateIso.value
? new Date(sortDateIso.value).toLocaleString()
: '')
const relativeDate = computed(() => {
if (!sortDateIso.value) return ''
const then = new Date(sortDateIso.value).getTime()
const diff = (Date.now() - then) / 1000
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`
return new Date(sortDateIso.value).toLocaleDateString()
})
const descriptionHtml = computed(() => {
// Detail endpoint returns description_full as plain text (the
// existing service uses html_to_plain on the stored description).
// Render the plain text in <p> wrappers; the sanitizer below is
// defensive for the case where the backend ever switches to raw HTML.
const raw = post.value.description_full || post.value.description_plain
if (!raw) return ''
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
const esc = raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return esc
.split(/\n\s*\n/)
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
})
function close () {
store.close()
}
function formatBytes (n) {
if (!n) return '0 B'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
}
</script>
<style scoped>
.fc-post-modal {
background: rgb(var(--v-theme-surface));
max-height: 90vh;
display: flex;
flex-direction: column;
}
.fc-post-modal__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
font-size: 0.85rem;
color: rgb(var(--v-theme-on-surface-variant));
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.15);
}
.fc-post-modal__artist {
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-weight: 600;
}
.fc-post-modal__artist:hover { color: rgb(var(--v-theme-accent)); }
.fc-post-modal__date { white-space: nowrap; }
.fc-post-modal__meta { white-space: nowrap; }
.fc-post-modal__body {
padding: 16px;
overflow-y: auto;
flex: 1 1 auto;
}
.fc-post-modal__sec {
margin-bottom: 24px;
}
.fc-post-modal__sec:last-child { margin-bottom: 0; }
.fc-post-modal__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 24px;
font-weight: 500;
margin: 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-modal__h3 {
font-family: 'Fraunces', Georgia, serif;
font-size: 16px;
font-weight: 500;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-modal__desc {
font-size: 0.95rem;
line-height: 1.55;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-modal__desc :deep(p) { margin: 0 0 12px 0; }
.fc-post-modal__desc :deep(a) { color: rgb(var(--v-theme-accent)); }
.fc-post-modal__loading-hint {
margin-top: 8px;
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-modal__atts {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fc-post-modal__att {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid rgb(var(--v-theme-on-surface-variant));
border-radius: 999px;
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-size: 0.85rem;
}
.fc-post-modal__att:hover {
color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
}
.fc-post-modal__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
@@ -51,68 +51,6 @@
</v-col>
</v-row>
<v-divider class="my-4" />
<div class="text-subtitle-2 mb-2">Downloader (FC-3c)</div>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="local.download_rate_limit_seconds"
label="Rate limit (seconds between requests)"
type="number" step="0.5" min="0"
density="compact" hide-details @blur="save"
/>
<div class="fc-help">gallery-dl extractor.sleep. Higher = slower but safer.</div>
</v-col>
<v-col cols="12" sm="6">
<v-switch
v-model="local.download_validate_files"
label="Validate downloaded files (magic-byte check)"
density="compact" hide-details color="accent" @change="save"
/>
</v-col>
</v-row>
<v-divider class="my-4" />
<div class="text-subtitle-2 mb-2">Download scheduling (FC-3d)</div>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="local.download_schedule_default_seconds"
label="Default check interval (seconds)"
type="number" :min="60" :max="86400"
density="compact" hide-details @blur="save"
/>
<div class="fc-help">
Used when a source has no per-source or per-artist override.
Default 28800 (8 hours).
</div>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="local.download_event_retention_days"
label="Event retention (days)"
type="number" :min="1" :max="3650"
density="compact" hide-details @blur="save"
/>
<div class="fc-help">
Completed download events older than this are deleted nightly.
Default 90.
</div>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="local.download_failure_warning_threshold"
label="Failure warning threshold"
type="number" :min="1" :max="100"
density="compact" hide-details @blur="save"
/>
<div class="fc-help">
Source row badge turns red after this many consecutive
failures. Sources are never auto-disabled. Default 5.
</div>
</v-col>
</v-row>
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
{{ store.settingsError }}
</v-alert>
@@ -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 })
@@ -0,0 +1,148 @@
<template>
<v-card
:variant="hasCredential ? 'outlined' : 'flat'"
:class="['fc-cred-card', hasCredential && 'fc-cred-card--set']"
>
<v-card-title class="d-flex align-center pa-3 ga-2">
<PlatformChip :platform="platform.key" size="small" />
<span class="text-body-1">{{ platform.name }}</span>
<v-spacer />
<v-chip :color="statusColor" size="x-small" variant="tonal">
{{ statusLabel }}
</v-chip>
</v-card-title>
<v-card-text class="pa-3 pt-0">
<template v-if="hasCredential">
<div class="fc-cred-card__row">
<v-icon size="small" color="success">mdi-check-circle</v-icon>
<span>Stored · captured {{ fmtDate(credential.captured_at) }}</span>
</div>
<div v-if="credential.expires_at" class="fc-cred-card__row">
<v-icon size="small" :color="expiringSoon ? 'warning' : 'on-surface-variant'">
mdi-clock-outline
</v-icon>
<span>Expires {{ fmtDate(credential.expires_at) }}</span>
</div>
<div v-if="credential.last_verified_at" class="fc-cred-card__row">
<v-icon size="small" color="on-surface-variant">mdi-shield-check</v-icon>
<span>Last verified {{ fmtDate(credential.last_verified_at) }}</span>
</div>
</template>
<template v-else>
<div class="fc-cred-card__empty">
<v-icon size="40" color="on-surface-variant" class="fc-cred-card__empty-icon">
mdi-key-remove
</v-icon>
<p class="text-caption text-medium-emphasis">
No credential stored. Use the extension or paste a {{ platform.auth_type }} below.
</p>
</div>
</template>
<v-expansion-panels variant="accordion" class="mt-2 fc-cred-card__how">
<v-expansion-panel>
<v-expansion-panel-title class="text-caption">
How to get {{ platform.auth_type }}
</v-expansion-panel-title>
<v-expansion-panel-text class="text-caption">
<slot name="howto">
<p>{{ howToFallback }}</p>
</slot>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-card-actions class="px-3 pb-3 pt-0">
<v-spacer />
<v-btn
v-if="hasCredential"
size="small" variant="text" color="error"
@click="$emit('remove', platform)"
>
Remove
</v-btn>
<v-btn
size="small"
:variant="hasCredential ? 'outlined' : 'flat'"
:color="hasCredential ? undefined : 'accent'"
@click="$emit('replace', platform)"
>
{{ hasCredential ? 'Update' : 'Add credentials' }}
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { computed } from 'vue'
import PlatformChip from './PlatformChip.vue'
const props = defineProps({
platform: { type: Object, required: true },
credential: { type: Object, default: null },
})
defineEmits(['replace', 'remove'])
const hasCredential = computed(() => !!props.credential)
// Within 7 days = expiring soon. The backend doesn't set hard rotation
// policy yet; this just nudges the operator with a warning chip.
const expiringSoon = computed(() => {
const exp = props.credential?.expires_at
if (!exp) return false
const diff = (new Date(exp).getTime() - Date.now()) / 86400_000
return diff > 0 && diff < 7
})
const statusLabel = computed(() => {
if (!hasCredential.value) return 'Not configured'
if (expiringSoon.value) return 'Expiring soon'
return 'Active'
})
const statusColor = computed(() => {
if (!hasCredential.value) return 'grey'
if (expiringSoon.value) return 'warning'
return 'success'
})
const howToFallback = computed(() => {
if (props.platform.auth_type === 'cookies') {
return 'Use the FabledCurator browser extension on the platform page, or export cookies.txt and paste here.'
}
return 'Paste the access token from your account settings.'
})
function fmtDate(iso) {
if (!iso) return '—'
return iso.slice(0, 10)
}
</script>
<style scoped>
.fc-cred-card {
border: 1px dashed rgb(var(--v-theme-on-surface-variant) / 0.3);
background: rgb(var(--v-theme-surface));
height: 100%;
}
.fc-cred-card--set {
border-style: solid;
border-color: rgb(var(--v-theme-accent) / 0.5);
}
.fc-cred-card__row {
display: flex; gap: 6px; align-items: center;
font-size: 0.9rem;
margin-top: 4px;
color: rgb(var(--v-theme-on-surface));
}
.fc-cred-card__empty {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 12px 0;
text-align: center;
}
.fc-cred-card__empty-icon { opacity: 0.5; }
.fc-cred-card__how :deep(.v-expansion-panel) {
background: transparent;
}
</style>
@@ -0,0 +1,36 @@
<template>
<div class="fc-dl-stats">
<v-chip
v-for="s in STAT_DEFS" :key="s.key"
:color="s.color"
variant="tonal"
:prepend-icon="s.icon"
size="default"
>
{{ s.label }}
<strong class="ms-1">{{ stats[s.key] ?? 0 }}</strong>
</v-chip>
</div>
</template>
<script setup>
defineProps({
stats: { type: Object, required: true },
})
// status keys come straight from the backend ENUM
// (pending|running|ok|error|skipped); display order + icons are UI-only.
const STAT_DEFS = [
{ key: 'pending', label: 'Queued', color: 'grey', icon: 'mdi-clock-outline' },
{ key: 'running', label: 'Running', color: 'info', icon: 'mdi-progress-clock' },
{ key: 'ok', label: 'Completed', color: 'success', icon: 'mdi-check-circle' },
{ key: 'error', label: 'Failed', color: 'error', icon: 'mdi-alert-circle' },
{ key: 'skipped', label: 'Skipped', color: 'warning', icon: 'mdi-skip-next' },
]
</script>
<style scoped>
.fc-dl-stats {
display: flex; gap: 8px; flex-wrap: wrap;
}
</style>
@@ -0,0 +1,120 @@
<template>
<div class="fc-dlf">
<v-menu :close-on-content-click="false" v-model="open">
<template #activator="{ props }">
<v-btn v-bind="props" variant="outlined" prepend-icon="mdi-filter-variant">
Filter
<v-chip v-if="activeCount" size="x-small" color="accent" class="ms-2">
{{ activeCount }}
</v-chip>
</v-btn>
</template>
<v-card min-width="320" class="pa-3">
<v-select
v-model="local.status"
:items="STATUS_OPTIONS"
label="Status"
density="compact" variant="outlined" hide-details clearable
class="mb-2"
/>
<v-text-field
v-model.number="local.source_id"
label="Source ID"
density="compact" variant="outlined" hide-details clearable
type="number" min="1"
class="mb-2"
/>
<v-text-field
v-model="local.from_date"
label="From"
density="compact" variant="outlined" hide-details clearable
type="date"
class="mb-2"
/>
<v-text-field
v-model="local.to_date"
label="To"
density="compact" variant="outlined" hide-details clearable
type="date"
class="mb-3"
/>
<div class="d-flex">
<v-btn variant="text" size="small" @click="reset">Reset</v-btn>
<v-spacer />
<v-btn color="accent" size="small" @click="apply">Apply</v-btn>
</div>
</v-card>
</v-menu>
<div v-if="activeCount" class="fc-dlf__pills mt-2">
<v-chip
v-for="p in activePills" :key="p.key"
size="small" closable variant="tonal"
@click:close="clearOne(p.key)"
>
{{ p.label }}
</v-chip>
<v-btn variant="text" size="x-small" @click="reset">Clear all</v-btn>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
const props = defineProps({
modelValue: { type: Object, required: true },
})
const emit = defineEmits(['update:modelValue'])
const STATUS_OPTIONS = [
{ title: 'Queued', value: 'pending' },
{ title: 'Running', value: 'running' },
{ title: 'Completed', value: 'ok' },
{ title: 'Failed', value: 'error' },
{ title: 'Skipped', value: 'skipped' },
]
const STATUS_LABEL = Object.fromEntries(STATUS_OPTIONS.map((o) => [o.value, o.title]))
const open = ref(false)
const local = reactive({
status: props.modelValue.status ?? null,
source_id: props.modelValue.source_id ?? null,
from_date: props.modelValue.from_date ?? null,
to_date: props.modelValue.to_date ?? null,
})
watch(() => props.modelValue, (v) => Object.assign(local, v), { deep: true })
const activePills = computed(() => {
const out = []
if (local.status) out.push({ key: 'status', label: `Status: ${STATUS_LABEL[local.status] || local.status}` })
if (local.source_id) out.push({ key: 'source_id', label: `Source #${local.source_id}` })
if (local.from_date) out.push({ key: 'from_date', label: `From ${local.from_date}` })
if (local.to_date) out.push({ key: 'to_date', label: `To ${local.to_date}` })
return out
})
const activeCount = computed(() => activePills.value.length)
function apply() {
emit('update:modelValue', { ...local })
open.value = false
}
function reset() {
local.status = null
local.source_id = null
local.from_date = null
local.to_date = null
emit('update:modelValue', { ...local })
}
function clearOne(key) {
local[key] = null
emit('update:modelValue', { ...local })
}
</script>
<style scoped>
.fc-dlf__pills {
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
}
</style>
@@ -0,0 +1,123 @@
<template>
<div>
<div class="fc-dl__top">
<DownloadStatChips :stats="store.stats" />
<v-spacer />
<v-btn variant="text" icon @click="refresh">
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">Refresh</v-tooltip>
</v-btn>
<MaintenanceMenu @refresh="refresh" />
</div>
<DownloadsFilterPopover v-model="filterModel" class="fc-dl__filter" />
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
{{ String(store.error) }}
</v-alert>
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="store.events.length === 0" class="fc-dl__empty">
<p>No download events match the current filter.</p>
</div>
<div v-else>
<DownloadEventRow
v-for="e in filteredEvents" :key="e.id" :event="e"
@open="openDetail"
/>
<div class="fc-dl__sentinel">
<v-btn v-if="store.hasMore" variant="text" @click="store.loadMore()" :loading="store.loading">
Load more
</v-btn>
<span v-else class="text-caption" style="opacity: 0.5">No more events.</span>
</div>
</div>
<DownloadDetailModal
:event="store.selected"
@close="store.closeDetail()"
/>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useDownloadsStore } from '../../stores/downloads.js'
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
import DownloadStatChips from './DownloadStatChips.vue'
import MaintenanceMenu from './MaintenanceMenu.vue'
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
const route = useRoute()
const store = useDownloadsStore()
const filterModel = ref({ ...store.filter })
async function refresh() {
await Promise.all([
store.loadFirst(),
store.loadStats(24),
])
}
onMounted(() => {
if (route.query.source_id) {
filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) }
}
refresh()
})
// Client-side date filter on the loaded page (avoids a backend round-trip
// for the date pickers; the existing /api/downloads endpoint can grow
// these as proper query params later if a UX need shows up).
const filteredEvents = computed(() => {
let arr = store.events
const from = filterModel.value.from_date
const to = filterModel.value.to_date
if (from) {
const fromTs = new Date(from).getTime()
arr = arr.filter((e) => new Date(e.started_at).getTime() >= fromTs)
}
if (to) {
const toTs = new Date(to).getTime() + 24 * 3600 * 1000 - 1
arr = arr.filter((e) => new Date(e.started_at).getTime() <= toTs)
}
return arr
})
watch(filterModel, async (m) => {
await store.applyFilter({
status: m.status,
source_id: m.source_id || null,
from_date: m.from_date,
to_date: m.to_date,
})
await store.loadStats(24)
}, { deep: true })
async function openDetail(id) {
await store.loadOne(id)
}
</script>
<style scoped>
.fc-dl__top {
display: flex; gap: 8px; align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.fc-dl__filter { margin-bottom: 12px; }
.fc-dl__loading, .fc-dl__empty {
display: flex; justify-content: center; padding: 3rem 0;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-dl__sentinel {
display: flex; justify-content: center; padding: 1rem 0;
}
</style>
@@ -0,0 +1,62 @@
<template>
<v-menu>
<template #activator="{ props }">
<v-btn v-bind="props" variant="outlined" prepend-icon="mdi-wrench" append-icon="mdi-chevron-down">
Maintenance
</v-btn>
</template>
<v-list density="compact">
<v-list-item
prepend-icon="mdi-refresh"
title="Retry failed"
subtitle="Re-enqueue every failed import task"
@click="onRetry"
/>
<v-list-item
prepend-icon="mdi-broom"
title="Clear stuck"
subtitle="Mark long-running import tasks failed and finalize their batch"
@click="onClear"
/>
<v-list-item
:disabled="true"
prepend-icon="mdi-download-box"
title="Export failed logs"
subtitle="CSV dump — v2"
>
<v-tooltip activator="parent" location="start">Deferred to a future release</v-tooltip>
</v-list-item>
</v-list>
</v-menu>
</template>
<script setup>
import { useImportStore } from '../../stores/import.js'
const emit = defineEmits(['refresh'])
const importStore = useImportStore()
async function onRetry() {
try {
await importStore.retryFailed()
globalThis.window?.__fcToast?.({ text: 'Retry queued', type: 'success' })
emit('refresh')
} catch (e) {
globalThis.window?.__fcToast?.({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' })
}
}
async function onClear() {
try {
const body = await importStore.clearStuck()
const n = body?.cleared ?? 0
globalThis.window?.__fcToast?.({
text: n ? `Cleared ${n} stuck task${n === 1 ? '' : 's'}` : 'Nothing stuck',
type: 'success',
})
emit('refresh')
} catch (e) {
globalThis.window?.__fcToast?.({ text: `Clear failed: ${e?.detail || e?.message || e}`, type: 'error' })
}
}
</script>
@@ -0,0 +1,25 @@
<template>
<v-chip
:color="color"
:size="size"
:variant="variant"
:prepend-icon="icon"
>
{{ label }}
</v-chip>
</template>
<script setup>
import { computed } from 'vue'
import { platformColor, platformIcon, platformLabel } from '../../utils/platformColor.js'
const props = defineProps({
platform: { type: String, required: true },
size: { type: String, default: 'small' },
variant: { type: String, default: 'tonal' },
})
const color = computed(() => platformColor(props.platform))
const icon = computed(() => platformIcon(props.platform))
const label = computed(() => platformLabel(props.platform))
</script>
@@ -0,0 +1,196 @@
<template>
<div>
<ExtensionKeyBar class="mb-4" />
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
{{ String(credentialsStore.error) }}
</v-alert>
<h3 class="text-h6 mb-3">Platform credentials</h3>
<v-row>
<v-col
v-for="p in platformsStore.list"
:key="p.key"
cols="12" md="6"
>
<CredentialCard
:platform="p"
:credential="credentialsStore.byPlatform.get(p.key) || null"
@replace="openUpload"
@remove="confirmRemove"
/>
</v-col>
</v-row>
<h3 class="text-h6 mb-3 mt-6">Downloader</h3>
<v-card variant="outlined">
<v-card-text v-if="importStore.settings">
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="dl.download_rate_limit_seconds"
label="Rate limit (seconds between requests)"
type="number" step="0.5" min="0"
density="compact" hide-details
@blur="saveDownloader"
/>
<div class="fc-help">gallery-dl extractor.sleep. Higher = slower but safer.</div>
</v-col>
<v-col cols="12" sm="6">
<v-switch
v-model="dl.download_validate_files"
label="Validate downloaded files (magic-byte check)"
density="compact" hide-details color="accent"
@change="saveDownloader"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-else>
<v-skeleton-loader type="paragraph" />
</v-card-text>
</v-card>
<h3 class="text-h6 mb-3 mt-6">Schedule defaults</h3>
<v-card variant="outlined">
<v-card-text v-if="importStore.settings">
<v-row>
<v-col cols="12" sm="4">
<v-text-field
v-model.number="dl.download_schedule_default_seconds"
label="Default check interval (seconds)"
type="number" :min="60" :max="86400"
density="compact" hide-details
@blur="saveDownloader"
/>
<div class="fc-help">
Used when a source has no per-source or per-artist override.
Default 28800 (8 hours).
</div>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
v-model.number="dl.download_event_retention_days"
label="Event retention (days)"
type="number" :min="1" :max="3650"
density="compact" hide-details
@blur="saveDownloader"
/>
<div class="fc-help">
Completed download events older than this are deleted nightly.
Default 90.
</div>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
v-model.number="dl.download_failure_warning_threshold"
label="Failure warning threshold"
type="number" :min="1" :max="100"
density="compact" hide-details
@blur="saveDownloader"
/>
<div class="fc-help">
Source row badge turns red after this many consecutive
failures. Sources are never auto-disabled. Default 5.
</div>
</v-col>
</v-row>
<v-alert v-if="importStore.settingsError" type="error" variant="tonal" class="mt-2" closable>
{{ importStore.settingsError }}
</v-alert>
</v-card-text>
<v-card-text v-else>
<v-skeleton-loader type="paragraph" />
</v-card-text>
</v-card>
<CredentialUploadDialog
v-model="showUpload"
:platform="uploadPlatform"
@saved="onSaved"
/>
<v-dialog v-model="removeConfirm.open" max-width="420">
<v-card>
<v-card-title>Delete {{ removeConfirm.platform?.name }} credential?</v-card-title>
<v-card-text>
The encrypted credential will be removed permanently. You'll need to
re-upload to use this platform again.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="removeConfirm.open = false">Cancel</v-btn>
<v-btn color="error" variant="flat" @click="doRemove">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from 'vue'
import { usePlatformsStore } from '../../stores/platforms.js'
import { useCredentialsStore } from '../../stores/credentials.js'
import { useImportStore } from '../../stores/import.js'
import ExtensionKeyBar from '../credentials/ExtensionKeyBar.vue'
import CredentialUploadDialog from '../credentials/CredentialUploadDialog.vue'
import CredentialCard from './CredentialCard.vue'
const platformsStore = usePlatformsStore()
const credentialsStore = useCredentialsStore()
const importStore = useImportStore()
const showUpload = ref(false)
const uploadPlatform = ref(null)
const removeConfirm = reactive({ open: false, platform: null })
const dl = reactive({
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(() => importStore.settings, (s) => { if (s) Object.assign(dl, s) }, { immediate: true })
onMounted(async () => {
await Promise.all([
platformsStore.loadAll(),
credentialsStore.loadAll(),
importStore.loadSettings(),
])
})
function openUpload(platform) {
uploadPlatform.value = platform
showUpload.value = true
}
async function onSaved() {
await credentialsStore.loadAll()
}
function confirmRemove(platform) {
removeConfirm.platform = platform
removeConfirm.open = true
}
async function doRemove() {
await credentialsStore.remove(removeConfirm.platform.key)
removeConfirm.open = false
await credentialsStore.loadAll()
}
async function saveDownloader() {
await importStore.patchSettings({ ...dl })
}
</script>
<style scoped>
.fc-help {
font-size: 12px;
color: rgb(var(--v-theme-on-surface-variant));
margin-top: 2px;
}
</style>
@@ -0,0 +1,521 @@
<template>
<div>
<div class="fc-subs__bar">
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
Add subscription
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-account-plus" @click="showArtistDialog = true">
New artist
</v-btn>
<v-spacer />
<v-select
v-model="statusFilter"
:items="STATUS_OPTIONS"
density="compact" variant="outlined" hide-details
style="max-width: 180px"
/>
<v-text-field
v-model="search"
density="compact" variant="outlined" hide-details clearable
prepend-inner-icon="mdi-magnify"
placeholder="Search subscriptions"
style="max-width: 320px"
/>
</div>
<v-slide-y-transition>
<v-card
v-if="selected.length"
variant="tonal" color="info"
class="fc-subs__bulk mb-3"
>
<v-card-text class="d-flex align-center pa-3 ga-3">
<span class="text-body-2">
{{ selected.length }} selected
</span>
<v-spacer />
<v-btn size="small" variant="text" prepend-icon="mdi-toggle-switch" @click="bulkSetEnabled(true)">
Enable all
</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-toggle-switch-off-outline" @click="bulkSetEnabled(false)">
Disable all
</v-btn>
<v-btn size="small" variant="text" color="error" prepend-icon="mdi-delete" @click="bulkDelete">
Delete
</v-btn>
<v-btn size="small" variant="text" @click="selected = []">Clear</v-btn>
</v-card-text>
</v-card>
</v-slide-y-transition>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
<div v-if="store.loading && groups.length === 0" class="fc-subs__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="filteredGroups.length === 0" class="fc-subs__empty">
<p v-if="groups.length === 0">No subscriptions yet. Add your first artist.</p>
<p v-else>No subscriptions match the current filter.</p>
</div>
<v-card v-else class="fc-subs__card" variant="outlined">
<v-data-table
:headers="headers"
:items="filteredGroups"
item-value="key"
v-model="selected"
v-model:expanded="expanded"
:items-per-page="50"
:items-per-page-options="ITEMS_PER_PAGE_OPTIONS"
density="comfortable"
hover show-select show-expand
@click:row="onRowClick"
>
<template #item.name="{ item }">
<span class="fc-subs__name">{{ item.artist.name }}</span>
</template>
<template #item.platforms="{ item }">
<div class="fc-subs__chips">
<PlatformChip
v-for="p in item.platforms" :key="p"
:platform="p" size="x-small"
/>
</div>
</template>
<template #item.sources_count="{ item }">
<v-chip size="x-small" variant="tonal" label>
{{ item.sources.length }}
</v-chip>
</template>
<template #item.health="{ item }">
<SourceHealthDot
v-if="item.worstSource"
:source="item.worstSource"
:warning-threshold="failureThreshold"
/>
<span v-else class="fc-subs__zero"></span>
</template>
<template #item.last_activity="{ item }">
<span class="fc-subs__when">{{ formatRelative(item.lastActivity) }}</span>
</template>
<template #item.actions="{ item }">
<v-btn
icon size="small" variant="text"
:loading="anyChecking(item.sources)"
@click.stop="checkAll(item)"
>
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">Check all sources</v-tooltip>
</v-btn>
<v-btn icon size="small" variant="text" @click.stop="openAddSource(item.artist)">
<v-icon>mdi-plus</v-icon>
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
</v-btn>
<v-btn
icon size="small" variant="text"
:to="`/posts?artist_id=${item.artist.id}`" @click.stop
>
<v-icon>mdi-rss</v-icon>
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
</v-btn>
<v-btn
icon size="small" variant="text"
:to="`/artist/${item.artist.slug}`" @click.stop
>
<v-icon>mdi-account</v-icon>
<v-tooltip activator="parent" location="top">Open artist page</v-tooltip>
</v-btn>
</template>
<template #expanded-row="{ columns, item }">
<tr class="fc-subs__sources-row">
<td :colspan="columns.length" class="fc-subs__sources-cell">
<v-table density="compact" class="fc-subs__sources-table">
<thead>
<tr>
<th></th>
<th>Platform</th>
<th>URL</th>
<th>Enabled</th>
<th>Last check</th>
<th>Next check</th>
<th>Errors</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<SourceRow
v-for="s in item.sources" :key="s.id" :source="s"
:checking="store.checkingIds.has(s.id)"
:warning-threshold="failureThreshold"
@edit="openEditSource"
@remove="removeSource"
@toggle="toggleSourceEnabled"
@check="onCheck"
/>
<tr v-if="item.sources.length === 0">
<td colspan="8" class="fc-subs__sources-empty">
No sources yet. Click + to add one.
</td>
</tr>
</tbody>
</v-table>
</td>
</tr>
</template>
</v-data-table>
</v-card>
<SourceFormDialog
v-model="showSourceDialog"
:source="editingSource"
:initial-artist="editingArtist"
@saved="onSourceSaved"
/>
<ArtistCreateDialog v-model="showArtistDialog" @created="onArtistCreated" />
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSourcesStore } from '../../stores/sources.js'
import { usePlatformsStore } from '../../stores/platforms.js'
import { useImportStore } from '../../stores/import.js'
import SourceRow from './SourceRow.vue'
import SourceHealthDot from './SourceHealthDot.vue'
import SourceFormDialog from './SourceFormDialog.vue'
import ArtistCreateDialog from './ArtistCreateDialog.vue'
import PlatformChip from './PlatformChip.vue'
const ITEMS_PER_PAGE_OPTIONS = [
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: 100, title: '100' },
{ value: -1, title: 'All' },
]
const STATUS_OPTIONS = [
{ title: 'All status', value: 'all' },
{ title: 'Enabled', value: 'enabled' },
{ title: 'Disabled', value: 'disabled' },
{ title: 'Has errors', value: 'errors' },
{ title: 'Stale', value: 'stale' },
]
const route = useRoute()
const router = useRouter()
const store = useSourcesStore()
const platformsStore = usePlatformsStore()
const importStore = useImportStore()
const search = ref('')
const statusFilter = ref('all')
const expanded = ref([])
const selected = ref([])
const showSourceDialog = ref(false)
const editingSource = ref(null)
const editingArtist = ref(null)
const showArtistDialog = ref(false)
const artistFilter = computed(() => {
const raw = route.query.artist_id
return raw == null ? null : Number(raw)
})
const failureThreshold = computed(() =>
importStore.settings?.download_failure_warning_threshold ?? 5,
)
async function refresh() {
await store.loadAll()
await platformsStore.loadAll()
if (!importStore.settings) await importStore.loadSettings()
}
onMounted(() => {
refresh()
if (artistFilter.value != null) {
expanded.value = [`artist-${artistFilter.value}`]
}
})
watch(() => route.query.artist_id, refresh)
const headers = [
{ title: 'Subscription', key: 'name', sortable: true, align: 'start' },
{ title: 'Platforms', key: 'platforms', sortable: false, align: 'start', width: 240 },
{ title: 'Sources', key: 'sources_count', sortable: true, align: 'start', width: 90 },
{ title: 'Health', key: 'health', sortable: false, align: 'start', width: 80 },
{ title: 'Last activity',key: 'last_activity', sortable: true, align: 'start', width: 140 },
{ title: 'Actions', key: 'actions', sortable: false, align: 'end', width: 200 },
]
const groups = computed(() => {
const all = store.sourcesByArtistGrouped()
return all.map((g) => {
const worstSource = pickWorstSource(g.sources, failureThreshold.value)
const lastActivity = pickLastActivity(g.sources)
const platforms = [...new Set(g.sources.map((s) => s.platform).filter(Boolean))]
return {
key: `artist-${g.artist.id}`,
artist: g.artist,
sources: g.sources,
sources_count: g.sources.length,
platforms,
worstSource,
lastActivity,
name: g.artist.name,
last_activity: lastActivity ?? '',
}
})
})
const filteredGroups = computed(() => {
let arr = groups.value
if (artistFilter.value != null) {
arr = arr.filter((g) => g.artist.id === artistFilter.value)
}
if (statusFilter.value !== 'all') {
arr = arr.filter((g) => groupMatchesStatus(g, statusFilter.value))
}
const q = search.value?.trim().toLowerCase()
if (q) {
arr = arr.filter(
(g) =>
g.artist.name.toLowerCase().includes(q) ||
g.sources.some(
(s) =>
(s.url || '').toLowerCase().includes(q) ||
(s.platform || '').toLowerCase().includes(q),
),
)
}
return arr
})
function groupMatchesStatus(g, status) {
if (status === 'enabled') return g.sources.some((s) => s.enabled)
if (status === 'disabled') return g.sources.every((s) => !s.enabled)
if (status === 'errors') return g.sources.some((s) => (s.consecutive_failures || 0) > 0)
if (status === 'stale') return g.sources.some((s) => !s.last_checked_at)
return true
}
function pickLastActivity(sources) {
let max = null
for (const s of sources) {
if (s.last_checked_at && (!max || s.last_checked_at > max)) max = s.last_checked_at
}
return max
}
function pickWorstSource(sources, threshold) {
if (!sources || sources.length === 0) return null
function level(s) {
if (!s.last_checked_at) return 0
const f = s.consecutive_failures || 0
if (f === 0) return 1
if (f < threshold) return 2
return 3
}
return sources.reduce((worst, s) => (level(s) > level(worst) ? s : worst), sources[0])
}
function formatRelative(iso) {
if (!iso) return 'Never'
const then = new Date(iso).getTime()
const diff = (Date.now() - then) / 1000
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
function onRowClick(_evt, { item }) {
const key = item.key
const idx = expanded.value.indexOf(key)
if (idx === -1) expanded.value = [...expanded.value, key]
else expanded.value = expanded.value.filter((k) => k !== key)
}
function openAddSource(artist) {
editingSource.value = null
editingArtist.value = artist
showSourceDialog.value = true
}
function openEditSource(source) {
editingSource.value = source
editingArtist.value = {
id: source.artist_id, name: source.artist_name, slug: source.artist_slug,
}
showSourceDialog.value = true
}
async function removeSource(source) {
await store.remove(source.id, source.artist_id)
await refresh()
}
async function toggleSourceEnabled({ source, enabled }) {
await store.update(source.id, { enabled }, source.artist_id)
await refresh()
}
async function onSourceSaved() {
showSourceDialog.value = false
await refresh()
}
function onArtistCreated(artist) {
showArtistDialog.value = false
openAddSource(artist)
}
async function onCheck(source) {
try {
const body = await store.checkNow(source.id)
globalThis.window?.__fcToast?.({
text: `Check enqueued (event #${body.download_event_id})`,
type: 'success',
})
} catch (e) {
if (e?.body?.download_event_id) {
globalThis.window?.__fcToast?.({
text: 'Already running — see Downloads',
type: 'info',
})
router.push({ path: '/subscriptions', query: { tab: 'downloads', source_id: source.id } })
} else {
globalThis.window?.__fcToast?.({
text: `Check failed: ${e?.detail || e?.message || e}`,
type: 'error',
})
}
}
}
async function checkAll(group) {
let ok = 0
let conflict = 0
for (const s of group.sources) {
if (!s.enabled) continue
try {
await store.checkNow(s.id)
ok += 1
} catch (e) {
if (e?.body?.download_event_id) conflict += 1
}
}
const parts = []
if (ok) parts.push(`${ok} queued`)
if (conflict) parts.push(`${conflict} already running`)
globalThis.window?.__fcToast?.({
text: parts.join(', ') || 'Nothing to check (no enabled sources)',
type: 'info',
})
}
function anyChecking(sources) {
return sources.some((s) => store.checkingIds.has(s.id))
}
function resolveSelectedGroups() {
return groups.value.filter((g) => selected.value.includes(g.key))
}
async function bulkSetEnabled(enabled) {
const groups = resolveSelectedGroups()
let changed = 0
for (const g of groups) {
for (const s of g.sources) {
if (s.enabled === enabled) continue
try {
await store.update(s.id, { enabled }, s.artist_id)
changed += 1
} catch { /* keep going */ }
}
}
await refresh()
globalThis.window?.__fcToast?.({
text: `${changed} source${changed === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}`,
type: 'success',
})
selected.value = []
}
async function bulkDelete() {
const groups = resolveSelectedGroups()
const total = groups.reduce((n, g) => n + g.sources.length, 0)
if (!globalThis.window?.confirm(
`Delete ${total} source${total === 1 ? '' : 's'} across ${groups.length} subscription${groups.length === 1 ? '' : 's'}? Artist rows remain.`,
)) return
let deleted = 0
for (const g of groups) {
for (const s of g.sources) {
try {
await store.remove(s.id, s.artist_id)
deleted += 1
} catch { /* keep going */ }
}
}
await refresh()
globalThis.window?.__fcToast?.({
text: `${deleted} source${deleted === 1 ? '' : 's'} deleted`,
type: 'success',
})
selected.value = []
}
</script>
<style scoped>
.fc-subs__bar {
display: flex; gap: 0.75rem; align-items: center;
padding-bottom: 1rem;
flex-wrap: wrap;
}
.fc-subs__loading, .fc-subs__empty {
display: flex; justify-content: center; padding: 2rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-subs__card {
background: rgb(var(--v-theme-surface));
}
.fc-subs__name { font-weight: 600; }
.fc-subs__chips {
display: flex; flex-wrap: wrap; gap: 4px;
}
.fc-subs__when {
color: rgb(var(--v-theme-on-surface-variant));
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.fc-subs__zero {
color: rgb(var(--v-theme-on-surface-variant));
opacity: 0.6;
}
.fc-subs__sources-row td {
padding: 0 !important;
background: rgb(var(--v-theme-surface-light));
}
.fc-subs__sources-cell {
padding-left: 2rem !important;
border-top: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.15);
}
.fc-subs__sources-table {
background: transparent !important;
}
.fc-subs__sources-empty {
color: rgb(var(--v-theme-on-surface-variant));
text-align: center;
padding: 1rem;
}
.fc-subs__bulk { border-radius: 8px; }
</style>
+8 -4
View File
@@ -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
+12 -3
View File
@@ -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,
}
})
+3 -3
View File
@@ -11,9 +11,9 @@ export const useModalStore = defineStore('modal', () => {
const error = ref(null)
// Post-scoped cycle. When set, prev/next cycles within this array
// (used by PostModal's PostImageGrid clicks). When null, prev/next
// falls back to current.value.neighbors (the gallery-store-driven
// /api/gallery/image/<id> neighbors).
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
// null, prev/next falls back to current.value.neighbors (the
// gallery-store-driven /api/gallery/image/<id> neighbors).
const postImageIds = ref(null)
const postImageIndex = ref(0)
-39
View File
@@ -1,39 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { usePostsStore } from './posts.js'
export const usePostModalStore = defineStore('postModal', () => {
const postsStore = usePostsStore()
// Feed-shape on open; upgraded to detail shape (full description +
// uncapped thumbnails) once getPostFull resolves.
const currentPost = ref(null)
const detailLoaded = ref(false)
const error = ref(null)
const isOpen = computed(() => currentPost.value !== null)
async function open (post) {
currentPost.value = post
detailLoaded.value = false
error.value = null
try {
const detail = await postsStore.getPostFull(post.id)
if (currentPost.value?.id === post.id) {
currentPost.value = detail
detailLoaded.value = true
}
} catch (e) {
// Keep feed-shape data; PostModal can still render title + truncated
// description + the up-to-6 feed thumbnails. Just surface the error.
error.value = e.message
}
}
function close () {
currentPost.value = null
detailLoaded.value = false
error.value = null
}
return { currentPost, detailLoaded, error, isOpen, open, close }
})
+43
View File
@@ -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
}
-79
View File
@@ -1,79 +0,0 @@
<template>
<v-container fluid class="pt-2 pb-6">
<ExtensionKeyBar />
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
{{ String(credentialsStore.error) }}
</v-alert>
<PlatformCredentialRow
v-for="p in platformsStore.list"
:key="p.key"
:platform="p"
:credential="credentialsStore.byPlatform.get(p.key) || null"
@replace="openUpload"
@remove="confirmRemove"
/>
<CredentialUploadDialog
v-model="showUpload"
:platform="uploadPlatform"
@saved="onSaved"
/>
<v-dialog v-model="removeConfirm.open" max-width="420">
<v-card>
<v-card-title>Delete {{ removeConfirm.platform?.name }} credential?</v-card-title>
<v-card-text>
The encrypted credential will be removed permanently. You'll need to
re-upload to use this platform again.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="removeConfirm.open = false">Cancel</v-btn>
<v-btn color="error" variant="flat" @click="doRemove">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { usePlatformsStore } from '../stores/platforms.js'
import { useCredentialsStore } from '../stores/credentials.js'
import ExtensionKeyBar from '../components/credentials/ExtensionKeyBar.vue'
import PlatformCredentialRow from '../components/credentials/PlatformCredentialRow.vue'
import CredentialUploadDialog from '../components/credentials/CredentialUploadDialog.vue'
const platformsStore = usePlatformsStore()
const credentialsStore = useCredentialsStore()
const showUpload = ref(false)
const uploadPlatform = ref(null)
const removeConfirm = reactive({ open: false, platform: null })
onMounted(async () => {
await Promise.all([platformsStore.loadAll(), credentialsStore.loadAll()])
})
function openUpload(platform) {
uploadPlatform.value = platform
showUpload.value = true
}
async function onSaved() {
await credentialsStore.loadAll()
}
function confirmRemove(platform) {
removeConfirm.platform = platform
removeConfirm.open = true
}
async function doRemove() {
await credentialsStore.remove(removeConfirm.platform.key)
removeConfirm.open = false
await credentialsStore.loadAll()
}
</script>
-70
View File
@@ -1,70 +0,0 @@
<template>
<v-container fluid class="pt-2 pb-6">
<FilterPills v-model="pill" />
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
{{ String(store.error) }}
</v-alert>
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="store.events.length === 0" class="fc-dl__empty">
<p>No download events yet. Trigger a check from /subscriptions.</p>
</div>
<div v-else>
<DownloadEventRow
v-for="e in store.events" :key="e.id" :event="e"
@open="openDetail"
/>
<div class="fc-dl__sentinel">
<v-btn v-if="store.hasMore" variant="text" @click="store.loadMore()" :loading="store.loading">
Load more
</v-btn>
<span v-else class="text-caption" style="opacity: 0.5">No more events.</span>
</div>
</div>
<DownloadDetailModal
:event="store.selected"
@close="store.closeDetail()"
/>
</v-container>
</template>
<script setup>
import { onMounted, ref, watch } from 'vue'
import { useDownloadsStore } from '../stores/downloads.js'
import FilterPills from '../components/downloads/FilterPills.vue'
import DownloadEventRow from '../components/downloads/DownloadEventRow.vue'
import DownloadDetailModal from '../components/downloads/DownloadDetailModal.vue'
const store = useDownloadsStore()
const pill = ref('all')
onMounted(() => store.loadFirst())
watch(pill, async (v) => {
// 'quarantined' has no API filter yet — defer until the API grows
// (a metadata.run_stats.quarantined_count > 0 filter, FC-3d territory).
// For now route it the same as 'all' but keep the chip for discoverability.
const statusMap = { all: null, running: 'running', error: 'error', quarantined: null }
await store.applyFilter({ status: statusMap[v] })
})
async function openDetail(id) {
await store.loadOne(id)
}
</script>
<style scoped>
.fc-dl__loading, .fc-dl__empty {
display: flex; justify-content: center; padding: 3rem 0;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-dl__sentinel {
display: flex; justify-content: center; padding: 1rem 0;
}
</style>
+47 -392
View File
@@ -1,416 +1,71 @@
<template>
<v-container fluid class="pt-2 pb-6">
<div class="fc-subs__bar">
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
Add subscription
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-account-plus" @click="showArtistDialog = true">
New artist
</v-btn>
<v-spacer />
<v-text-field
v-model="search"
density="compact" variant="outlined" hide-details clearable
prepend-inner-icon="mdi-magnify"
placeholder="Search subscriptions"
style="max-width: 320px"
/>
</div>
<v-tabs
v-model="tab"
align-tabs="start"
color="accent"
density="compact"
class="fc-subs-tabs"
>
<v-tab value="subscriptions">
<v-icon start>mdi-account-multiple-check</v-icon>
Subscriptions
</v-tab>
<v-tab value="downloads">
<v-icon start>mdi-cloud-download</v-icon>
Downloads
</v-tab>
<v-tab value="settings">
<v-icon start>mdi-cog</v-icon>
Settings
</v-tab>
</v-tabs>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mt-4">
{{ String(store.error) }}
</v-alert>
<div v-if="store.loading && groups.length === 0" class="fc-subs__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="filteredGroups.length === 0" class="fc-subs__empty">
<p v-if="groups.length === 0">No subscriptions yet. Add your first artist.</p>
<p v-else>No subscriptions match "{{ search }}".</p>
</div>
<v-card v-else class="fc-subs__card" variant="outlined">
<v-data-table
:headers="headers"
:items="filteredGroups"
item-value="key"
v-model:expanded="expanded"
:items-per-page="50"
:items-per-page-options="ITEMS_PER_PAGE_OPTIONS"
density="comfortable"
hover
show-expand
@click:row="onRowClick"
>
<template #item.name="{ item }">
<span class="fc-subs__name">{{ item.artist.name }}</span>
</template>
<template #item.sources_count="{ item }">
<v-chip size="x-small" variant="tonal" label>
{{ item.sources.length }} source{{ item.sources.length === 1 ? '' : 's' }}
</v-chip>
</template>
<template #item.health="{ item }">
<SourceHealthDot
v-if="item.worstSource"
:source="item.worstSource"
:warning-threshold="failureThreshold"
/>
<span v-else class="fc-subs__zero"></span>
</template>
<template #item.last_activity="{ item }">
<span class="fc-subs__when">
{{ formatRelative(item.lastActivity) }}
</span>
</template>
<template #item.actions="{ item }">
<v-btn
icon size="small" variant="text"
:loading="anyChecking(item.sources)"
@click.stop="checkAll(item)"
>
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">Check all sources</v-tooltip>
</v-btn>
<v-btn
icon size="small" variant="text"
@click.stop="openAddSource(item.artist)"
>
<v-icon>mdi-plus</v-icon>
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
</v-btn>
<v-btn
icon size="small" variant="text"
:to="`/posts?artist_id=${item.artist.id}`"
@click.stop
>
<v-icon>mdi-rss</v-icon>
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
</v-btn>
<v-btn
icon size="small" variant="text"
:to="`/artist/${item.artist.slug}`"
@click.stop
>
<v-icon>mdi-account</v-icon>
<v-tooltip activator="parent" location="top">Open artist page</v-tooltip>
</v-btn>
</template>
<template #expanded-row="{ columns, item }">
<tr class="fc-subs__sources-row">
<td :colspan="columns.length" class="fc-subs__sources-cell">
<v-table density="compact" class="fc-subs__sources-table">
<thead>
<tr>
<th></th>
<th>Platform</th>
<th>URL</th>
<th>Enabled</th>
<th>Last check</th>
<th>Next check</th>
<th>Errors</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<SourceRow
v-for="s in item.sources" :key="s.id" :source="s"
:checking="store.checkingIds.has(s.id)"
:warning-threshold="failureThreshold"
@edit="openEditSource"
@remove="removeSource"
@toggle="toggleSourceEnabled"
@check="onCheck"
/>
<tr v-if="item.sources.length === 0">
<td colspan="8" class="fc-subs__sources-empty">
No sources yet. Click + to add one.
</td>
</tr>
</tbody>
</v-table>
</td>
</tr>
</template>
</v-data-table>
</v-card>
<SourceFormDialog
v-model="showSourceDialog"
:source="editingSource"
:initial-artist="editingArtist"
@saved="onSourceSaved"
/>
<ArtistCreateDialog v-model="showArtistDialog" @created="onArtistCreated" />
<v-window v-model="tab" class="mt-4">
<v-window-item value="subscriptions">
<SubscriptionsTab />
</v-window-item>
<v-window-item value="downloads">
<DownloadsTab />
</v-window-item>
<v-window-item value="settings">
<SettingsTab />
</v-window-item>
</v-window>
</v-container>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSourcesStore } from '../stores/sources.js'
import { usePlatformsStore } from '../stores/platforms.js'
import { useImportStore } from '../stores/import.js'
import SourceRow from '../components/subscriptions/SourceRow.vue'
import SourceHealthDot from '../components/subscriptions/SourceHealthDot.vue'
import SourceFormDialog from '../components/subscriptions/SourceFormDialog.vue'
import ArtistCreateDialog from '../components/subscriptions/ArtistCreateDialog.vue'
const ITEMS_PER_PAGE_OPTIONS = [
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: 100, title: '100' },
{ value: -1, title: 'All' },
]
import SubscriptionsTab from '../components/subscriptions/SubscriptionsTab.vue'
import DownloadsTab from '../components/subscriptions/DownloadsTab.vue'
import SettingsTab from '../components/subscriptions/SettingsTab.vue'
const VALID_TABS = ['subscriptions', 'downloads', 'settings']
const route = useRoute()
const router = useRouter()
const store = useSourcesStore()
const platformsStore = usePlatformsStore()
const importStore = useImportStore()
const search = ref('')
const expanded = ref([])
const showSourceDialog = ref(false)
const editingSource = ref(null)
const editingArtist = ref(null)
const showArtistDialog = ref(false)
const artistFilter = computed(() => {
const raw = route.query.artist_id
return raw == null ? null : Number(raw)
})
const failureThreshold = computed(() =>
importStore.settings?.download_failure_warning_threshold ?? 5
const tab = ref(
VALID_TABS.includes(route.query.tab) ? route.query.tab : 'subscriptions',
)
async function refresh() {
await store.loadAll()
await platformsStore.loadAll()
if (!importStore.settings) await importStore.loadSettings()
}
onMounted(() => {
refresh()
if (artistFilter.value != null) {
// Pre-expand the row that the deep-link refers to.
expanded.value = [`artist-${artistFilter.value}`]
}
})
watch(() => route.query.artist_id, refresh)
const headers = [
{ title: 'Subscription', key: 'name', sortable: true, align: 'start' },
{ title: 'Sources', key: 'sources_count', sortable: true, align: 'start', width: 110 },
{ title: 'Health', key: 'health', sortable: false, align: 'start', width: 80 },
{ title: 'Last activity',key: 'last_activity', sortable: true, align: 'start', width: 140 },
{ title: 'Actions', key: 'actions', sortable: false, align: 'end', width: 200 },
]
const groups = computed(() => {
const all = store.sourcesByArtistGrouped()
return all.map(g => {
const worstSource = pickWorstSource(g.sources, failureThreshold.value)
const lastActivity = pickLastActivity(g.sources)
return {
key: `artist-${g.artist.id}`,
artist: g.artist,
sources: g.sources,
sources_count: g.sources.length,
worstSource,
lastActivity,
name: g.artist.name, // for sortable column
last_activity: lastActivity ?? '', // for sortable column
}
})
watch(tab, (t) => {
if (route.query.tab === t) return
router.replace({ query: { ...route.query, tab: t } })
})
const filteredGroups = computed(() => {
let arr = groups.value
if (artistFilter.value != null) {
arr = arr.filter(g => g.artist.id === artistFilter.value)
watch(() => route.query.tab, (q) => {
if (q && VALID_TABS.includes(q) && tab.value !== q) {
tab.value = q
}
const q = search.value?.trim().toLowerCase()
if (q) {
arr = arr.filter(g =>
g.artist.name.toLowerCase().includes(q)
|| g.sources.some(s => (s.url || '').toLowerCase().includes(q)
|| (s.platform || '').toLowerCase().includes(q))
)
}
return arr
})
function pickLastActivity(sources) {
let max = null
for (const s of sources) {
if (s.last_checked_at && (!max || s.last_checked_at > max)) max = s.last_checked_at
}
return max
}
function pickWorstSource(sources, threshold) {
// Health order (worst → best): critical, warning, healthy, unchecked.
// Picks the source with the worst level so the row's dot reflects the
// worst-case state. Within a level, the first is fine.
if (!sources || sources.length === 0) return null
function level(s) {
if (!s.last_checked_at) return 0 // unchecked
const f = s.consecutive_failures || 0
if (f === 0) return 1 // healthy
if (f < threshold) return 2 // warning
return 3 // critical
}
return sources.reduce((worst, s) =>
level(s) > level(worst) ? s : worst,
sources[0],
)
}
function formatRelative(iso) {
if (!iso) return 'Never'
const then = new Date(iso).getTime()
const diff = (Date.now() - then) / 1000
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
function onRowClick(_evt, { item, internalItem }) {
// Toggle expansion on row click (in addition to the chevron).
const key = item.key
const idx = expanded.value.indexOf(key)
if (idx === -1) expanded.value = [...expanded.value, key]
else expanded.value = expanded.value.filter(k => k !== key)
}
function openAddSource(artist) {
editingSource.value = null
editingArtist.value = artist
showSourceDialog.value = true
}
function openEditSource(source) {
editingSource.value = source
editingArtist.value = { id: source.artist_id, name: source.artist_name, slug: source.artist_slug }
showSourceDialog.value = true
}
async function removeSource(source) {
await store.remove(source.id, source.artist_id)
await refresh()
}
async function toggleSourceEnabled({ source, enabled }) {
await store.update(source.id, { enabled }, source.artist_id)
await refresh()
}
async function onSourceSaved() {
showSourceDialog.value = false
await refresh()
}
function onArtistCreated(artist) {
showArtistDialog.value = false
openAddSource(artist)
}
async function onCheck(source) {
try {
const body = await store.checkNow(source.id)
globalThis.window?.__fcToast?.({
text: `Check enqueued (event #${body.download_event_id})`,
type: 'success',
})
} catch (e) {
if (e?.body?.download_event_id) {
globalThis.window?.__fcToast?.({
text: 'Already running — see Downloads',
type: 'info',
})
router.push({ path: '/downloads', query: { source_id: source.id } })
} else {
globalThis.window?.__fcToast?.({
text: `Check failed: ${e?.detail || e?.message || e}`,
type: 'error',
})
}
}
}
async function checkAll(group) {
let ok = 0
let conflict = 0
for (const s of group.sources) {
if (!s.enabled) continue
try {
await store.checkNow(s.id)
ok += 1
} catch (e) {
if (e?.body?.download_event_id) conflict += 1
}
}
const parts = []
if (ok) parts.push(`${ok} queued`)
if (conflict) parts.push(`${conflict} already running`)
globalThis.window?.__fcToast?.({
text: parts.join(', ') || 'Nothing to check (no enabled sources)',
type: 'info',
})
}
function anyChecking(sources) {
return sources.some(s => store.checkingIds.has(s.id))
}
</script>
<style scoped>
.fc-subs__bar {
display: flex; gap: 0.75rem; align-items: center;
padding-bottom: 1rem;
}
.fc-subs__loading, .fc-subs__empty {
display: flex; justify-content: center; padding: 2rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-subs__card {
background: rgb(var(--v-theme-surface));
}
.fc-subs__name {
font-weight: 600;
}
.fc-subs__when {
color: rgb(var(--v-theme-on-surface-variant));
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.fc-subs__zero {
color: rgb(var(--v-theme-on-surface-variant));
opacity: 0.6;
}
.fc-subs__sources-row td {
padding: 0 !important;
background: rgb(var(--v-theme-surface-light));
}
.fc-subs__sources-cell {
padding-left: 2rem !important;
border-top: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.15);
}
.fc-subs__sources-table {
background: transparent !important;
}
.fc-subs__sources-empty {
color: rgb(var(--v-theme-on-surface-variant));
text-align: center;
padding: 1rem;
.fc-subs-tabs {
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
}
</style>
+19
View File
@@ -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
+133 -2
View File
@@ -78,6 +78,10 @@ def test_parse_empty_dict_all_none():
def test_parse_core_fields_and_id_priority():
"""`post_id` MUST win over `id` (SubscribeStar duplicate-post fix).
Patreon sidecars in the wild don't expose post_id; this test uses
`category=patreon` but synthetically sets both fields to pin the
parser's precedence."""
sd = parse_sidecar({
"category": "patreon",
"id": 12345, "post_id": 999,
@@ -88,7 +92,7 @@ def test_parse_core_fields_and_id_priority():
"published_at": "2023-08-01T04:20:02Z",
})
assert sd.platform == "patreon"
assert sd.external_post_id == "12345" # 'id' wins over 'post_id'
assert sd.external_post_id == "999" # 'post_id' wins over 'id'
assert sd.post_url == "https://patreon.com/posts/12345"
assert sd.post_title == "Hello"
assert sd.description == "<p>body</p>"
@@ -96,13 +100,25 @@ def test_parse_core_fields_and_id_priority():
assert sd.post_date.year == 2023 and sd.post_date.tzinfo is not None
def test_parse_id_used_when_no_post_id():
"""Without post_id (Patreon/Pixiv/Discord real shape), `id` wins."""
sd = parse_sidecar({"id": 12345, "url": "https://example.test/p/1"})
assert sd.external_post_id == "12345"
def test_parse_description_precedence_and_images_count():
sd = parse_sidecar({"description": "d", "caption": "c",
"images": [1, 2, 3]})
assert sd.description == "d" # content>description>caption
assert sd.description == "d" # content>description>caption>message
assert sd.attachment_count == 3 # len(images) fallback
def test_parse_message_used_as_description_fallback():
"""Discord posts have `message` not `content`; FC must surface it."""
sd = parse_sidecar({"category": "discord", "message": "hello channel"})
assert sd.description == "hello channel"
def test_parse_date_epoch_and_unparseable_and_naive():
assert parse_sidecar({"timestamp": 1690857602}).post_date.tzinfo is not None
assert parse_sidecar({"date": "not-a-date"}).post_date is None
@@ -110,6 +126,121 @@ def test_parse_date_epoch_and_unparseable_and_naive():
assert naive is not None and naive.utcoffset().total_seconds() == 0
def test_parse_title_derived_from_content_when_empty():
"""SubscribeStar gallery-dl writes `title: ""` and puts the leading
sentence in `content` HTML. When `title` is empty, synthesize the
post title from the content body's first non-empty text line."""
sd = parse_sidecar({
"title": "",
"content": "\n<div>Lets say hello to you guys with my Belle <br><br><br>\n</div>\n",
})
assert sd.post_title == "Lets say hello to you guys with my Belle"
assert sd.description == (
"<div>Lets say hello to you guys with my Belle <br><br><br>\n</div>"
)
def test_parse_title_derived_truncates_long_content():
long = "x" * 200
sd = parse_sidecar({"title": "", "content": long})
assert sd.post_title is not None
assert len(sd.post_title) <= 120
assert sd.post_title.endswith("")
def test_parse_title_explicit_wins_over_content_fallback():
"""If `title` is non-empty, the fallback never runs."""
sd = parse_sidecar({"title": "Real Title", "content": "<p>body line</p>"})
assert sd.post_title == "Real Title"
def test_parse_title_no_fallback_when_no_content():
sd = parse_sidecar({"title": ""})
assert sd.post_title is None
def test_parse_subscribestar_post_url_derived_and_post_id_wins():
"""SubscribeStar gallery-dl puts the per-attachment id in `id` and
the actual post id in `post_id`. The bare `url` is the file URL —
must be ignored and a derived permalink used instead."""
sd = parse_sidecar({
"category": "subscribestar",
"id": 711509, "post_id": 360360,
"url": "/post_uploads?payload=opaque",
"title": "",
"content": "<div>hello</div>",
})
assert sd.external_post_id == "360360"
assert sd.post_url == "https://www.subscribestar.com/posts/360360"
def test_parse_pixiv_post_url_derived():
"""Pixiv's `url` is the image URL (i.pximg.net); must be replaced
with the post permalink under /artworks/<id>."""
sd = parse_sidecar({
"category": "pixiv",
"id": 140466853,
"url": "https://i.pximg.net/img-original/img/2026/01/28/10/28/24/140466853_p0.jpg",
"title": "Nerissa x Jailbird",
})
assert sd.external_post_id == "140466853"
assert sd.post_url == "https://www.pixiv.net/artworks/140466853"
def test_parse_hentaifoundry_post_url_derived():
"""HF sidecars omit `url` entirely and use `index`+`user` for the
post's natural key. Synthesize the canonical /pictures/user/<u>/<i>
permalink."""
sd = parse_sidecar({
"category": "hentaifoundry",
"index": 1182595,
"user": "HolyMeh",
"title": "Annigosa",
})
assert sd.external_post_id == "1182595"
assert sd.post_url == "https://www.hentai-foundry.com/pictures/user/HolyMeh/1182595"
def test_parse_discord_post_url_derived_and_message_id_wins():
"""Discord posts use `message_id` for the post key and the
server/channel/message triple for the permalink."""
sd = parse_sidecar({
"category": "discord",
"message_id": "1195924119762505818",
"channel_id": "968315530597498880",
"server_id": "771088957849075793",
"message": "channel body text",
"url": "https://cdn.discordapp.com/attachments/file.png",
})
assert sd.external_post_id == "1195924119762505818"
assert sd.post_url == (
"https://discord.com/channels/771088957849075793/"
"968315530597498880/1195924119762505818"
)
assert sd.description == "channel body text"
def test_parse_patreon_post_url_kept_as_is():
"""Patreon's bare `url` IS a real permalink — must not be replaced."""
sd = parse_sidecar({
"category": "patreon",
"id": 47074733,
"url": "https://www.patreon.com/posts/barbara-genshin-47074733",
})
assert sd.post_url == "https://www.patreon.com/posts/barbara-genshin-47074733"
def test_parse_derived_url_returns_none_when_fields_missing():
"""If the per-platform fields needed to derive the URL are missing,
return None rather than fall back to the file `url`."""
sd = parse_sidecar({
"category": "subscribestar",
"url": "/post_uploads?payload=opaque",
# no post_id
})
assert sd.post_url is None
def test_parse_ignores_non_str_junk():
sd = parse_sidecar({"category": 5, "title": 7, "page_count": "x",
"id": True})