Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9075d8eadd | |||
| df6d89cb59 | |||
| 12be188ada | |||
| 6d7116c090 | |||
| b447c42853 | |||
| abafc3265e | |||
| 2394e47370 | |||
| 8243740a04 | |||
| 88e53e5b86 | |||
| aa28bddeab | |||
| b7b313cc05 | |||
| bd3f996582 | |||
| ae8c78ae09 | |||
| 4d2c464045 | |||
| b8ad17c68d | |||
| 1fd54897d8 | |||
| 9322c984fd | |||
| 37e8b796a1 | |||
| 8675f105ad | |||
| 74dac6b960 | |||
| 9e19c081b0 | |||
| 3838f04c16 | |||
| 42b1340324 | |||
| 3b1e2f1ceb | |||
| 8cdf0af0e1 | |||
| ccee344099 | |||
| 0316f92e8b | |||
| 6df74683b3 | |||
| 243e536225 | |||
| 2f16699971 | |||
| a5cb684d34 | |||
| 965a953b2e | |||
| 90c176b195 | |||
| b8d89b9f2a | |||
| 07344e0843 | |||
| 42c33e44f9 | |||
| 4e82208926 | |||
| 85b640f32e | |||
| c7001f4aed | |||
| f827612930 | |||
| 3f0153cba5 | |||
| 52fff00353 | |||
| f3e8f30a8f | |||
| eee107766e | |||
| c14338cbce | |||
| 7a64730bd2 | |||
| 1803a09306 | |||
| 8c36dd28b0 | |||
| 0f7cd3cb76 | |||
| 7b0dd4182c | |||
| 88cfb3dd02 | |||
| fb41b90110 | |||
| 7d84990f6d | |||
| ca55d92c68 | |||
| cce014be3a | |||
| c07effb593 | |||
| a36f72b383 | |||
| 2e8d7c960c | |||
| 992f38ec20 | |||
| 0bc5767a2b | |||
| 397021dcbd | |||
| b0bfbc585a | |||
| 110c1c0e51 | |||
| 6de84d0d60 | |||
| 4e1f208a9f | |||
| 06913eba8e | |||
| b7f693b15e | |||
| ecd0199799 | |||
| 983da9e5b1 | |||
| a41eddae3f | |||
| 7aa7f5a3d6 | |||
| 5d4f223b71 | |||
| 2505b197ae | |||
| 0d0b236ac3 | |||
| a06ada4c9b | |||
| ebd985990c | |||
| 4da8d1d774 | |||
| 05090c6e85 | |||
| 2d4bfa4375 | |||
| 6ed2021ad6 | |||
| 4f2ceaaf31 | |||
| 8a5b337a53 | |||
| 900d878d27 | |||
| fd80d40a34 | |||
| 929d3fc092 | |||
| c0c9e56fb9 | |||
| 3a577d5ade | |||
| 06a2f60c08 | |||
| 0978fbac66 | |||
| f4fe02e346 | |||
| efb142239d |
+106
-32
@@ -2,7 +2,17 @@ name: Build images
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev, main]
|
# `:dev` builds dropped 2026-05-26 — operator tests from `:latest` after
|
||||||
|
# merge-to-main, not from the dev branch image. Saves one full docker
|
||||||
|
# build per dev push.
|
||||||
|
branches: [main]
|
||||||
|
# Tag-push triggers an immutable per-version image build (e.g.
|
||||||
|
# `:v26.05.26.5`) — gives a real rollback story alongside the floating
|
||||||
|
# `:main` / `:latest`. Layer reuse keeps the registry-storage cost
|
||||||
|
# negligible per tag. Doesn't overlap with the push-to-main build (that
|
||||||
|
# one publishes `:main` + `:latest`; the tag-push build publishes only
|
||||||
|
# `:<tag>`).
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
|
# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
|
||||||
# - write:package, read:package (for docker push to git.fabledsword.com)
|
# - write:package, read:package (for docker push to git.fabledsword.com)
|
||||||
@@ -68,17 +78,11 @@ jobs:
|
|||||||
echo "No release named ext-$VERSION; will sign via AMO and upload"
|
echo "No release named ext-$VERSION; will sign via AMO and upload"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Download cached signed XPI (cache hit)
|
# No "download cached XPI in sign-extension" step: build-web
|
||||||
if: steps.cache.outputs.cached == 'true'
|
# fetches directly from the Forgejo ext-<version> release asset
|
||||||
env:
|
# (removed 2026-05-26 alongside the actions/upload-artifact
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
# removal — sign-extension's job is just to ensure the cache
|
||||||
run: |
|
# exists on Forgejo; the build-web side reads it independently).
|
||||||
set -eu
|
|
||||||
mkdir -p extension/web-ext-artifacts
|
|
||||||
curl -sL -H "Authorization: token $TOKEN" \
|
|
||||||
-o "extension/web-ext-artifacts/fabledcurator-${{ steps.extver.outputs.version }}.xpi" \
|
|
||||||
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/assets/${{ steps.cache.outputs.asset_id }}"
|
|
||||||
ls -la extension/web-ext-artifacts/
|
|
||||||
|
|
||||||
- name: Sign via AMO (cache miss)
|
- name: Sign via AMO (cache miss)
|
||||||
if: steps.cache.outputs.cached != 'true'
|
if: steps.cache.outputs.cached != 'true'
|
||||||
@@ -148,12 +152,11 @@ jobs:
|
|||||||
trap - EXIT
|
trap - EXIT
|
||||||
echo "Uploaded fabledcurator-$VERSION.xpi to ext-$VERSION release"
|
echo "Uploaded fabledcurator-$VERSION.xpi to ext-$VERSION release"
|
||||||
|
|
||||||
- name: Upload XPI as Actions artifact (handoff to build-web)
|
# No actions/upload-artifact step: Forgejo Actions (and our
|
||||||
uses: actions/upload-artifact@v4
|
# act_runner) doesn't support upload-artifact@v4+ (GHES limitation
|
||||||
with:
|
# surfaced 2026-05-26). Instead build-web reads the signed XPI
|
||||||
name: signed-extension
|
# straight from the ext-<version> Forgejo release we just uploaded
|
||||||
path: extension/web-ext-artifacts/fabledcurator-*.xpi
|
# to. Same source of truth; no double-store.
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
needs: [sign-extension]
|
needs: [sign-extension]
|
||||||
@@ -165,28 +168,93 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download signed extension (main only)
|
- name: Download signed XPI from Forgejo release asset (main + tags)
|
||||||
if: github.ref == 'refs/heads/main'
|
# Fires on main-push AND on tag-push. Tag-push builds re-package the
|
||||||
uses: actions/download-artifact@v4
|
# same source code as the preceding main-push build but with an
|
||||||
with:
|
# immutable version tag — they need the XPI too, otherwise the
|
||||||
name: signed-extension
|
# versioned image ships without the signed extension.
|
||||||
path: extension/web-ext-artifacts/
|
#
|
||||||
|
# Tag-push vs main-push race (operator-flagged 2026-05-27 after
|
||||||
- name: Place signed XPI in build context (main only)
|
# v26.05.27.0 hit it): a release cut fires BOTH workflows almost
|
||||||
if: github.ref == 'refs/heads/main'
|
# 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: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||||
XPI=$(ls extension/web-ext-artifacts/*.xpi | head -1)
|
# 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"
|
||||||
mkdir -p frontend/public/extension
|
mkdir -p frontend/public/extension
|
||||||
cp "$XPI" "frontend/public/extension/fabledcurator-$VERSION.xpi"
|
DEST="frontend/public/extension/fabledcurator-$VERSION.xpi"
|
||||||
cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi"
|
# -f = fail on HTTP error (prevents silent corruption like the
|
||||||
|
# 2026-05-26 incident); -L = follow redirects.
|
||||||
|
curl -sfL -H "Authorization: token $TOKEN" -o "$DEST" "$DOWNLOAD_URL"
|
||||||
|
# Sanity check: the binary should start with the ZIP magic (PK\x03\x04).
|
||||||
|
# If it's anything else, the next docker build will ship a corrupt XPI.
|
||||||
|
MAGIC=$(head -c 2 "$DEST" | od -An -c | tr -d ' \n')
|
||||||
|
if [ "$MAGIC" != "PK" ]; then
|
||||||
|
echo "ERROR: downloaded XPI does not start with ZIP magic 'PK' (got '$MAGIC')"
|
||||||
|
echo "File contents preview:"
|
||||||
|
head -c 200 "$DEST"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$DEST" "frontend/public/extension/fabledcurator-latest.xpi"
|
||||||
ls -la frontend/public/extension/
|
ls -la frontend/public/extension/
|
||||||
|
|
||||||
- name: Determine tag
|
- name: Determine tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
# Three trigger shapes:
|
||||||
|
# refs/tags/v… → tag-push: publish ONLY the immutable version
|
||||||
|
# tag (e.g. :v26.05.26.5). Don't touch :latest;
|
||||||
|
# that already got published by the main-push
|
||||||
|
# build for the merge commit.
|
||||||
|
# refs/heads/main → push to main (incl. PR merge commits):
|
||||||
|
# publish :main + :latest (floating).
|
||||||
|
# anything else → safety net; shouldn't fire given the `on:`
|
||||||
|
# config above (dev was dropped). Tag :dev to
|
||||||
|
# surface the unexpected run in the registry.
|
||||||
|
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
|
||||||
|
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||||
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
elif [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
|
||||||
@@ -217,7 +285,13 @@ jobs:
|
|||||||
- name: Determine tag
|
- name: Determine tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
# Mirrors build-web's three-shape logic (tag-push / main-push /
|
||||||
|
# safety-net dev). The -ml image follows the same release cadence
|
||||||
|
# as the web image.
|
||||||
|
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
|
||||||
|
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||||
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
elif [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
+15
-30
@@ -8,8 +8,10 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
pull_request:
|
# pull_request trigger intentionally absent — with branches: [dev, main]
|
||||||
branches: [main]
|
# above, every PR commit already fires CI via the push event on dev. Adding
|
||||||
|
# pull_request would duplicate runs on dev→main PRs. FC has no fork PRs
|
||||||
|
# (single-operator Forgejo repo) so push coverage is complete.
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-lint-and-test:
|
backend-lint-and-test:
|
||||||
@@ -24,13 +26,17 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Cache pip wheels
|
# Cache step removed 2026-05-26: act_runner's cache backend has been
|
||||||
uses: actions/cache@v4
|
# broken on this homelab runner since 2026-05-15 (first as request-
|
||||||
with:
|
# timeout warnings, then as hard "Cannot find module .../dist/restore/
|
||||||
path: ~/.cache/pip
|
# index.js" failures that tank the whole job). The cache step targeted
|
||||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
# ~/.cache/pip but the install below uses `uv pip install` primarily,
|
||||||
restore-keys: |
|
# whose own cache lives at ~/.cache/uv — so the cache step's real
|
||||||
pip-${{ runner.os }}-py314-
|
# benefit was marginal even when working. Cost of removal: ~30s of
|
||||||
|
# wheel downloads per job. Future re-enable: mount ~/.cache/uv as a
|
||||||
|
# docker volume at the runner level (skips actions/cache entirely),
|
||||||
|
# or fix the runner-side cache backend (clear /var/run/act/actions/*,
|
||||||
|
# pin act_runner version, etc.).
|
||||||
|
|
||||||
- name: Install Python deps
|
- name: Install Python deps
|
||||||
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
||||||
@@ -124,13 +130,6 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Cache pip wheels
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pip
|
|
||||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
|
||||||
restore-keys: |
|
|
||||||
pip-${{ runner.os }}-py314-
|
|
||||||
- name: API integration shard (resolve service IPs, migrate, test)
|
- name: API integration shard (resolve service IPs, migrate, test)
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
@@ -189,13 +188,6 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Cache pip wheels
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pip
|
|
||||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
|
||||||
restore-keys: |
|
|
||||||
pip-${{ runner.os }}-py314-
|
|
||||||
- name: Importer integration shard (resolve service IPs, migrate, test)
|
- name: Importer integration shard (resolve service IPs, migrate, test)
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
@@ -254,13 +246,6 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Cache pip wheels
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pip
|
|
||||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
|
||||||
restore-keys: |
|
|
||||||
pip-${{ runner.os }}-py314-
|
|
||||||
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
|
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""fc-cleanup: library_audit_run table for async transparency/single_color audits
|
||||||
|
|
||||||
|
Revision ID: 0020
|
||||||
|
Revises: 0019
|
||||||
|
Create Date: 2026-05-26
|
||||||
|
|
||||||
|
The table backs the async audit lifecycle: rule + params snapshot, status
|
||||||
|
state machine ('running' → 'ready' → 'applied'/'cancelled'/'error'), and
|
||||||
|
the matched_ids JSONB array that the apply step deletes. Capped at 50k IDs
|
||||||
|
per row by the scan task (oversize = rule too aggressive, operator narrows
|
||||||
|
before re-running).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0020"
|
||||||
|
down_revision: Union[str, None] = "0019"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"library_audit_run",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("rule", sa.String(32), nullable=False),
|
||||||
|
sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"status", sa.String(16),
|
||||||
|
nullable=False, server_default="running",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"started_at", sa.DateTime(timezone=True),
|
||||||
|
nullable=False, server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"scanned_count", sa.Integer(),
|
||||||
|
nullable=False, server_default="0",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"matched_count", sa.Integer(),
|
||||||
|
nullable=False, server_default="0",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"matched_ids", postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False, server_default=sa.text("'[]'::jsonb"),
|
||||||
|
),
|
||||||
|
sa.Column("error", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_library_audit_run_rule", "library_audit_run", ["rule"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_library_audit_run_status", "library_audit_run", ["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_library_audit_run_status", table_name="library_audit_run")
|
||||||
|
op.drop_index("ix_library_audit_run_rule", table_name="library_audit_run")
|
||||||
|
op.drop_table("library_audit_run")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""provenance-race: dedupe + UNIQUE(image_record_id, post_id) on image_provenance
|
||||||
|
|
||||||
|
Revision ID: 0021
|
||||||
|
Revises: 0020
|
||||||
|
Create Date: 2026-05-26
|
||||||
|
|
||||||
|
Closes the race in Importer._apply_sidecar's existence-check + INSERT pattern.
|
||||||
|
Two workers writing for the same (image, post) pair both saw no existing row
|
||||||
|
and both inserted, leaving duplicates that then broke .scalar_one_or_none()
|
||||||
|
on every subsequent deep-scan rederive against those images
|
||||||
|
(MultipleResultsFound). Most plausibly seeded when the 5-min recovery sweep
|
||||||
|
re-enqueued a still-running long-import task and the second worker collided
|
||||||
|
with the first inside _apply_sidecar.
|
||||||
|
|
||||||
|
Migration steps:
|
||||||
|
1. DELETE all but min(id) per (image_record_id, post_id) pair. Operator's
|
||||||
|
DB had 2 affected pairs at write-time; harmless no-op if zero.
|
||||||
|
2. Add UNIQUE constraint so the importer's new savepoint+IntegrityError
|
||||||
|
recovery path can trip on collision and re-select, mirroring
|
||||||
|
uq_source_artist_platform_url and uq_post_source_external_id.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0021"
|
||||||
|
down_revision: Union[str, None] = "0020"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM image_provenance ip1
|
||||||
|
USING image_provenance ip2
|
||||||
|
WHERE ip1.image_record_id = ip2.image_record_id
|
||||||
|
AND ip1.post_id = ip2.post_id
|
||||||
|
AND ip1.id > ip2.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_image_provenance_image_post",
|
||||||
|
"image_provenance",
|
||||||
|
["image_record_id", "post_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint(
|
||||||
|
"uq_image_provenance_image_post",
|
||||||
|
"image_provenance",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""source-collapse: one Source per (artist, platform) — consolidate junk per-post Sources
|
||||||
|
|
||||||
|
Revision ID: 0022
|
||||||
|
Revises: 0021
|
||||||
|
Create Date: 2026-05-26
|
||||||
|
|
||||||
|
Closes the operator-flagged 2026-05-26 issue where the filesystem importer
|
||||||
|
called _find_or_create_source(url=sd.post_url), creating one Source row per
|
||||||
|
imported post URL. Operator's Atole artist had 406 Source rows where there
|
||||||
|
should have been 1 (the /cw/Atole subscription Source).
|
||||||
|
|
||||||
|
Source represents a subscription feed (one per artist+platform — the
|
||||||
|
gallery-dl URL polled by the FC-3 downloader). Posts hang off it. The
|
||||||
|
filesystem importer was misusing Source as a per-post key.
|
||||||
|
|
||||||
|
Migration steps per (artist_id, platform) group with >1 Source:
|
||||||
|
1. Pick canonical — prefer a URL NOT matching '/posts/<id>$' (real
|
||||||
|
campaign URL like /cw/Atole); else min(id).
|
||||||
|
2. PRE-merge any Posts under non-canonical sources whose
|
||||||
|
external_post_id ALREADY exists under the canonical source. (Same
|
||||||
|
gallery-dl post imported via two different sidecar paths can plant
|
||||||
|
two Post rows with identical external_post_id under different
|
||||||
|
Sources for the same artist.) Repoint ImageProvenance +
|
||||||
|
ImageRecord.primary_post_id to the canonical-side Post, dedupe
|
||||||
|
ImageProvenance against alembic 0021's uq, then delete the
|
||||||
|
non-canonical-side Post. This MUST happen before step 3 — Postgres
|
||||||
|
fires uq_post_source_external_id row-by-row during the bulk UPDATE
|
||||||
|
and the merge-after-reparent ordering 500s on first collision
|
||||||
|
(operator-hit during v26.05.26.1 deploy, 2026-05-26).
|
||||||
|
3. Reparent remaining Posts onto canonical (no collisions possible now).
|
||||||
|
4. Reparent ImageProvenance.source_id off the non-canonical sources.
|
||||||
|
5. Delete the orphan Source rows.
|
||||||
|
6. If the canonical Source's URL still looks like a per-post URL (no
|
||||||
|
campaign URL existed among candidates), rewrite it to
|
||||||
|
'sidecar:<platform>:<artist_slug>' so the artist detail page shows
|
||||||
|
something readable.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision: str = "0022"
|
||||||
|
down_revision: Union[str, None] = "0021"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_POST_URL_RE = r"/posts/[^/]+$"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Find (artist_id, platform) groups with > 1 Source row.
|
||||||
|
groups = conn.execute(text("""
|
||||||
|
SELECT artist_id, platform
|
||||||
|
FROM source
|
||||||
|
GROUP BY artist_id, platform
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
for artist_id, platform in groups:
|
||||||
|
rows = conn.execute(
|
||||||
|
text("""
|
||||||
|
SELECT id, url FROM source
|
||||||
|
WHERE artist_id = :a AND platform = :p
|
||||||
|
ORDER BY id ASC
|
||||||
|
"""),
|
||||||
|
{"a": artist_id, "p": platform},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Canonical: first row whose URL doesn't look like a per-post URL;
|
||||||
|
# else min(id).
|
||||||
|
canonical_id = None
|
||||||
|
for sid, url in rows:
|
||||||
|
if not _matches_post_url(url):
|
||||||
|
canonical_id = sid
|
||||||
|
break
|
||||||
|
if canonical_id is None:
|
||||||
|
canonical_id = rows[0][0]
|
||||||
|
|
||||||
|
other_ids = [sid for sid, _ in rows if sid != canonical_id]
|
||||||
|
if not other_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# STEP 2: PRE-merge ALL Posts with duplicate external_post_id
|
||||||
|
# across the entire (canonical + others) group, BEFORE the bulk
|
||||||
|
# reparent. Two cases must both be handled:
|
||||||
|
# (A) canonical has Post X with epid=N; an "other" source has
|
||||||
|
# Post Y with epid=N → after bulk UPDATE, (canonical, N)
|
||||||
|
# collides with itself.
|
||||||
|
# (B) two different "other" sources each have a Post with
|
||||||
|
# epid=N; canonical has none → after bulk UPDATE, both
|
||||||
|
# are repointed to (canonical, N) and the second collides.
|
||||||
|
# The earlier version of this migration only handled (A); the
|
||||||
|
# operator's deploy 2026-05-26 tripped (B) at line 139.
|
||||||
|
# Fix: group ALL Posts in the (artist, platform) by epid; for
|
||||||
|
# any group with count>1, pick the keep (prefer one already
|
||||||
|
# under canonical; else lowest id) and merge the rest into it.
|
||||||
|
all_posts = conn.execute(
|
||||||
|
text("""
|
||||||
|
SELECT external_post_id, id, source_id
|
||||||
|
FROM post
|
||||||
|
WHERE source_id = :canonical OR source_id = ANY(:others)
|
||||||
|
ORDER BY external_post_id, id
|
||||||
|
"""),
|
||||||
|
{"canonical": canonical_id, "others": other_ids},
|
||||||
|
).fetchall()
|
||||||
|
by_epid: dict = {}
|
||||||
|
for epid, post_id, src_id in all_posts:
|
||||||
|
by_epid.setdefault(epid, []).append((post_id, src_id))
|
||||||
|
for _epid, posts in by_epid.items():
|
||||||
|
if len(posts) <= 1:
|
||||||
|
continue
|
||||||
|
# Prefer a Post already under canonical as the keep.
|
||||||
|
canonical_posts = [p for p in posts if p[1] == canonical_id]
|
||||||
|
if canonical_posts:
|
||||||
|
keep_id = canonical_posts[0][0]
|
||||||
|
else:
|
||||||
|
keep_id = posts[0][0] # already sorted by id ASC
|
||||||
|
drop_ids = [p[0] for p in posts if p[0] != keep_id]
|
||||||
|
for drop_id in drop_ids:
|
||||||
|
# Pre-delete image_provenance rows under drop_ whose
|
||||||
|
# image_record_id ALREADY has a provenance under keep —
|
||||||
|
# the UPDATE below would otherwise repoint them and
|
||||||
|
# trip uq_image_provenance_image_post (alembic 0021)
|
||||||
|
# row-by-row before any after-the-fact dedupe could
|
||||||
|
# run. Operator's v26.05.26.3 deploy 2026-05-26 tripped
|
||||||
|
# this at line 123.
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
# Now safe to repoint the survivors.
|
||||||
|
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("DELETE FROM post WHERE id = :drop_"),
|
||||||
|
{"drop_": drop_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# STEP 3: Bulk reparent the remaining Posts off the other
|
||||||
|
# Sources. After step 2, no collisions on
|
||||||
|
# (canonical, external_post_id) are possible.
|
||||||
|
conn.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE post SET source_id = :canonical
|
||||||
|
WHERE source_id = ANY(:others)
|
||||||
|
"""),
|
||||||
|
{"canonical": canonical_id, "others": other_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
# STEP 4: Reparent ImageProvenance.source_id (denormalized FK).
|
||||||
|
# No UNIQUE on source_id; safe bulk update.
|
||||||
|
conn.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE image_provenance SET source_id = :canonical
|
||||||
|
WHERE source_id = ANY(:others)
|
||||||
|
"""),
|
||||||
|
{"canonical": canonical_id, "others": other_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
# STEP 5: Drop the orphan Sources.
|
||||||
|
conn.execute(
|
||||||
|
text("DELETE FROM source WHERE id = ANY(:others)"),
|
||||||
|
{"others": other_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the canonical's URL still looks per-post (no campaign URL
|
||||||
|
# existed among the candidates), rewrite to a synthetic anchor so
|
||||||
|
# the artist detail page renders something readable.
|
||||||
|
canonical_url = conn.execute(
|
||||||
|
text("SELECT url FROM source WHERE id = :id"),
|
||||||
|
{"id": canonical_id},
|
||||||
|
).scalar_one()
|
||||||
|
if _matches_post_url(canonical_url):
|
||||||
|
slug = conn.execute(
|
||||||
|
text("SELECT slug FROM artist WHERE id = :id"),
|
||||||
|
{"id": artist_id},
|
||||||
|
).scalar_one()
|
||||||
|
conn.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE source
|
||||||
|
SET url = :new_url, enabled = false
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": canonical_id,
|
||||||
|
"new_url": f"sidecar:{platform}:{slug}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Lossy migration — orphan Sources deleted, Posts reparented, Posts
|
||||||
|
# merged. No safe downgrade. If you need to roll back the schema
|
||||||
|
# invariant, fork from 0021 and re-run filesystem imports.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_post_url(url: str) -> bool:
|
||||||
|
"""True if url ends with /posts/<token> (gallery-dl-style per-post URL)."""
|
||||||
|
import re
|
||||||
|
return bool(re.search(_POST_URL_RE, url or ""))
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""drop meta + rating tag kinds — operator-retired 2026-05-26
|
||||||
|
|
||||||
|
Revision ID: 0023
|
||||||
|
Revises: 0022
|
||||||
|
Create Date: 2026-05-26
|
||||||
|
|
||||||
|
Operator decided meta + rating aren't valid tag kinds for FC. Per-row
|
||||||
|
behavior: DELETE existing rows (operator chose "clean break" over
|
||||||
|
"convert to general"). All cascading FKs (image_tag, tag_alias,
|
||||||
|
tag_allowlist, tag_reference_embedding, tag_suggestion_rejection,
|
||||||
|
series_page) use ondelete="CASCADE" so a single DELETE on tag cleans
|
||||||
|
the related rows in one go.
|
||||||
|
|
||||||
|
After the data cleanup, recreate the tag_kind ENUM without 'meta' /
|
||||||
|
'rating' (Postgres has no `ALTER TYPE ... DROP VALUE`; standard
|
||||||
|
rename-create-cast-drop dance). The server default 'general' is
|
||||||
|
dropped before the type swap and restored after.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0023"
|
||||||
|
down_revision: Union[str, None] = "0022"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Delete tags of the retired kinds. CASCADE handles related tables.
|
||||||
|
op.execute("DELETE FROM tag WHERE kind IN ('meta', 'rating')")
|
||||||
|
|
||||||
|
# 2. Drop the CHECK constraint that references the enum's literal
|
||||||
|
# values. Postgres can't resolve `kind = 'character'` across the
|
||||||
|
# type swap below — the literal would bind to the new tag_kind
|
||||||
|
# but the column is on tag_kind_old, producing
|
||||||
|
# "operator does not exist: tag_kind = tag_kind_old".
|
||||||
|
# (Operator-hit during the v26.05.26.5 deploy attempt; ck was
|
||||||
|
# originally added by alembic 0002.) Recreated post-swap.
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_tag_fandom_requires_character", "tag", type_="check"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Drop the server default — ALTER COLUMN TYPE can't carry it
|
||||||
|
# across the type swap below.
|
||||||
|
op.execute("ALTER TABLE tag ALTER COLUMN kind DROP DEFAULT")
|
||||||
|
|
||||||
|
# 4. Recreate the tag_kind enum without meta/rating.
|
||||||
|
op.execute("ALTER TYPE tag_kind RENAME TO tag_kind_old")
|
||||||
|
op.execute(
|
||||||
|
"CREATE TYPE tag_kind AS ENUM ("
|
||||||
|
"'artist', 'character', 'fandom', 'general', "
|
||||||
|
"'series', 'archive', 'post'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE tag "
|
||||||
|
"ALTER COLUMN kind TYPE tag_kind "
|
||||||
|
"USING kind::text::tag_kind"
|
||||||
|
)
|
||||||
|
op.execute("DROP TYPE tag_kind_old")
|
||||||
|
|
||||||
|
# 5. Restore the server default.
|
||||||
|
op.execute("ALTER TABLE tag ALTER COLUMN kind SET DEFAULT 'general'")
|
||||||
|
|
||||||
|
# 6. Restore the CHECK constraint (now bound to the new tag_kind).
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_tag_fandom_requires_character",
|
||||||
|
"tag",
|
||||||
|
"(fandom_id IS NULL) OR (kind = 'character')",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Add the values back to the enum so old code can boot. The deleted
|
||||||
|
# tag rows are gone permanently — no safe restore.
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_tag_fandom_requires_character", "tag", type_="check"
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE tag ALTER COLUMN kind DROP DEFAULT")
|
||||||
|
op.execute("ALTER TYPE tag_kind RENAME TO tag_kind_old")
|
||||||
|
op.execute(
|
||||||
|
"CREATE TYPE tag_kind AS ENUM ("
|
||||||
|
"'artist', 'character', 'fandom', 'general', "
|
||||||
|
"'series', 'archive', 'post', 'meta', 'rating'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE tag "
|
||||||
|
"ALTER COLUMN kind TYPE tag_kind "
|
||||||
|
"USING kind::text::tag_kind"
|
||||||
|
)
|
||||||
|
op.execute("DROP TYPE tag_kind_old")
|
||||||
|
op.execute("ALTER TABLE tag ALTER COLUMN kind SET DEFAULT 'general'")
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_tag_fandom_requires_character",
|
||||||
|
"tag",
|
||||||
|
"(fandom_id IS NULL) OR (kind = 'character')",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
+38
-1
@@ -3,13 +3,23 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Quart
|
from quart import Quart, request
|
||||||
|
|
||||||
from .api import all_blueprints
|
from .api import all_blueprints
|
||||||
from .config import get_config
|
from .config import get_config
|
||||||
from .frontend import frontend_bp
|
from .frontend import frontend_bp
|
||||||
from .services.credential_crypto import CredentialCrypto
|
from .services.credential_crypto import CredentialCrypto
|
||||||
|
|
||||||
|
# Browser-extension origins. The FabledCurator extension fetches from
|
||||||
|
# moz-extension://<uuid>/ on Firefox and chrome-extension://<uuid>/ on
|
||||||
|
# Chromium-based browsers. Operator-flagged 2026-05-26: extension's
|
||||||
|
# 'Test connection' returned `NetworkError` because the X-Extension-Key
|
||||||
|
# header on /api/credentials triggers a CORS preflight that our routes
|
||||||
|
# don't handle. Whitelisting only these two schemes (not opening CORS
|
||||||
|
# up generally) lets the extension talk to a plain-HTTP self-hosted FC
|
||||||
|
# without weakening the no-CORS posture for normal browser usage.
|
||||||
|
_EXTENSION_ORIGIN_SCHEMES = ("moz-extension://", "chrome-extension://")
|
||||||
|
|
||||||
_CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64")
|
_CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64")
|
||||||
|
|
||||||
|
|
||||||
@@ -35,6 +45,33 @@ def create_app() -> Quart:
|
|||||||
# Registered last so /api/* routes win over the SPA catch-all.
|
# Registered last so /api/* routes win over the SPA catch-all.
|
||||||
app.register_blueprint(frontend_bp)
|
app.register_blueprint(frontend_bp)
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
async def _extension_cors_preflight():
|
||||||
|
# Short-circuit OPTIONS preflight from the browser extension with a
|
||||||
|
# 204 + CORS headers (the after_request hook below adds them).
|
||||||
|
# Without this, OPTIONS lands on routes that only declared POST/GET
|
||||||
|
# methods and 405s before the after_request gets a chance.
|
||||||
|
if request.method != "OPTIONS":
|
||||||
|
return None
|
||||||
|
origin = request.headers.get("Origin", "")
|
||||||
|
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
|
||||||
|
return "", 204
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
async def _extension_cors_headers(response):
|
||||||
|
origin = request.headers.get("Origin", "")
|
||||||
|
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
|
||||||
|
response.headers["Access-Control-Allow-Origin"] = origin
|
||||||
|
response.headers["Access-Control-Allow-Methods"] = (
|
||||||
|
"GET, POST, PATCH, DELETE, OPTIONS"
|
||||||
|
)
|
||||||
|
response.headers["Access-Control-Allow-Headers"] = (
|
||||||
|
"Content-Type, X-Extension-Key"
|
||||||
|
)
|
||||||
|
response.headers["Access-Control-Max-Age"] = "86400"
|
||||||
|
return response
|
||||||
|
|
||||||
@app.after_serving
|
@app.after_serving
|
||||||
async def _dispose_db_engine() -> None:
|
async def _dispose_db_engine() -> None:
|
||||||
from .extensions import dispose_engine
|
from .extensions import dispose_engine
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
from .artist import artist_bp
|
from .artist import artist_bp
|
||||||
from .artists import artists_bp
|
from .artists import artists_bp
|
||||||
from .attachments import attachments_bp
|
from .attachments import attachments_bp
|
||||||
|
from .cleanup import cleanup_bp
|
||||||
from .credentials import credentials_bp
|
from .credentials import credentials_bp
|
||||||
from .downloads import downloads_bp
|
from .downloads import downloads_bp
|
||||||
from .extension import extension_bp
|
from .extension import extension_bp
|
||||||
@@ -37,6 +38,7 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
from .system_activity import system_activity_bp
|
from .system_activity import system_activity_bp
|
||||||
from .system_backup import system_backup_bp
|
from .system_backup import system_backup_bp
|
||||||
from .tags import tags_bp
|
from .tags import tags_bp
|
||||||
|
from .thumbnails import thumbnails_bp
|
||||||
return [
|
return [
|
||||||
api_bp,
|
api_bp,
|
||||||
attachments_bp,
|
attachments_bp,
|
||||||
@@ -50,12 +52,14 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
system_activity_bp,
|
system_activity_bp,
|
||||||
system_backup_bp,
|
system_backup_bp,
|
||||||
admin_bp,
|
admin_bp,
|
||||||
|
cleanup_bp,
|
||||||
import_admin_bp,
|
import_admin_bp,
|
||||||
migrate_bp,
|
migrate_bp,
|
||||||
suggestions_bp,
|
suggestions_bp,
|
||||||
allowlist_bp,
|
allowlist_bp,
|
||||||
aliases_bp,
|
aliases_bp,
|
||||||
ml_admin_bp,
|
ml_admin_bp,
|
||||||
|
thumbnails_bp,
|
||||||
sources_bp,
|
sources_bp,
|
||||||
platforms_bp,
|
platforms_bp,
|
||||||
posts_bp,
|
posts_bp,
|
||||||
|
|||||||
@@ -97,11 +97,19 @@ async def images_bulk_delete():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
return jsonify(projected)
|
|
||||||
|
|
||||||
sha8 = _bulk_image_confirm_token(image_ids)
|
sha8 = _bulk_image_confirm_token(image_ids)
|
||||||
expected = f"delete-images-{sha8}"
|
expected = f"delete-images-{sha8}"
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
# Hand the canonical Tier-C confirm token back with the
|
||||||
|
# projection so the frontend doesn't have to recompute SHA-256
|
||||||
|
# client-side via crypto.subtle (Secure-Context-gated,
|
||||||
|
# undefined on plain-HTTP origins per the homelab posture).
|
||||||
|
# Operator-flagged 2026-05-27.
|
||||||
|
projected["confirm_token"] = expected
|
||||||
|
return jsonify(projected)
|
||||||
|
|
||||||
|
|
||||||
if supplied_confirm != expected:
|
if supplied_confirm != expected:
|
||||||
return _bad(
|
return _bad(
|
||||||
"confirm_mismatch",
|
"confirm_mismatch",
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""FC-Cleanup: /api/cleanup/* — retroactive enforcement of import filters.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /min-dimension/preview synchronous SQL audit
|
||||||
|
POST /min-dimension/delete synchronous SQL delete (Tier-C token)
|
||||||
|
POST /audit async transparency / single_color start
|
||||||
|
GET /audit list recent audit_run rows
|
||||||
|
GET /audit/<id> single audit_run row
|
||||||
|
POST /audit/<id>/apply apply matched_ids deletes (Tier-C token)
|
||||||
|
POST /audit/<id>/cancel flip running audit to cancelled
|
||||||
|
|
||||||
|
Unused-tags retroactive prune intentionally NOT in this namespace —
|
||||||
|
TagMaintenanceCard (Maintenance tab → moved to Cleanup tab in v26.05.25.7)
|
||||||
|
uses the existing /api/admin/tags/prune-unused endpoint via the admin
|
||||||
|
store. No duplicate route here.
|
||||||
|
|
||||||
|
Confirm-token format matches modal/DestructiveConfirmModal.vue convention:
|
||||||
|
`delete-min-dim-<sha8(w,h)>` for min-dim delete
|
||||||
|
`delete-audit-<id>` for audit apply
|
||||||
|
(Modal hardcodes action ∈ {'restore', 'delete'}; "apply audit" is semantically a delete of the matched images, so we use `delete-audit-<id>`.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from quart import Blueprint, jsonify, request
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..extensions import get_session
|
||||||
|
from ..models import LibraryAuditRun
|
||||||
|
from ..services import cleanup_service
|
||||||
|
|
||||||
|
cleanup_bp = Blueprint("cleanup", __name__, url_prefix="/api/cleanup")
|
||||||
|
|
||||||
|
IMAGES_ROOT = Path("/images")
|
||||||
|
|
||||||
|
|
||||||
|
def _bad(error: str, *, status: int = 400, **extra):
|
||||||
|
body = {"error": error}
|
||||||
|
body.update(extra)
|
||||||
|
return jsonify(body), status
|
||||||
|
|
||||||
|
|
||||||
|
def _min_dim_token(min_w: int, min_h: int) -> str:
|
||||||
|
# SHA-256 (not MD5) — Web Crypto's subtle.digest rejects MD5; both
|
||||||
|
# sides use SHA-256 truncated to 8 hex chars.
|
||||||
|
canon = f"{min_w}x{min_h}"
|
||||||
|
return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_audit_run(audit: LibraryAuditRun) -> dict:
|
||||||
|
return {
|
||||||
|
"id": audit.id,
|
||||||
|
"rule": audit.rule,
|
||||||
|
"params": audit.params,
|
||||||
|
"status": audit.status,
|
||||||
|
"started_at": audit.started_at.isoformat() if audit.started_at else None,
|
||||||
|
"finished_at": audit.finished_at.isoformat() if audit.finished_at else None,
|
||||||
|
"scanned_count": audit.scanned_count,
|
||||||
|
"matched_count": audit.matched_count,
|
||||||
|
"matched_ids": audit.matched_ids,
|
||||||
|
"error": audit.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@cleanup_bp.route("/min-dimension/preview", methods=["POST"])
|
||||||
|
async def min_dim_preview():
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
min_w = int(body.get("min_width", 0))
|
||||||
|
min_h = int(body.get("min_height", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _bad("invalid_dimensions")
|
||||||
|
if min_w < 0 or min_h < 0:
|
||||||
|
return _bad("invalid_dimensions")
|
||||||
|
async with get_session() as session:
|
||||||
|
projection = await session.run_sync(
|
||||||
|
lambda s: cleanup_service.project_min_dimension_violations(
|
||||||
|
s, min_width=min_w, min_height=min_h,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Hand the canonical Tier-C delete token back with the preview so
|
||||||
|
# the frontend doesn't have to recompute SHA-256 client-side.
|
||||||
|
# window.crypto.subtle is Secure-Context-gated and undefined on
|
||||||
|
# plain-HTTP origins (homelab posture); without this the Delete
|
||||||
|
# button silently swallowed the TypeError and never opened the
|
||||||
|
# confirm modal. Operator-flagged 2026-05-27.
|
||||||
|
projection["confirm_token"] = _min_dim_token(min_w, min_h)
|
||||||
|
return jsonify(projection)
|
||||||
|
|
||||||
|
|
||||||
|
@cleanup_bp.route("/min-dimension/delete", methods=["POST"])
|
||||||
|
async def min_dim_delete():
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
min_w = int(body.get("min_width", 0))
|
||||||
|
min_h = int(body.get("min_height", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _bad("invalid_dimensions")
|
||||||
|
if min_w < 0 or min_h < 0:
|
||||||
|
return _bad("invalid_dimensions")
|
||||||
|
supplied = body.get("confirm", "")
|
||||||
|
expected = _min_dim_token(min_w, min_h)
|
||||||
|
if supplied != expected:
|
||||||
|
return _bad("confirm_mismatch", expected=expected)
|
||||||
|
async with get_session() as session:
|
||||||
|
deleted = await session.run_sync(
|
||||||
|
lambda s: cleanup_service.delete_min_dimension_violations(
|
||||||
|
s, min_width=min_w, min_height=min_h, images_root=IMAGES_ROOT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"deleted": deleted})
|
||||||
|
|
||||||
|
|
||||||
|
@cleanup_bp.route("/audit", methods=["POST"])
|
||||||
|
async def audit_create():
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
rule = body.get("rule")
|
||||||
|
params = body.get("params") or {}
|
||||||
|
if rule not in ("transparency", "single_color"):
|
||||||
|
return _bad("invalid_rule")
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
return _bad("invalid_params")
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
audit_id = await session.run_sync(
|
||||||
|
lambda s: cleanup_service.start_audit_run(
|
||||||
|
s, rule=rule, params=params,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except cleanup_service.AuditAlreadyRunning as running_id:
|
||||||
|
return _bad(
|
||||||
|
"audit_already_running", status=409,
|
||||||
|
running_id=int(str(running_id)),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return _bad(str(exc))
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"audit_id": audit_id, "status": "running"}), 202
|
||||||
|
|
||||||
|
|
||||||
|
@cleanup_bp.route("/audit/<int:audit_id>", methods=["GET"])
|
||||||
|
async def audit_get(audit_id: int):
|
||||||
|
async with get_session() as session:
|
||||||
|
audit = (await session.execute(
|
||||||
|
select(LibraryAuditRun).where(LibraryAuditRun.id == audit_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if audit is None:
|
||||||
|
return _bad("not_found", status=404)
|
||||||
|
return jsonify(_serialize_audit_run(audit))
|
||||||
|
|
||||||
|
|
||||||
|
@cleanup_bp.route("/audit", methods=["GET"])
|
||||||
|
async def audit_history():
|
||||||
|
try:
|
||||||
|
limit = min(int(request.args.get("limit", "20")), 100)
|
||||||
|
except ValueError:
|
||||||
|
return _bad("invalid_limit")
|
||||||
|
async with get_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(LibraryAuditRun)
|
||||||
|
.order_by(LibraryAuditRun.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)).scalars().all()
|
||||||
|
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
@cleanup_bp.route("/audit/<int:audit_id>/apply", methods=["POST"])
|
||||||
|
async def audit_apply(audit_id: int):
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
confirm = body.get("confirm", "")
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
deleted = await session.run_sync(
|
||||||
|
lambda s: cleanup_service.apply_audit_run(
|
||||||
|
s, audit_id=audit_id, confirm_token=confirm,
|
||||||
|
images_root=IMAGES_ROOT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except cleanup_service.AuditNotReady as exc:
|
||||||
|
return _bad("audit_not_ready", current_status=str(exc))
|
||||||
|
except cleanup_service.ConfirmTokenMismatch as exc:
|
||||||
|
return _bad("confirm_mismatch", expected=str(exc))
|
||||||
|
except ValueError as exc:
|
||||||
|
return _bad("not_found", status=404, detail=str(exc))
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"deleted": deleted})
|
||||||
|
|
||||||
|
|
||||||
|
@cleanup_bp.route("/audit/<int:audit_id>/cancel", methods=["POST"])
|
||||||
|
async def audit_cancel(audit_id: int):
|
||||||
|
async with get_session() as session:
|
||||||
|
await session.run_sync(
|
||||||
|
lambda s: cleanup_service.cancel_audit_run(s, audit_id=audit_id)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"cancelled": True})
|
||||||
@@ -5,8 +5,10 @@ status/source/artist. Returns slim records.
|
|||||||
Detail view: full DownloadEvent including the metadata JSONB.
|
Detail view: full DownloadEvent including the metadata JSONB.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import Artist, DownloadEvent, Source
|
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])
|
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"])
|
@downloads_bp.route("/<int:event_id>", methods=["GET"])
|
||||||
async def get_download(event_id: int):
|
async def get_download(event_id: int):
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
|
|||||||
@@ -93,10 +93,20 @@ def _read_manifest_sync() -> dict | None:
|
|||||||
asyncio.to_thread (ASYNC240: no pathlib I/O in async functions)."""
|
asyncio.to_thread (ASYNC240: no pathlib I/O in async functions)."""
|
||||||
if not XPI_DIR.is_dir():
|
if not XPI_DIR.is_dir():
|
||||||
return None
|
return None
|
||||||
xpis = sorted(XPI_DIR.glob("fabledcurator-*.xpi"), key=lambda p: p.stat().st_mtime)
|
# Exclude the `fabledcurator-latest.xpi` alias when picking the file to
|
||||||
if not xpis:
|
# extract a version from — it's a copy of the latest versioned XPI,
|
||||||
|
# written at the same mtime by build.yml, and would otherwise tie or
|
||||||
|
# win the sort (operator-flagged 2026-05-26: UI displayed "v latest"
|
||||||
|
# because `_extract_version("fabledcurator-latest.xpi")` returns
|
||||||
|
# the literal "latest"). The alias still serves as `latest_url`.
|
||||||
|
versioned = [
|
||||||
|
p for p in XPI_DIR.glob("fabledcurator-*.xpi")
|
||||||
|
if p.name != "fabledcurator-latest.xpi"
|
||||||
|
]
|
||||||
|
if not versioned:
|
||||||
return None
|
return None
|
||||||
latest = xpis[-1]
|
versioned.sort(key=lambda p: p.stat().st_mtime)
|
||||||
|
latest = versioned[-1]
|
||||||
return {
|
return {
|
||||||
"installed": True,
|
"installed": True,
|
||||||
"version": _extract_version(latest.name),
|
"version": _extract_version(latest.name),
|
||||||
|
|||||||
@@ -103,17 +103,22 @@ async def list_tasks():
|
|||||||
|
|
||||||
@import_admin_bp.route("/retry-failed", methods=["POST"])
|
@import_admin_bp.route("/retry-failed", methods=["POST"])
|
||||||
async def retry_failed():
|
async def retry_failed():
|
||||||
|
# Fold SELECT into UPDATE…WHERE…RETURNING — the prior SELECT-then-
|
||||||
|
# UPDATE-WHERE-id-IN pattern blew past psycopg's 65535-parameter
|
||||||
|
# ceiling once failed_ids exceeded ~65k rows.
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
failed_ids = (
|
result = await session.execute(
|
||||||
await session.execute(select(ImportTask.id).where(ImportTask.status == "failed"))
|
update(ImportTask)
|
||||||
).scalars().all()
|
.where(ImportTask.status == "failed")
|
||||||
|
.values(
|
||||||
|
status="queued", error=None,
|
||||||
|
started_at=None, finished_at=None,
|
||||||
|
)
|
||||||
|
.returning(ImportTask.id)
|
||||||
|
)
|
||||||
|
failed_ids = [row[0] for row in result.all()]
|
||||||
if not failed_ids:
|
if not failed_ids:
|
||||||
return jsonify({"retried": 0})
|
return jsonify({"retried": 0})
|
||||||
await session.execute(
|
|
||||||
update(ImportTask)
|
|
||||||
.where(ImportTask.id.in_(failed_ids))
|
|
||||||
.values(status="queued", error=None, started_at=None, finished_at=None)
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
from ..tasks.import_file import import_media_file
|
from ..tasks.import_file import import_media_file
|
||||||
@@ -138,28 +143,26 @@ async def clear_stuck():
|
|||||||
autoretry-looped for 2 days after a corrupt-data PIL OSError.
|
autoretry-looped for 2 days after a corrupt-data PIL OSError.
|
||||||
"""
|
"""
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
stuck_ids = (
|
# Fold SELECT into UPDATE…WHERE — see /retry-failed for the
|
||||||
await session.execute(
|
# 65535-parameter ceiling rationale. rowcount is enough here
|
||||||
select(ImportTask.id).where(
|
# because we don't need the ids afterward (no .delay()).
|
||||||
ImportTask.status.in_(["pending", "queued", "processing"])
|
clear_result = await session.execute(
|
||||||
)
|
update(ImportTask)
|
||||||
|
.where(
|
||||||
|
ImportTask.status.in_(["pending", "queued", "processing"])
|
||||||
)
|
)
|
||||||
).scalars().all()
|
.values(
|
||||||
if stuck_ids:
|
status="failed",
|
||||||
await session.execute(
|
finished_at=datetime.now(UTC),
|
||||||
update(ImportTask)
|
error=(
|
||||||
.where(ImportTask.id.in_(stuck_ids))
|
"manually cleared via /api/import/clear-stuck "
|
||||||
.values(
|
"— stuck in non-terminal state; retry once "
|
||||||
status="failed",
|
"underlying cause (corrupt file, missing model, "
|
||||||
finished_at=datetime.now(UTC),
|
"etc.) is resolved"
|
||||||
error=(
|
),
|
||||||
"manually cleared via /api/import/clear-stuck "
|
|
||||||
"— stuck in non-terminal state; retry once "
|
|
||||||
"underlying cause (corrupt file, missing model, "
|
|
||||||
"etc.) is resolved"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
tasks_failed = clear_result.rowcount or 0
|
||||||
|
|
||||||
# Finalize any 'running' ImportBatch that no longer has any
|
# Finalize any 'running' ImportBatch that no longer has any
|
||||||
# active children. The "Scanning..." banner is driven by
|
# active children. The "Scanning..." banner is driven by
|
||||||
@@ -195,7 +198,7 @@ async def clear_stuck():
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"tasks_failed": len(stuck_ids),
|
"tasks_failed": tasks_failed,
|
||||||
"batches_finalized": finalized_batches,
|
"batches_finalized": finalized_batches,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+32
-5
@@ -15,6 +15,7 @@ from ..services.tag_service import (
|
|||||||
TagService,
|
TagService,
|
||||||
TagValidationError,
|
TagValidationError,
|
||||||
)
|
)
|
||||||
|
from ..utils.tag_prefix import parse_kind_prefix
|
||||||
|
|
||||||
tags_bp = Blueprint("tags", __name__, url_prefix="/api")
|
tags_bp = Blueprint("tags", __name__, url_prefix="/api")
|
||||||
|
|
||||||
@@ -105,13 +106,39 @@ async def directory():
|
|||||||
|
|
||||||
@tags_bp.route("/tags", methods=["POST"])
|
@tags_bp.route("/tags", methods=["POST"])
|
||||||
async def create_tag():
|
async def create_tag():
|
||||||
|
"""Create a tag. Two input shapes accepted:
|
||||||
|
1. Explicit: {name, kind, fandom_id?} — caller already split, kind wins.
|
||||||
|
2. IR-suffix: {name} where name = "kind:Name" (e.g. "artist:Eric").
|
||||||
|
The server runs parse_kind_prefix(name) to derive kind; the colon
|
||||||
|
and prefix are stripped from the stored tag name. If no recognized
|
||||||
|
prefix is present, the kind defaults to `general`.
|
||||||
|
Explicit kind ALWAYS wins (backward-compat for existing callers).
|
||||||
|
"""
|
||||||
body = await request.get_json()
|
body = await request.get_json()
|
||||||
if not body or "name" not in body or "kind" not in body:
|
if not body or "name" not in body:
|
||||||
return jsonify({"error": "name and kind required"}), 400
|
return jsonify({"error": "name required"}), 400
|
||||||
name = body["name"]
|
name = body["name"]
|
||||||
kind = _coerce_kind(body["kind"])
|
explicit_kind_raw = body.get("kind")
|
||||||
if kind is None:
|
|
||||||
return jsonify({"error": f"invalid kind {body['kind']!r}"}), 400
|
if explicit_kind_raw is not None:
|
||||||
|
# Caller provided kind — honor it; don't re-parse.
|
||||||
|
kind = _coerce_kind(explicit_kind_raw)
|
||||||
|
if kind is None:
|
||||||
|
return jsonify({"error": f"invalid kind {explicit_kind_raw!r}"}), 400
|
||||||
|
else:
|
||||||
|
# IR-style: parse "kind:Name" from the raw name.
|
||||||
|
parsed_kind, parsed_name = parse_kind_prefix(name)
|
||||||
|
if parsed_kind is not None:
|
||||||
|
name = parsed_name
|
||||||
|
kind = _coerce_kind(parsed_kind)
|
||||||
|
# parse_kind_prefix only returns kinds from KNOWN_KINDS which
|
||||||
|
# are all valid TagKind members, so _coerce_kind can't return
|
||||||
|
# None here — but defensive.
|
||||||
|
if kind is None:
|
||||||
|
return jsonify({"error": f"invalid kind {parsed_kind!r}"}), 400
|
||||||
|
else:
|
||||||
|
kind = TagKind.general
|
||||||
|
|
||||||
fandom_id = body.get("fandom_id")
|
fandom_id = body.get("fandom_id")
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Thumbnail admin API: backfill trigger."""
|
||||||
|
|
||||||
|
from quart import Blueprint, jsonify
|
||||||
|
|
||||||
|
thumbnails_bp = Blueprint("thumbnails", __name__, url_prefix="/api/thumbnails")
|
||||||
|
|
||||||
|
|
||||||
|
@thumbnails_bp.route("/backfill", methods=["POST"])
|
||||||
|
async def trigger_backfill():
|
||||||
|
from ..tasks.thumbnail import backfill_thumbnails
|
||||||
|
|
||||||
|
r = backfill_thumbnails.delay()
|
||||||
|
return jsonify({"celery_task_id": r.id}), 202
|
||||||
@@ -33,6 +33,7 @@ def make_celery() -> Celery:
|
|||||||
"backend.app.tasks.download",
|
"backend.app.tasks.download",
|
||||||
"backend.app.tasks.backup",
|
"backend.app.tasks.backup",
|
||||||
"backend.app.tasks.admin",
|
"backend.app.tasks.admin",
|
||||||
|
"backend.app.tasks.library_audit",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
app.conf.update(
|
app.conf.update(
|
||||||
@@ -47,6 +48,7 @@ def make_celery() -> Celery:
|
|||||||
"backend.app.tasks.migration.*": {"queue": "maintenance"},
|
"backend.app.tasks.migration.*": {"queue": "maintenance"},
|
||||||
"backend.app.tasks.backup.*": {"queue": "maintenance"},
|
"backend.app.tasks.backup.*": {"queue": "maintenance"},
|
||||||
"backend.app.tasks.admin.*": {"queue": "maintenance"},
|
"backend.app.tasks.admin.*": {"queue": "maintenance"},
|
||||||
|
"backend.app.tasks.library_audit.*": {"queue": "maintenance"},
|
||||||
},
|
},
|
||||||
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
|
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
|
||||||
task_acks_late=True,
|
task_acks_late=True,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from .image_record import ImageRecord
|
|||||||
from .import_batch import ImportBatch
|
from .import_batch import ImportBatch
|
||||||
from .import_settings import ImportSettings
|
from .import_settings import ImportSettings
|
||||||
from .import_task import ImportTask
|
from .import_task import ImportTask
|
||||||
|
from .library_audit_run import LibraryAuditRun
|
||||||
from .migration_run import MigrationRun
|
from .migration_run import MigrationRun
|
||||||
from .ml_settings import MLSettings
|
from .ml_settings import MLSettings
|
||||||
from .post import Post
|
from .post import Post
|
||||||
@@ -43,6 +44,7 @@ __all__ = [
|
|||||||
"ImportBatch",
|
"ImportBatch",
|
||||||
"ImportTask",
|
"ImportTask",
|
||||||
"ImportSettings",
|
"ImportSettings",
|
||||||
|
"LibraryAuditRun",
|
||||||
"MLSettings",
|
"MLSettings",
|
||||||
"MigrationRun",
|
"MigrationRun",
|
||||||
"TagAlias",
|
"TagAlias",
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
"""ImageProvenance — links an ImageRecord to a Post.
|
"""ImageProvenance — links an ImageRecord to a Post.
|
||||||
|
|
||||||
Many-to-one (one image, many provenance rows) enables the enrich-on-duplicate
|
One image can have many provenance rows — different posts each contribute
|
||||||
rule (spec §3): when a downloaded image is a pHash dupe of an existing
|
metadata (enrich-on-duplicate rule, spec §3: a downloaded image that is a
|
||||||
record, we append a new provenance row to the existing record rather than
|
pHash dupe of an existing record gets a NEW provenance row for the new post
|
||||||
dropping the metadata.
|
appended, rather than the metadata being dropped). But the (image, post)
|
||||||
|
pair is unique — alembic 0021 enforces uq_image_provenance_image_post
|
||||||
|
after operator-flagged 2026-05-26 saw _apply_sidecar's existence-check +
|
||||||
|
INSERT race plant duplicates that then broke .scalar_one_or_none() on
|
||||||
|
every later deep-scan rederive (MultipleResultsFound).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, func
|
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base
|
||||||
@@ -16,6 +20,12 @@ from .base import Base
|
|||||||
|
|
||||||
class ImageProvenance(Base):
|
class ImageProvenance(Base):
|
||||||
__tablename__ = "image_provenance"
|
__tablename__ = "image_provenance"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"image_record_id", "post_id",
|
||||||
|
name="uq_image_provenance_image_post",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
image_record_id: Mapped[int] = mapped_column(
|
image_record_id: Mapped[int] = mapped_column(
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""LibraryAuditRun — async transparency / single_color audit lifecycle.
|
||||||
|
|
||||||
|
State machine: running → ready → applied / cancelled / error.
|
||||||
|
matched_ids JSONB is appended-to by scan_library_for_rule; apply_audit_run
|
||||||
|
reads it and routes through cleanup_service.delete_images.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryAuditRun(Base):
|
||||||
|
__tablename__ = "library_audit_run"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
rule: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||||
|
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default="running", index=True,
|
||||||
|
)
|
||||||
|
# running | ready | applied | cancelled | error
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||||
|
)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True,
|
||||||
|
)
|
||||||
|
scanned_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
matched_ids: Mapped[list[int]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
@@ -35,8 +35,10 @@ class TagKind(StrEnum):
|
|||||||
series = "series"
|
series = "series"
|
||||||
archive = "archive"
|
archive = "archive"
|
||||||
post = "post"
|
post = "post"
|
||||||
meta = "meta"
|
# `meta` and `rating` retired by operator 2026-05-26 (alembic 0023).
|
||||||
rating = "rating"
|
# `artist` retired in FC-2d-vii-c — artists are first-class entities
|
||||||
|
# via Artist/Source rows now, not tags — but the enum value stays
|
||||||
|
# to keep historic tag rows queryable.
|
||||||
|
|
||||||
|
|
||||||
image_tag = Table(
|
image_tag = Table(
|
||||||
|
|||||||
@@ -111,12 +111,22 @@ class ArtistService:
|
|||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
post_count = (
|
||||||
|
await self.session.execute(
|
||||||
|
select(func.count(func.distinct(Post.id)))
|
||||||
|
.select_from(Post)
|
||||||
|
.join(Source, Source.id == Post.source_id)
|
||||||
|
.where(Source.artist_id == aid)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": artist.id,
|
"id": artist.id,
|
||||||
"name": artist.name,
|
"name": artist.name,
|
||||||
"slug": artist.slug,
|
"slug": artist.slug,
|
||||||
"is_subscription": bool(artist.is_subscription),
|
"is_subscription": bool(artist.is_subscription),
|
||||||
"image_count": int(image_count),
|
"image_count": int(image_count),
|
||||||
|
"post_count": int(post_count),
|
||||||
"date_range": {
|
"date_range": {
|
||||||
"min": dmin.isoformat() if dmin else None,
|
"min": dmin.isoformat() if dmin else None,
|
||||||
"max": dmax.isoformat() if dmax else None,
|
"max": dmax.isoformat() if dmax else None,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Audit rule modules. Each module exposes evaluate(pil_image, **params) -> bool.
|
||||||
|
|
||||||
|
The retroactive library-cleanup tab and (future) import-time filter logic
|
||||||
|
both consume these. Importers should NOT inline rule logic going forward;
|
||||||
|
add the rule here and call from both sides.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Single-color audit: matches images where one color dominates beyond
|
||||||
|
the threshold (within the given Euclidean RGB tolerance). The first
|
||||||
|
canonical implementation — the import-side filter (SkipReason.single_color)
|
||||||
|
was never wired; FC-Cleanup's audit module is the source of truth and a
|
||||||
|
future spec can adopt it on the import path too.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
_THUMB_SIZE = (64, 64)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
pil_image,
|
||||||
|
*,
|
||||||
|
threshold: float,
|
||||||
|
tolerance: int,
|
||||||
|
) -> bool:
|
||||||
|
"""True iff the fraction of pixels within `tolerance` (Euclidean RGB
|
||||||
|
distance) of the dominant color exceeds `threshold`.
|
||||||
|
|
||||||
|
Downsamples to 64x64 for speed (~4ms regardless of source size).
|
||||||
|
Alpha channels are stripped; only RGB is considered. Animated images
|
||||||
|
use frame 0 (PIL's default after Image.open without seek).
|
||||||
|
"""
|
||||||
|
im = pil_image
|
||||||
|
if im.mode == "RGBA":
|
||||||
|
im = im.convert("RGB")
|
||||||
|
elif im.mode not in ("RGB", "L"):
|
||||||
|
im = im.convert("RGB")
|
||||||
|
if im.size != _THUMB_SIZE:
|
||||||
|
im = im.resize(_THUMB_SIZE, Image.Resampling.BILINEAR)
|
||||||
|
pixels = list(im.getdata())
|
||||||
|
if not pixels:
|
||||||
|
return False
|
||||||
|
# Normalize L-mode pixels to RGB tuples for distance math.
|
||||||
|
if isinstance(pixels[0], int):
|
||||||
|
pixels = [(p, p, p) for p in pixels]
|
||||||
|
# Dominant color = mean RGB.
|
||||||
|
n = len(pixels)
|
||||||
|
sum_r = sum(p[0] for p in pixels)
|
||||||
|
sum_g = sum(p[1] for p in pixels)
|
||||||
|
sum_b = sum(p[2] for p in pixels)
|
||||||
|
dom = (sum_r / n, sum_g / n, sum_b / n)
|
||||||
|
tol_sq = tolerance * tolerance
|
||||||
|
within = 0
|
||||||
|
for r, g, b in pixels:
|
||||||
|
dr = r - dom[0]
|
||||||
|
dg = g - dom[1]
|
||||||
|
db = b - dom[2]
|
||||||
|
if dr * dr + dg * dg + db * db <= tol_sq:
|
||||||
|
within += 1
|
||||||
|
return (within / n) > threshold
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Transparency audit: matches images whose transparent-pixel fraction
|
||||||
|
exceeds the threshold. Animated images short-circuit (skipped) to avoid
|
||||||
|
the multi-frame PIL decode that hits Celery's hard time limit."""
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(pil_image, *, threshold: float) -> bool:
|
||||||
|
"""True iff the image's transparent-pixel fraction exceeds threshold.
|
||||||
|
|
||||||
|
False for non-alpha modes and animated images. Mirrors the import-side
|
||||||
|
Importer._transparency_pct logic so retroactive enforcement matches
|
||||||
|
prospective filtering.
|
||||||
|
"""
|
||||||
|
if getattr(pil_image, "is_animated", False):
|
||||||
|
return False
|
||||||
|
if pil_image.mode not in ("RGBA", "LA") and not (
|
||||||
|
pil_image.mode == "P" and "transparency" in pil_image.info
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
im = pil_image
|
||||||
|
if im.mode != "RGBA":
|
||||||
|
im = im.convert("RGBA")
|
||||||
|
alpha = im.getchannel("A")
|
||||||
|
histogram = alpha.histogram()
|
||||||
|
transparent = histogram[0]
|
||||||
|
total = sum(histogram)
|
||||||
|
pct = transparent / total if total else 0.0
|
||||||
|
return pct > threshold
|
||||||
@@ -12,12 +12,14 @@ re-exports from this module and then delete the wrapper.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..models import Artist, ImageRecord, Tag
|
from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
|
||||||
from ..models.series_page import SeriesPage
|
from ..models.series_page import SeriesPage
|
||||||
from ..models.tag import image_tag
|
from ..models.tag import image_tag
|
||||||
|
|
||||||
@@ -365,3 +367,146 @@ def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict:
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"deleted": len(ids), "sample_names": sample}
|
return {"deleted": len(ids), "sample_names": sample}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MIN_DIM_SAMPLE_CAP = 50
|
||||||
|
|
||||||
|
|
||||||
|
def project_min_dimension_violations(
|
||||||
|
session: Session, *, min_width: int, min_height: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Return {count, sample_ids} for image_record rows with width or
|
||||||
|
height below the thresholds. Synchronous SQL — no PIL inspection
|
||||||
|
needed since width/height are stored columns."""
|
||||||
|
base = select(ImageRecord.id).where(
|
||||||
|
(ImageRecord.width < min_width) | (ImageRecord.height < min_height)
|
||||||
|
)
|
||||||
|
count = session.execute(
|
||||||
|
select(func.count()).select_from(base.subquery())
|
||||||
|
).scalar_one()
|
||||||
|
sample_ids = session.execute(
|
||||||
|
base.order_by(ImageRecord.id).limit(_MIN_DIM_SAMPLE_CAP)
|
||||||
|
).scalars().all()
|
||||||
|
return {"count": count, "sample_ids": list(sample_ids)}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_min_dimension_violations(
|
||||||
|
session: Session, *, min_width: int, min_height: int, images_root: Path,
|
||||||
|
) -> int:
|
||||||
|
"""Delete every image_record where width<min_w OR height<min_h.
|
||||||
|
Routes through delete_images so file-unlink + cascading FKs
|
||||||
|
(image_tag / image_provenance / etc.) are handled uniformly."""
|
||||||
|
ids = session.execute(
|
||||||
|
select(ImageRecord.id).where(
|
||||||
|
(ImageRecord.width < min_width) | (ImageRecord.height < min_height)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if not ids:
|
||||||
|
return 0
|
||||||
|
result = delete_images(
|
||||||
|
session, image_ids=list(ids), images_root=images_root,
|
||||||
|
)
|
||||||
|
return result["images_deleted"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Audit lifecycle (transparency + single_color async scans).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAlreadyRunning(Exception):
|
||||||
|
"""Another audit_run is currently in status='running' — wait or
|
||||||
|
cancel it before starting a new one. Surfaces as HTTP 409 in the
|
||||||
|
/api/cleanup/audit POST endpoint."""
|
||||||
|
|
||||||
|
|
||||||
|
class AuditNotReady(Exception):
|
||||||
|
"""apply_audit_run called on an audit whose status is not 'ready'."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmTokenMismatch(Exception):
|
||||||
|
"""Operator-supplied confirm token did not match server-recomputed token."""
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_RULES = ("transparency", "single_color")
|
||||||
|
|
||||||
|
|
||||||
|
def start_audit_run(
|
||||||
|
session: Session, *, rule: str, params: dict[str, Any],
|
||||||
|
) -> int:
|
||||||
|
"""Create a LibraryAuditRun row in status='running' and dispatch the
|
||||||
|
scan_library_for_rule Celery task. Returns the new audit_id.
|
||||||
|
|
||||||
|
Concurrent-runs guard: raises AuditAlreadyRunning if any audit_run
|
||||||
|
has status='running'. Operator must cancel or wait."""
|
||||||
|
if rule not in _VALID_RULES:
|
||||||
|
raise ValueError(f"unknown rule {rule!r}; expected one of {_VALID_RULES}")
|
||||||
|
existing = session.execute(
|
||||||
|
select(LibraryAuditRun.id).where(LibraryAuditRun.status == "running")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise AuditAlreadyRunning(existing)
|
||||||
|
audit = LibraryAuditRun(
|
||||||
|
rule=rule,
|
||||||
|
params=params,
|
||||||
|
status="running",
|
||||||
|
scanned_count=0,
|
||||||
|
matched_count=0,
|
||||||
|
matched_ids=[],
|
||||||
|
)
|
||||||
|
session.add(audit)
|
||||||
|
session.flush()
|
||||||
|
audit_id = audit.id
|
||||||
|
# Dispatch after flush so audit_id is populated; commit happens in
|
||||||
|
# the API handler so the audit row + dispatch are visible together.
|
||||||
|
from ..tasks.library_audit import scan_library_for_rule
|
||||||
|
scan_library_for_rule.delay(audit_id)
|
||||||
|
return audit_id
|
||||||
|
|
||||||
|
|
||||||
|
def apply_audit_run(
|
||||||
|
session: Session, *, audit_id: int, confirm_token: str, images_root: Path,
|
||||||
|
) -> int:
|
||||||
|
"""Delete all images in audit_run.matched_ids after confirming token.
|
||||||
|
Marks audit status='applied'. Routes through delete_images so files
|
||||||
|
+ cascading FK rows are handled uniformly."""
|
||||||
|
audit = session.execute(
|
||||||
|
select(LibraryAuditRun).where(LibraryAuditRun.id == audit_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if audit is None:
|
||||||
|
raise ValueError(f"audit_run {audit_id} not found")
|
||||||
|
if audit.status != "ready":
|
||||||
|
raise AuditNotReady(audit.status)
|
||||||
|
# Token format matches modal/DestructiveConfirmModal.vue convention:
|
||||||
|
# ${action}-${kind}-${runId}. The modal hardcodes action ∈ {'restore',
|
||||||
|
# 'delete'}; "apply audit" is semantically a delete of the matched
|
||||||
|
# images, so we use 'delete-audit-<id>' (not 'apply-audit-<id>').
|
||||||
|
expected = f"delete-audit-{audit_id}"
|
||||||
|
if confirm_token != expected:
|
||||||
|
raise ConfirmTokenMismatch(expected)
|
||||||
|
ids = list(audit.matched_ids or [])
|
||||||
|
deleted = 0
|
||||||
|
if ids:
|
||||||
|
result = delete_images(session, image_ids=ids, images_root=images_root)
|
||||||
|
deleted = result["images_deleted"]
|
||||||
|
session.execute(
|
||||||
|
update(LibraryAuditRun)
|
||||||
|
.where(LibraryAuditRun.id == audit_id)
|
||||||
|
.values(status="applied", finished_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_audit_run(session: Session, *, audit_id: int) -> None:
|
||||||
|
"""Flip a running audit_run to 'cancelled'. The scan task checks
|
||||||
|
for status=='cancelled' between batches and exits cleanly."""
|
||||||
|
session.execute(
|
||||||
|
update(LibraryAuditRun)
|
||||||
|
.where(LibraryAuditRun.id == audit_id)
|
||||||
|
.where(LibraryAuditRun.status == "running")
|
||||||
|
.values(status="cancelled", finished_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ class CredentialService:
|
|||||||
return None
|
return None
|
||||||
plaintext = self.crypto.decrypt(row.encrypted_blob)
|
plaintext = self.crypto.decrypt(row.encrypted_blob)
|
||||||
netscape = _to_netscape(plaintext)
|
netscape = _to_netscape(plaintext)
|
||||||
|
netscape = _augment_cookies(platform, netscape)
|
||||||
self.cookies_dir.mkdir(parents=True, exist_ok=True)
|
self.cookies_dir.mkdir(parents=True, exist_ok=True)
|
||||||
out = self.cookies_dir / f"{platform}_cookies.txt"
|
out = self.cookies_dir / f"{platform}_cookies.txt"
|
||||||
out.write_text(netscape)
|
out.write_text(netscape)
|
||||||
@@ -163,6 +164,19 @@ class CredentialService:
|
|||||||
return self.crypto.decrypt(row.encrypted_blob)
|
return self.crypto.decrypt(row.encrypted_blob)
|
||||||
|
|
||||||
|
|
||||||
|
def _augment_cookies(platform: str, netscape: str) -> str:
|
||||||
|
"""Delegate to the platform's `augment_cookies` hook if one is
|
||||||
|
registered (subscribestar, hentaifoundry, etc. — see
|
||||||
|
`services/platforms/<name>.py`). No-op when the platform doesn't
|
||||||
|
register a hook (Patreon, DeviantArt). Centralizing the
|
||||||
|
quirks-per-platform in the platforms package means adding a new
|
||||||
|
platform's cookie quirks doesn't require touching this file."""
|
||||||
|
info = PLATFORMS.get(platform)
|
||||||
|
if info is None or info.augment_cookies is None:
|
||||||
|
return netscape
|
||||||
|
return info.augment_cookies(netscape)
|
||||||
|
|
||||||
|
|
||||||
def _to_netscape(plaintext: str) -> str:
|
def _to_netscape(plaintext: str) -> str:
|
||||||
"""Accept either Netscape-format text (the extension's output) or a
|
"""Accept either Netscape-format text (the extension's output) or a
|
||||||
JSON array of cookie dicts (a manual-paste edge case); produce
|
JSON array of cookie dicts (a manual-paste edge case); produce
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models import Artist, Source
|
from ..models import Artist, Source
|
||||||
@@ -97,20 +98,40 @@ class ExtensionService:
|
|||||||
raise UnknownPlatformError(f"no platform pattern matched {url!r}")
|
raise UnknownPlatformError(f"no platform pattern matched {url!r}")
|
||||||
|
|
||||||
async def _find_or_create_artist(self, raw_name: str) -> tuple[Artist, bool]:
|
async def _find_or_create_artist(self, raw_name: str) -> tuple[Artist, bool]:
|
||||||
|
"""Race-safe find-or-create on Artist by slug. Mirrors the
|
||||||
|
savepoint + IntegrityError recovery pattern used in
|
||||||
|
Importer._find_or_create_source/post (see
|
||||||
|
reference_scalar_one_or_none_duplicates memory). Without this,
|
||||||
|
two concurrent quick-add-source calls hitting the same artist
|
||||||
|
would both miss the existence check and the second INSERT would
|
||||||
|
500 against uq_artist_slug.
|
||||||
|
"""
|
||||||
slug = slugify(raw_name)
|
slug = slugify(raw_name)
|
||||||
existing = (await self.session.execute(
|
existing = (await self.session.execute(
|
||||||
select(Artist).where(Artist.slug == slug)
|
select(Artist).where(Artist.slug == slug)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing, False
|
return existing, False
|
||||||
artist = Artist(name=raw_name, slug=slug, is_subscription=True)
|
sp = await self.session.begin_nested()
|
||||||
self.session.add(artist)
|
try:
|
||||||
await self.session.flush()
|
artist = Artist(name=raw_name, slug=slug, is_subscription=True)
|
||||||
return artist, True
|
self.session.add(artist)
|
||||||
|
await self.session.flush()
|
||||||
|
await sp.commit()
|
||||||
|
return artist, True
|
||||||
|
except IntegrityError:
|
||||||
|
await sp.rollback()
|
||||||
|
recovered = (await self.session.execute(
|
||||||
|
select(Artist).where(Artist.slug == slug)
|
||||||
|
)).scalar_one()
|
||||||
|
return recovered, False
|
||||||
|
|
||||||
async def _find_or_create_source(
|
async def _find_or_create_source(
|
||||||
self, *, artist_id: int, platform: str, url: str,
|
self, *, artist_id: int, platform: str, url: str,
|
||||||
) -> tuple[Source, bool]:
|
) -> tuple[Source, bool]:
|
||||||
|
"""Race-safe — same pattern as _find_or_create_artist above. The
|
||||||
|
uq_source_artist_platform_url constraint catches the duplicate
|
||||||
|
insert; we roll the savepoint back and re-select."""
|
||||||
existing = (await self.session.execute(
|
existing = (await self.session.execute(
|
||||||
select(Source).where(
|
select(Source).where(
|
||||||
Source.artist_id == artist_id,
|
Source.artist_id == artist_id,
|
||||||
@@ -120,8 +141,24 @@ class ExtensionService:
|
|||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing, False
|
return existing, False
|
||||||
src = Source(artist_id=artist_id, platform=platform, url=url, enabled=True)
|
sp = await self.session.begin_nested()
|
||||||
self.session.add(src)
|
try:
|
||||||
await self.session.flush()
|
src = Source(
|
||||||
|
artist_id=artist_id, platform=platform,
|
||||||
|
url=url, enabled=True,
|
||||||
|
)
|
||||||
|
self.session.add(src)
|
||||||
|
await self.session.flush()
|
||||||
|
await sp.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await sp.rollback()
|
||||||
|
recovered = (await self.session.execute(
|
||||||
|
select(Source).where(
|
||||||
|
Source.artist_id == artist_id,
|
||||||
|
Source.platform == platform,
|
||||||
|
Source.url == url,
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
return recovered, False
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
return src, True
|
return src, True
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
@@ -202,6 +203,136 @@ class Importer:
|
|||||||
(phash, width or 0, height or 0, image_id)
|
(phash, width or 0, height or 0, image_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _find_or_create_source(
|
||||||
|
self, *, artist_id: int, platform: str, url: str,
|
||||||
|
) -> Source:
|
||||||
|
"""Race-safe find-or-create on `source` keyed by
|
||||||
|
(artist_id, platform, url) — the same key as the
|
||||||
|
`uq_source_artist_platform_url` constraint.
|
||||||
|
|
||||||
|
Two concurrent workers processing different files in the same
|
||||||
|
post can both find no existing Source row then both INSERT,
|
||||||
|
which trips the unique constraint and poisons the session with
|
||||||
|
`psycopg.errors.UniqueViolation`. Operator-flagged 2026-05-26.
|
||||||
|
|
||||||
|
Pattern: select; if absent, open a savepoint and INSERT.
|
||||||
|
On IntegrityError, roll the savepoint back (NOT the outer
|
||||||
|
transaction, which would lose the surrounding scan's progress)
|
||||||
|
and re-select — the concurrent op just created the row we
|
||||||
|
wanted, so the second select will find it.
|
||||||
|
"""
|
||||||
|
existing = self.session.execute(
|
||||||
|
select(Source).where(
|
||||||
|
Source.artist_id == artist_id,
|
||||||
|
Source.platform == platform,
|
||||||
|
Source.url == url,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
sp = self.session.begin_nested()
|
||||||
|
try:
|
||||||
|
row = Source(artist_id=artist_id, platform=platform, url=url)
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.flush()
|
||||||
|
sp.commit()
|
||||||
|
return row
|
||||||
|
except IntegrityError:
|
||||||
|
sp.rollback()
|
||||||
|
return self.session.execute(
|
||||||
|
select(Source).where(
|
||||||
|
Source.artist_id == artist_id,
|
||||||
|
Source.platform == platform,
|
||||||
|
Source.url == url,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
def _source_for_sidecar(
|
||||||
|
self, *, artist_id: int, platform: str, artist_slug: str,
|
||||||
|
) -> Source:
|
||||||
|
"""Filesystem-import sidecar Source resolver.
|
||||||
|
|
||||||
|
Source represents a subscription feed (one per artist+platform — the
|
||||||
|
gallery-dl URL polled by the FC-3 downloader). The filesystem importer
|
||||||
|
used to call _find_or_create_source(url=sd.post_url), which created
|
||||||
|
one Source row per post URL — 100s of junk Sources per artist, all
|
||||||
|
with enabled=True, polluting the artist detail page and tricking the
|
||||||
|
subscription checker into trying to poll patreon post URLs as feeds.
|
||||||
|
Operator-flagged 2026-05-26.
|
||||||
|
|
||||||
|
New behaviour: if any Source row exists for (artist_id, platform),
|
||||||
|
reuse it regardless of its URL — the artist's real subscription Source
|
||||||
|
(created by the downloader / extension / UI) is the canonical
|
||||||
|
attachment point for filesystem-imported posts. If none exists, create
|
||||||
|
ONE synthetic anchor with url='sidecar:<platform>:<artist_slug>' and
|
||||||
|
enabled=False (so the subscription checker doesn't poll it).
|
||||||
|
"""
|
||||||
|
existing = self.session.execute(
|
||||||
|
select(Source)
|
||||||
|
.where(
|
||||||
|
Source.artist_id == artist_id,
|
||||||
|
Source.platform == platform,
|
||||||
|
)
|
||||||
|
.order_by(Source.id.asc())
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
synthetic_url = f"sidecar:{platform}:{artist_slug}"
|
||||||
|
sp = self.session.begin_nested()
|
||||||
|
try:
|
||||||
|
row = Source(
|
||||||
|
artist_id=artist_id,
|
||||||
|
platform=platform,
|
||||||
|
url=synthetic_url,
|
||||||
|
enabled=False,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.flush()
|
||||||
|
sp.commit()
|
||||||
|
return row
|
||||||
|
except IntegrityError:
|
||||||
|
sp.rollback()
|
||||||
|
return self.session.execute(
|
||||||
|
select(Source)
|
||||||
|
.where(
|
||||||
|
Source.artist_id == artist_id,
|
||||||
|
Source.platform == platform,
|
||||||
|
)
|
||||||
|
.order_by(Source.id.asc())
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
def _find_or_create_post(
|
||||||
|
self, *, source_id: int, external_post_id: str,
|
||||||
|
) -> Post:
|
||||||
|
"""Race-safe find-or-create on `post` keyed by
|
||||||
|
(source_id, external_post_id). Mirrors `_find_or_create_source`
|
||||||
|
— same savepoint + IntegrityError-recovery pattern."""
|
||||||
|
existing = self.session.execute(
|
||||||
|
select(Post).where(
|
||||||
|
Post.source_id == source_id,
|
||||||
|
Post.external_post_id == external_post_id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
sp = self.session.begin_nested()
|
||||||
|
try:
|
||||||
|
row = Post(source_id=source_id, external_post_id=external_post_id)
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.flush()
|
||||||
|
sp.commit()
|
||||||
|
return row
|
||||||
|
except IntegrityError:
|
||||||
|
sp.rollback()
|
||||||
|
return self.session.execute(
|
||||||
|
select(Post).where(
|
||||||
|
Post.source_id == source_id,
|
||||||
|
Post.external_post_id == external_post_id,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
def import_one(self, source: Path) -> ImportResult:
|
def import_one(self, source: Path) -> ImportResult:
|
||||||
"""Dispatch by kind. Media → normal pipeline. Archive → extract
|
"""Dispatch by kind. Media → normal pipeline. Archive → extract
|
||||||
media members (one Post via the archive-adjacent sidecar) and
|
media members (one Post via the archive-adjacent sidecar) and
|
||||||
@@ -240,30 +371,13 @@ class Importer:
|
|||||||
return None
|
return None
|
||||||
sd = parse_sidecar(data)
|
sd = parse_sidecar(data)
|
||||||
platform = sd.platform or "unknown"
|
platform = sd.platform or "unknown"
|
||||||
url = sd.post_url or f"sidecar:{platform}"
|
src = self._source_for_sidecar(
|
||||||
src = self.session.execute(
|
artist_id=artist.id, platform=platform, artist_slug=artist.slug,
|
||||||
select(Source).where(
|
)
|
||||||
Source.artist_id == artist.id,
|
|
||||||
Source.platform == platform,
|
|
||||||
Source.url == url,
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if src is None:
|
|
||||||
src = Source(artist_id=artist.id, platform=platform, url=url)
|
|
||||||
self.session.add(src)
|
|
||||||
self.session.flush()
|
|
||||||
epid = sd.external_post_id or sc.stem
|
epid = sd.external_post_id or sc.stem
|
||||||
post = self.session.execute(
|
return self._find_or_create_post(
|
||||||
select(Post).where(
|
source_id=src.id, external_post_id=epid,
|
||||||
Post.source_id == src.id,
|
)
|
||||||
Post.external_post_id == epid,
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if post is None:
|
|
||||||
post = Post(source_id=src.id, external_post_id=epid)
|
|
||||||
self.session.add(post)
|
|
||||||
self.session.flush()
|
|
||||||
return post
|
|
||||||
|
|
||||||
def _capture_attachment(
|
def _capture_attachment(
|
||||||
self, source: Path, *, post: Post | None = None,
|
self, source: Path, *, post: Post | None = None,
|
||||||
@@ -704,30 +818,15 @@ class Importer:
|
|||||||
src = explicit_source
|
src = explicit_source
|
||||||
else:
|
else:
|
||||||
platform = sd.platform or "unknown"
|
platform = sd.platform or "unknown"
|
||||||
url = sd.post_url or f"sidecar:{platform}"
|
src = self._source_for_sidecar(
|
||||||
src = self.session.execute(
|
artist_id=artist.id, platform=platform,
|
||||||
select(Source).where(
|
artist_slug=artist.slug,
|
||||||
Source.artist_id == artist.id,
|
)
|
||||||
Source.platform == platform,
|
|
||||||
Source.url == url,
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if src is None:
|
|
||||||
src = Source(artist_id=artist.id, platform=platform, url=url)
|
|
||||||
self.session.add(src)
|
|
||||||
self.session.flush()
|
|
||||||
|
|
||||||
epid = sd.external_post_id or sc.stem
|
epid = sd.external_post_id or sc.stem
|
||||||
post = self.session.execute(
|
post = self._find_or_create_post(
|
||||||
select(Post).where(
|
source_id=src.id, external_post_id=epid,
|
||||||
Post.source_id == src.id,
|
)
|
||||||
Post.external_post_id == epid,
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if post is None:
|
|
||||||
post = Post(source_id=src.id, external_post_id=epid)
|
|
||||||
self.session.add(post)
|
|
||||||
self.session.flush()
|
|
||||||
if sd.post_url is not None:
|
if sd.post_url is not None:
|
||||||
post.post_url = sd.post_url
|
post.post_url = sd.post_url
|
||||||
if sd.post_title is not None:
|
if sd.post_title is not None:
|
||||||
@@ -740,6 +839,15 @@ class Importer:
|
|||||||
post.attachment_count = sd.attachment_count
|
post.attachment_count = sd.attachment_count
|
||||||
post.raw_metadata = sd.raw
|
post.raw_metadata = sd.raw
|
||||||
|
|
||||||
|
# Race-safe (image_record_id, post_id) upsert — mirrors the
|
||||||
|
# _find_or_create_source/post savepoint pattern. The plain
|
||||||
|
# SELECT-then-INSERT pattern lost a race when two workers ran
|
||||||
|
# _apply_sidecar on the same (image, post) pair (e.g. the 5-min
|
||||||
|
# recovery sweep re-enqueued a still-running long import), planting
|
||||||
|
# duplicates that then broke .scalar_one_or_none() on every later
|
||||||
|
# deep-scan rederive (MultipleResultsFound). Alembic 0021 adds the
|
||||||
|
# uq_image_provenance_image_post UNIQUE so this savepoint actually
|
||||||
|
# trips on collision.
|
||||||
exists = self.session.execute(
|
exists = self.session.execute(
|
||||||
select(ImageProvenance.id).where(
|
select(ImageProvenance.id).where(
|
||||||
ImageProvenance.image_record_id == record.id,
|
ImageProvenance.image_record_id == record.id,
|
||||||
@@ -747,14 +855,20 @@ class Importer:
|
|||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if exists is None:
|
if exists is None:
|
||||||
self.session.add(
|
sp = self.session.begin_nested()
|
||||||
ImageProvenance(
|
try:
|
||||||
image_record_id=record.id,
|
self.session.add(
|
||||||
post_id=post.id,
|
ImageProvenance(
|
||||||
source_id=src.id,
|
image_record_id=record.id,
|
||||||
captured_metadata=sd.raw,
|
post_id=post.id,
|
||||||
|
source_id=src.id,
|
||||||
|
captured_metadata=sd.raw,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
self.session.flush()
|
||||||
|
sp.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
sp.rollback()
|
||||||
if record.primary_post_id is None:
|
if record.primary_post_id is None:
|
||||||
record.primary_post_id = post.id
|
record.primary_post_id = post.id
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
@@ -860,8 +974,26 @@ class Importer:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _transparency_pct(self, source: Path) -> float:
|
def _transparency_pct(self, source: Path) -> float:
|
||||||
"""Fraction of fully-transparent pixels in the image. 0.0 if no alpha."""
|
"""Fraction of fully-transparent pixels in the image. 0.0 if no alpha.
|
||||||
|
|
||||||
|
For animated formats (multi-frame WebP / GIF / APNG), short-circuit
|
||||||
|
to 0.0 instead of decoding every frame. PIL's `getchannel("A")`
|
||||||
|
forces a full decode of all frames in an animated image, which for
|
||||||
|
a large animated WebP takes 5+ minutes and blows past the Celery
|
||||||
|
soft+hard time limits (300s/360s → SIGKILL). Operator-flagged
|
||||||
|
2026-05-26. Transparency analysis on a multi-frame image isn't
|
||||||
|
meaningful for art-curation purposes anyway — different frames
|
||||||
|
have different alpha — so the existing too_transparent skip rule
|
||||||
|
is bypassed entirely for animated content.
|
||||||
|
"""
|
||||||
with Image.open(source) as im:
|
with Image.open(source) as im:
|
||||||
|
if getattr(im, "is_animated", False):
|
||||||
|
log.info(
|
||||||
|
"skipping transparency check for animated image %s "
|
||||||
|
"(n_frames=%d) — avoids multi-frame decode timeout",
|
||||||
|
source, getattr(im, "n_frames", 0),
|
||||||
|
)
|
||||||
|
return 0.0
|
||||||
if im.mode not in ("RGBA", "LA") and not (
|
if im.mode not in ("RGBA", "LA") and not (
|
||||||
im.mode == "P" and "transparency" in im.info
|
im.mode == "P" and "transparency" in im.info
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
"""FC-3b platforms registry — the single source of truth for what
|
|
||||||
FabledCurator supports.
|
|
||||||
|
|
||||||
Lifted from GallerySubscriber's
|
|
||||||
~/Nextcloud/Projects/GallerySubscriber/backend/app/api/platforms.py
|
|
||||||
and ~/.../extension/lib/platforms.js. Six platforms; auth_type and
|
|
||||||
URL patterns match GS exactly so the existing browser extension
|
|
||||||
hits FC unmodified.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PlatformInfo:
|
|
||||||
key: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
auth_type: Literal["cookies", "token"]
|
|
||||||
requires_auth: bool
|
|
||||||
url_pattern: str
|
|
||||||
url_examples: list[str]
|
|
||||||
default_config: dict
|
|
||||||
notes: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# Common defaults used across most platforms; embedded per-platform
|
|
||||||
# below so per-platform overrides remain explicit.
|
|
||||||
_DEFAULTS = {
|
|
||||||
"sleep": 3.0,
|
|
||||||
"sleep_request": 1.5,
|
|
||||||
"skip_existing": True,
|
|
||||||
"save_metadata": True,
|
|
||||||
"timeout": 3600,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
PLATFORMS: dict[str, PlatformInfo] = {
|
|
||||||
"patreon": PlatformInfo(
|
|
||||||
key="patreon",
|
|
||||||
name="Patreon",
|
|
||||||
description="Download posts from Patreon creators",
|
|
||||||
auth_type="cookies",
|
|
||||||
requires_auth=True,
|
|
||||||
url_pattern=r"^https?://(www\.)?patreon\.com/",
|
|
||||||
url_examples=[
|
|
||||||
"https://www.patreon.com/example_artist",
|
|
||||||
"https://www.patreon.com/user?u=12345678",
|
|
||||||
],
|
|
||||||
default_config={**_DEFAULTS, "content_types": ["images", "attachments"]},
|
|
||||||
),
|
|
||||||
"subscribestar": PlatformInfo(
|
|
||||||
key="subscribestar",
|
|
||||||
name="SubscribeStar",
|
|
||||||
description="Download posts from SubscribeStar creators",
|
|
||||||
auth_type="cookies",
|
|
||||||
requires_auth=True,
|
|
||||||
url_pattern=r"^https?://(www\.)?subscribestar\.(com|adult)/",
|
|
||||||
url_examples=[
|
|
||||||
"https://subscribestar.adult/example_artist",
|
|
||||||
"https://www.subscribestar.com/example_artist",
|
|
||||||
],
|
|
||||||
default_config={**_DEFAULTS, "content_types": ["all"]},
|
|
||||||
),
|
|
||||||
"hentaifoundry": PlatformInfo(
|
|
||||||
key="hentaifoundry",
|
|
||||||
name="Hentai Foundry",
|
|
||||||
description="Download artwork from Hentai Foundry artists",
|
|
||||||
auth_type="cookies",
|
|
||||||
requires_auth=False,
|
|
||||||
url_pattern=r"^https?://(www\.)?hentai-foundry\.com/",
|
|
||||||
url_examples=[
|
|
||||||
"https://www.hentai-foundry.com/user/example_artist",
|
|
||||||
"https://www.hentai-foundry.com/pictures/user/example_artist",
|
|
||||||
],
|
|
||||||
default_config={**_DEFAULTS, "content_types": ["pictures"]},
|
|
||||||
),
|
|
||||||
"discord": PlatformInfo(
|
|
||||||
key="discord",
|
|
||||||
name="Discord",
|
|
||||||
description="Download attachments from Discord channels",
|
|
||||||
auth_type="token",
|
|
||||||
requires_auth=True,
|
|
||||||
url_pattern=r"^https?://(www\.)?discord\.com/channels/",
|
|
||||||
url_examples=["https://discord.com/channels/123456789/987654321"],
|
|
||||||
default_config={**_DEFAULTS, "content_types": ["all"]},
|
|
||||||
notes="Requires Discord user token (not bot token).",
|
|
||||||
),
|
|
||||||
"pixiv": PlatformInfo(
|
|
||||||
key="pixiv",
|
|
||||||
name="Pixiv",
|
|
||||||
description="Download artwork from Pixiv artists",
|
|
||||||
auth_type="token",
|
|
||||||
requires_auth=True,
|
|
||||||
url_pattern=r"^https?://(www\.)?pixiv\.net/",
|
|
||||||
url_examples=[
|
|
||||||
"https://www.pixiv.net/users/12345678",
|
|
||||||
"https://www.pixiv.net/en/users/12345678",
|
|
||||||
],
|
|
||||||
default_config={**_DEFAULTS, "content_types": ["all"]},
|
|
||||||
notes="Requires OAuth refresh token. Run `gallery-dl oauth:pixiv` to obtain one.",
|
|
||||||
),
|
|
||||||
"deviantart": PlatformInfo(
|
|
||||||
key="deviantart",
|
|
||||||
name="DeviantArt",
|
|
||||||
description="Download artwork from DeviantArt artists",
|
|
||||||
auth_type="cookies",
|
|
||||||
requires_auth=False,
|
|
||||||
url_pattern=r"^https?://(www\.)?deviantart\.com/",
|
|
||||||
url_examples=[
|
|
||||||
"https://www.deviantart.com/example-artist",
|
|
||||||
"https://www.deviantart.com/example-artist/gallery",
|
|
||||||
],
|
|
||||||
default_config={**_DEFAULTS, "content_types": ["gallery"]},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def known_platform_keys() -> frozenset[str]:
|
|
||||||
return frozenset(PLATFORMS.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def auth_type_for(platform: str) -> str | None:
|
|
||||||
info = PLATFORMS.get(platform)
|
|
||||||
return info.auth_type if info else None
|
|
||||||
|
|
||||||
|
|
||||||
def to_dict(info: PlatformInfo) -> dict:
|
|
||||||
return {
|
|
||||||
"key": info.key,
|
|
||||||
"name": info.name,
|
|
||||||
"description": info.description,
|
|
||||||
"auth_type": info.auth_type,
|
|
||||||
"requires_auth": info.requires_auth,
|
|
||||||
"url_pattern": info.url_pattern,
|
|
||||||
"url_examples": info.url_examples,
|
|
||||||
"default_config": info.default_config,
|
|
||||||
"notes": info.notes,
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""FC-3b platforms registry — single source of truth for what
|
||||||
|
FabledCurator supports + where each platform's quirks live.
|
||||||
|
|
||||||
|
Adding a new platform: drop a new module `<platform>.py` next to this
|
||||||
|
one, declare an `INFO = PlatformInfo(...)`, add the import + entry in
|
||||||
|
PLATFORMS below. Sidecar parsing, cookie materialization, and
|
||||||
|
`/api/platforms` pick it up automatically.
|
||||||
|
|
||||||
|
Lifted from GallerySubscriber's
|
||||||
|
~/Nextcloud/Projects/GallerySubscriber/backend/app/api/platforms.py
|
||||||
|
and ~/.../extension/lib/platforms.js. Six platforms; auth_type and
|
||||||
|
URL patterns match GS exactly so the existing browser extension
|
||||||
|
hits FC unmodified.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
DEFAULT_DESCRIPTION_KEYS,
|
||||||
|
DEFAULT_EXTERNAL_POST_ID_KEYS,
|
||||||
|
PlatformInfo,
|
||||||
|
)
|
||||||
|
from .deviantart import INFO as _DEVIANTART
|
||||||
|
from .discord import INFO as _DISCORD
|
||||||
|
from .hentaifoundry import INFO as _HENTAIFOUNDRY
|
||||||
|
from .patreon import INFO as _PATREON
|
||||||
|
from .pixiv import INFO as _PIXIV
|
||||||
|
from .subscribestar import INFO as _SUBSCRIBESTAR
|
||||||
|
|
||||||
|
PLATFORMS: dict[str, PlatformInfo] = {
|
||||||
|
info.key: info
|
||||||
|
for info in (
|
||||||
|
_PATREON,
|
||||||
|
_SUBSCRIBESTAR,
|
||||||
|
_HENTAIFOUNDRY,
|
||||||
|
_DISCORD,
|
||||||
|
_PIXIV,
|
||||||
|
_DEVIANTART,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def known_platform_keys() -> frozenset[str]:
|
||||||
|
return frozenset(PLATFORMS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def auth_type_for(platform: str) -> str | None:
|
||||||
|
info = PLATFORMS.get(platform)
|
||||||
|
return info.auth_type if info else None
|
||||||
|
|
||||||
|
|
||||||
|
def to_dict(info: PlatformInfo) -> dict:
|
||||||
|
"""Serialize a PlatformInfo to a JSON-safe dict for /api/platforms.
|
||||||
|
|
||||||
|
Behavioral fields (callables, sidecar-chain overrides) are
|
||||||
|
intentionally omitted — they aren't useful to API consumers.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"key": info.key,
|
||||||
|
"name": info.name,
|
||||||
|
"description": info.description,
|
||||||
|
"auth_type": info.auth_type,
|
||||||
|
"requires_auth": info.requires_auth,
|
||||||
|
"url_pattern": info.url_pattern,
|
||||||
|
"url_examples": info.url_examples,
|
||||||
|
"default_config": info.default_config,
|
||||||
|
"notes": info.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def external_post_id_keys_for(platform: str | None) -> tuple[str, ...]:
|
||||||
|
"""Resolve the external_post_id lookup chain for a given platform,
|
||||||
|
falling back to the module default when the platform isn't
|
||||||
|
registered or hasn't overridden the chain."""
|
||||||
|
info = PLATFORMS.get(platform) if platform else None
|
||||||
|
if info is not None and info.external_post_id_keys is not None:
|
||||||
|
return info.external_post_id_keys
|
||||||
|
return DEFAULT_EXTERNAL_POST_ID_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
def description_keys_for(platform: str | None) -> tuple[str, ...]:
|
||||||
|
"""Resolve the description body lookup chain for a given platform."""
|
||||||
|
info = PLATFORMS.get(platform) if platform else None
|
||||||
|
if info is not None and info.description_keys is not None:
|
||||||
|
return info.description_keys
|
||||||
|
return DEFAULT_DESCRIPTION_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PLATFORMS",
|
||||||
|
"PlatformInfo",
|
||||||
|
"auth_type_for",
|
||||||
|
"description_keys_for",
|
||||||
|
"external_post_id_keys_for",
|
||||||
|
"known_platform_keys",
|
||||||
|
"to_dict",
|
||||||
|
]
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""PlatformInfo dataclass + shared defaults + small helpers.
|
||||||
|
|
||||||
|
Per-platform modules import from here, register their PlatformInfo via
|
||||||
|
INFO, optionally attaching `derive_post_url` and/or `augment_cookies`
|
||||||
|
callables for behavior that diverges from gallery-dl's mainline shape
|
||||||
|
(Patreon).
|
||||||
|
|
||||||
|
Adding a new platform: drop a new module under `services/platforms/`,
|
||||||
|
declare an INFO, and add it to the import list in
|
||||||
|
`services/platforms/__init__.py`. Sidecar parsing, cookie
|
||||||
|
materialization, and the /api/platforms response pick it up
|
||||||
|
automatically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
# Sidecar parsing defaults. Per-platform PlatformInfo entries can
|
||||||
|
# override these by setting `external_post_id_keys=` /
|
||||||
|
# `description_keys=`. Most don't need to — the defaults already cover
|
||||||
|
# every platform FC supports.
|
||||||
|
#
|
||||||
|
# external_post_id chain: `post_id` MUST come before `id` because
|
||||||
|
# SubscribeStar gallery-dl puts the per-attachment id in `id` and the
|
||||||
|
# actual post id in `post_id`; picking `id` first fragments
|
||||||
|
# multi-image SubscribeStar posts into N Post rows. 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.
|
||||||
|
# (Banked 2026-05-27 during the sidecar audit.)
|
||||||
|
DEFAULT_EXTERNAL_POST_ID_KEYS: tuple[str, ...] = (
|
||||||
|
"post_id", "id", "index", "message_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Description body chain: Discord's gallery-dl extractor uses `message`
|
||||||
|
# (no `content`); appended to the chain so Discord posts surface body
|
||||||
|
# text.
|
||||||
|
DEFAULT_DESCRIPTION_KEYS: tuple[str, ...] = (
|
||||||
|
"content", "description", "caption", "message",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlatformInfo:
|
||||||
|
# --- Identity / metadata ---
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
auth_type: Literal["cookies", "token"]
|
||||||
|
requires_auth: bool
|
||||||
|
url_pattern: str
|
||||||
|
url_examples: list[str]
|
||||||
|
default_config: dict
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
# --- Sidecar parsing overrides ---
|
||||||
|
# Each is None to mean "use the module default above"; a platform
|
||||||
|
# only sets one of these when its sidecar shape genuinely differs.
|
||||||
|
external_post_id_keys: tuple[str, ...] | None = None
|
||||||
|
description_keys: tuple[str, ...] | None = None
|
||||||
|
|
||||||
|
# --- Behavioral hooks ---
|
||||||
|
# Synthesize a post permalink from sidecar data. Required when
|
||||||
|
# gallery-dl's `url` field is the file/CDN URL rather than the post
|
||||||
|
# permalink (subscribestar/pixiv/hf/discord). None = trust the bare
|
||||||
|
# `url` field (patreon, deviantart).
|
||||||
|
derive_post_url: Callable[[dict], str | None] | None = None
|
||||||
|
|
||||||
|
# Post-process the materialized cookies.txt for gallery-dl. Used by
|
||||||
|
# platforms whose server gates or extractor quirks need synthetic
|
||||||
|
# cookies the extension can't capture (subscribestar age cookie, HF
|
||||||
|
# host-only PHPSESSID duplicate). None = no-op.
|
||||||
|
augment_cookies: Callable[[str], str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def str_id_value(v) -> str | None:
|
||||||
|
"""Coerce a JSON scalar id into a non-empty string, rejecting bool
|
||||||
|
(Python's bool is an int subclass so `isinstance(True, int)` is
|
||||||
|
True; without this guard a sidecar with `"id": true` would produce
|
||||||
|
external_post_id="True")."""
|
||||||
|
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:
|
||||||
|
"""Same idea as str_id_value but for plain string fields (no int
|
||||||
|
coercion)."""
|
||||||
|
if isinstance(v, str) and v.strip():
|
||||||
|
return v.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Shared gallery-dl invocation defaults. Embedded in each platform's
|
||||||
|
# default_config (with platform-specific overrides) so per-platform
|
||||||
|
# choices stay explicit.
|
||||||
|
GD_DEFAULTS = {
|
||||||
|
"sleep": 3.0,
|
||||||
|
"sleep_request": 1.5,
|
||||||
|
"skip_existing": True,
|
||||||
|
"save_metadata": True,
|
||||||
|
"timeout": 3600,
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""DeviantArt — no exercised quirks yet.
|
||||||
|
|
||||||
|
No operator-owned DeviantArt archive existed at the 2026-05-27 sidecar
|
||||||
|
audit, so we don't know yet whether DA's gallery-dl sidecars are
|
||||||
|
well-behaved or have their own quirks. When DA gets exercised for the
|
||||||
|
first time, add `derive_post_url` / `augment_cookies` here as needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import GD_DEFAULTS, PlatformInfo
|
||||||
|
|
||||||
|
INFO = PlatformInfo(
|
||||||
|
key="deviantart",
|
||||||
|
name="DeviantArt",
|
||||||
|
description="Download artwork from DeviantArt artists",
|
||||||
|
auth_type="cookies",
|
||||||
|
requires_auth=False,
|
||||||
|
url_pattern=r"^https?://(www\.)?deviantart\.com/",
|
||||||
|
url_examples=[
|
||||||
|
"https://www.deviantart.com/example-artist",
|
||||||
|
"https://www.deviantart.com/example-artist/gallery",
|
||||||
|
],
|
||||||
|
default_config={**GD_DEFAULTS, "content_types": ["gallery"]},
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Discord — one quirk + one already-default.
|
||||||
|
|
||||||
|
post_url: gallery-dl's `url` is the CDN attachment URL. The "permalink"
|
||||||
|
for a Discord message uses the (server, channel, message) triple via
|
||||||
|
`discord.com/channels/<server>/<channel>/<message>`. Note that
|
||||||
|
permalinks are only resolvable for users in the same server — public
|
||||||
|
access doesn't work — but the URL is still useful to the operator
|
||||||
|
in-app.
|
||||||
|
|
||||||
|
Description body is in `message` not `content`. That's already covered
|
||||||
|
by the default description chain in base.py (DEFAULT_DESCRIPTION_KEYS
|
||||||
|
ends with `message`). No description_keys override needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import GD_DEFAULTS, PlatformInfo, str_id_value
|
||||||
|
|
||||||
|
|
||||||
|
def derive_post_url(data: dict) -> str | None:
|
||||||
|
sid = str_id_value(data.get("server_id"))
|
||||||
|
cid = str_id_value(data.get("channel_id"))
|
||||||
|
mid = str_id_value(data.get("message_id"))
|
||||||
|
if sid and cid and mid:
|
||||||
|
return f"https://discord.com/channels/{sid}/{cid}/{mid}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
INFO = PlatformInfo(
|
||||||
|
key="discord",
|
||||||
|
name="Discord",
|
||||||
|
description="Download attachments from Discord channels",
|
||||||
|
auth_type="token",
|
||||||
|
requires_auth=True,
|
||||||
|
url_pattern=r"^https?://(www\.)?discord\.com/channels/",
|
||||||
|
url_examples=["https://discord.com/channels/123456789/987654321"],
|
||||||
|
default_config={**GD_DEFAULTS, "content_types": ["all"]},
|
||||||
|
notes="Requires Discord user token (not bot token).",
|
||||||
|
derive_post_url=derive_post_url,
|
||||||
|
)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""HentaiFoundry — two quirks colocated.
|
||||||
|
|
||||||
|
1. post_url: HF sidecars omit `url` entirely; `src` is the image URL.
|
||||||
|
Synthesize the permalink from `user` + `index`
|
||||||
|
(/pictures/user/<user>/<index>).
|
||||||
|
|
||||||
|
2. augment_cookies: gallery-dl's HF extractor checks
|
||||||
|
`self.cookies.get("PHPSESSID", domain="www.hentai-foundry.com")` with
|
||||||
|
`requests`' EXACT domain matching. The extension's pre-v1.0.5
|
||||||
|
`cookies.js` aggressively rewrote every captured cookie to the
|
||||||
|
leading-dot subdomain-wide form (`.hentai-foundry.com`), which fails
|
||||||
|
the exact lookup even though the cookie IS sent on actual HTTP
|
||||||
|
requests (RFC 6265 subdomain matching). The extractor falls into
|
||||||
|
an unauthenticated `?enterAgree=1` HEAD that 401s. Inject host-only
|
||||||
|
duplicates of PHPSESSID + YII_CSRF_TOKEN so the lookup succeeds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import GD_DEFAULTS, PlatformInfo, str_field, str_id_value
|
||||||
|
|
||||||
|
_HOST_ONLY_NAMES = ("PHPSESSID", "YII_CSRF_TOKEN")
|
||||||
|
|
||||||
|
|
||||||
|
def derive_post_url(data: dict) -> str | None:
|
||||||
|
user = str_field(data.get("user")) or str_field(data.get("artist"))
|
||||||
|
idx = str_id_value(data.get("index"))
|
||||||
|
if user and idx:
|
||||||
|
return f"https://www.hentai-foundry.com/pictures/user/{user}/{idx}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def augment_cookies(netscape: str) -> str:
|
||||||
|
body = netscape.rstrip("\n")
|
||||||
|
if not body:
|
||||||
|
return netscape
|
||||||
|
lines = body.split("\n")
|
||||||
|
existing_host_only: set[str] = set()
|
||||||
|
by_name: dict[str, list[str]] = {}
|
||||||
|
for raw in lines:
|
||||||
|
if not raw or raw.startswith("#"):
|
||||||
|
continue
|
||||||
|
parts = raw.split("\t")
|
||||||
|
if len(parts) < 7:
|
||||||
|
continue
|
||||||
|
domain, _flag, _path, _secure, _exp, name, _value = parts[:7]
|
||||||
|
if name not in _HOST_ONLY_NAMES:
|
||||||
|
continue
|
||||||
|
if domain == "www.hentai-foundry.com":
|
||||||
|
existing_host_only.add(name)
|
||||||
|
elif domain in (".hentai-foundry.com", "hentai-foundry.com"):
|
||||||
|
by_name.setdefault(name, []).append(raw)
|
||||||
|
|
||||||
|
appended: list[str] = []
|
||||||
|
for name in _HOST_ONLY_NAMES:
|
||||||
|
if name in existing_host_only or name not in by_name:
|
||||||
|
continue
|
||||||
|
# Duplicate the first subdomain-wide line as host-only on
|
||||||
|
# www.hentai-foundry.com. Same value + expiry; flag=FALSE marks
|
||||||
|
# the entry host-only in netscape format.
|
||||||
|
parts = by_name[name][0].split("\t")
|
||||||
|
parts[0] = "www.hentai-foundry.com"
|
||||||
|
parts[1] = "FALSE"
|
||||||
|
appended.append("\t".join(parts[:7]))
|
||||||
|
|
||||||
|
if not appended:
|
||||||
|
return netscape
|
||||||
|
return body + "\n" + "\n".join(appended) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
INFO = PlatformInfo(
|
||||||
|
key="hentaifoundry",
|
||||||
|
name="Hentai Foundry",
|
||||||
|
description="Download artwork from Hentai Foundry artists",
|
||||||
|
auth_type="cookies",
|
||||||
|
requires_auth=False,
|
||||||
|
url_pattern=r"^https?://(www\.)?hentai-foundry\.com/",
|
||||||
|
url_examples=[
|
||||||
|
"https://www.hentai-foundry.com/user/example_artist",
|
||||||
|
"https://www.hentai-foundry.com/pictures/user/example_artist",
|
||||||
|
],
|
||||||
|
default_config={**GD_DEFAULTS, "content_types": ["pictures"]},
|
||||||
|
derive_post_url=derive_post_url,
|
||||||
|
augment_cookies=augment_cookies,
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Patreon — no quirks. The reference platform.
|
||||||
|
|
||||||
|
Patreon's gallery-dl sidecars are the well-behaved baseline: `url` is a
|
||||||
|
real permalink, `id` is the post id, `title` and `content` are
|
||||||
|
populated. No cookie quirks (session cookies are domain-wide). No
|
||||||
|
derivation overrides.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import GD_DEFAULTS, PlatformInfo
|
||||||
|
|
||||||
|
INFO = PlatformInfo(
|
||||||
|
key="patreon",
|
||||||
|
name="Patreon",
|
||||||
|
description="Download posts from Patreon creators",
|
||||||
|
auth_type="cookies",
|
||||||
|
requires_auth=True,
|
||||||
|
url_pattern=r"^https?://(www\.)?patreon\.com/",
|
||||||
|
url_examples=[
|
||||||
|
"https://www.patreon.com/example_artist",
|
||||||
|
"https://www.patreon.com/user?u=12345678",
|
||||||
|
],
|
||||||
|
default_config={**GD_DEFAULTS, "content_types": ["images", "attachments"]},
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Pixiv — one quirk.
|
||||||
|
|
||||||
|
post_url: gallery-dl's `url` is the image URL on `i.pximg.net`. The
|
||||||
|
post permalink follows /artworks/<id>. external_post_id (= `id`) was
|
||||||
|
already correct, so no override there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import GD_DEFAULTS, PlatformInfo, str_id_value
|
||||||
|
|
||||||
|
|
||||||
|
def derive_post_url(data: dict) -> str | None:
|
||||||
|
pid = str_id_value(data.get("id"))
|
||||||
|
if pid:
|
||||||
|
return f"https://www.pixiv.net/artworks/{pid}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
INFO = PlatformInfo(
|
||||||
|
key="pixiv",
|
||||||
|
name="Pixiv",
|
||||||
|
description="Download artwork from Pixiv artists",
|
||||||
|
auth_type="token",
|
||||||
|
requires_auth=True,
|
||||||
|
url_pattern=r"^https?://(www\.)?pixiv\.net/",
|
||||||
|
url_examples=[
|
||||||
|
"https://www.pixiv.net/users/12345678",
|
||||||
|
"https://www.pixiv.net/en/users/12345678",
|
||||||
|
],
|
||||||
|
default_config={**GD_DEFAULTS, "content_types": ["all"]},
|
||||||
|
notes="Requires OAuth refresh token. Run `gallery-dl oauth:pixiv` to obtain one.",
|
||||||
|
derive_post_url=derive_post_url,
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""SubscribeStar — three quirks colocated.
|
||||||
|
|
||||||
|
1. external_post_id: gallery-dl puts the per-attachment id in `id`
|
||||||
|
(e.g. 711509) and the actual post id in `post_id` (e.g. 360360).
|
||||||
|
The default chain in base.py already prefers `post_id`; this module
|
||||||
|
doesn't need to override it but the comment lives here too so a
|
||||||
|
future reader knows the chain's order was driven by this platform.
|
||||||
|
|
||||||
|
2. post_url: gallery-dl's `url` is the file CDN URL
|
||||||
|
(`/post_uploads?payload=...`). Synthesize the post permalink from
|
||||||
|
`post_id`.
|
||||||
|
|
||||||
|
3. augment_cookies: the server gates artist pages behind a
|
||||||
|
`_personalization_id` age-confirmation cookie that the user can't
|
||||||
|
easily refresh — SubscribeStar's frontend JS uses localStorage to
|
||||||
|
suppress the age popup once dismissed. gallery-dl's own login flow
|
||||||
|
sidesteps this by setting `18_plus_agreement_generic=true` on
|
||||||
|
`.subscribestar.adult`; we mirror that for cookies captured via the
|
||||||
|
extension.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import GD_DEFAULTS, PlatformInfo, str_id_value
|
||||||
|
|
||||||
|
|
||||||
|
def derive_post_url(data: dict) -> str | None:
|
||||||
|
pid = str_id_value(data.get("post_id"))
|
||||||
|
if pid:
|
||||||
|
return f"https://www.subscribestar.com/posts/{pid}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def augment_cookies(netscape: str) -> str:
|
||||||
|
if "18_plus_agreement_generic" in netscape:
|
||||||
|
return netscape
|
||||||
|
# Far-future expiry — gallery-dl's own login flow sets this with no
|
||||||
|
# explicit expiry; the server only checks presence/value.
|
||||||
|
expiry = 4102444800 # 2100-01-01 UTC
|
||||||
|
line = "\t".join([
|
||||||
|
".subscribestar.adult", "TRUE", "/", "TRUE",
|
||||||
|
str(expiry), "18_plus_agreement_generic", "true",
|
||||||
|
])
|
||||||
|
body = netscape.rstrip("\n")
|
||||||
|
if not body:
|
||||||
|
body = "# Netscape HTTP Cookie File"
|
||||||
|
return body + "\n" + line + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
INFO = PlatformInfo(
|
||||||
|
key="subscribestar",
|
||||||
|
name="SubscribeStar",
|
||||||
|
description="Download posts from SubscribeStar creators",
|
||||||
|
auth_type="cookies",
|
||||||
|
requires_auth=True,
|
||||||
|
url_pattern=r"^https?://(www\.)?subscribestar\.(com|adult)/",
|
||||||
|
url_examples=[
|
||||||
|
"https://subscribestar.adult/example_artist",
|
||||||
|
"https://www.subscribestar.com/example_artist",
|
||||||
|
],
|
||||||
|
default_config={**GD_DEFAULTS, "content_types": ["all"]},
|
||||||
|
derive_post_url=derive_post_url,
|
||||||
|
augment_cookies=augment_cookies,
|
||||||
|
)
|
||||||
@@ -109,7 +109,10 @@ class PostFeedService:
|
|||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
post, artist, source = row
|
post, artist, source = row
|
||||||
thumbs_map = await self._thumbnails_for([post.id])
|
# Detail endpoint returns the FULL image list for PostModal's
|
||||||
|
# masonry grid — feed query still caps at THUMBNAIL_LIMIT via
|
||||||
|
# the default arg.
|
||||||
|
thumbs_map = await self._thumbnails_for([post.id], limit=None)
|
||||||
atts_map = await self._attachments_for([post.id])
|
atts_map = await self._attachments_for([post.id])
|
||||||
item = self._to_dict(post, artist, source, thumbs_map, atts_map)
|
item = self._to_dict(post, artist, source, thumbs_map, atts_map)
|
||||||
item["description_full"] = html_to_plain(post.description)
|
item["description_full"] = html_to_plain(post.description)
|
||||||
@@ -117,15 +120,21 @@ class PostFeedService:
|
|||||||
|
|
||||||
# --- composition helpers ---------------------------------------------
|
# --- composition helpers ---------------------------------------------
|
||||||
|
|
||||||
async def _thumbnails_for(self, post_ids: list[int]) -> dict[int, dict]:
|
async def _thumbnails_for(
|
||||||
"""post_id -> {"thumbs": [...up to 6], "more": int}.
|
self, post_ids: list[int], *, limit: int | None = THUMBNAIL_LIMIT,
|
||||||
|
) -> dict[int, dict]:
|
||||||
|
"""post_id -> {"thumbs": [...up to limit], "more": int}.
|
||||||
|
|
||||||
Selects THUMBNAIL_LIMIT+1 images per post via window function so we
|
Selects up to `limit` images per post via window function so we
|
||||||
can detect overflow in a single query.
|
can detect overflow in a single query. Pass `limit=None` to
|
||||||
|
return ALL thumbnails per post (used by `get_post` for PostModal's
|
||||||
|
masonry grid; the feed pass keeps the default cap so payloads
|
||||||
|
stay small).
|
||||||
"""
|
"""
|
||||||
if not post_ids:
|
if not post_ids:
|
||||||
return {}
|
return {}
|
||||||
# Rank images within each post and fetch only the top THUMBNAIL_LIMIT+1.
|
# Rank images within each post; cap at `limit` rows per post when
|
||||||
|
# limit is set, return all when limit is None.
|
||||||
ranked = (
|
ranked = (
|
||||||
select(
|
select(
|
||||||
ImageRecord.id,
|
ImageRecord.id,
|
||||||
@@ -143,12 +152,13 @@ class PostFeedService:
|
|||||||
.where(ImageRecord.primary_post_id.in_(post_ids))
|
.where(ImageRecord.primary_post_id.in_(post_ids))
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
rows = (await self.session.execute(
|
stmt = select(
|
||||||
select(
|
ranked.c.id, ranked.c.primary_post_id,
|
||||||
ranked.c.id, ranked.c.primary_post_id,
|
ranked.c.sha256, ranked.c.mime, ranked.c.total,
|
||||||
ranked.c.sha256, ranked.c.mime, ranked.c.total,
|
)
|
||||||
).where(ranked.c.rn <= THUMBNAIL_LIMIT)
|
if limit is not None:
|
||||||
)).all()
|
stmt = stmt.where(ranked.c.rn <= limit)
|
||||||
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
|
||||||
out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids}
|
out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids}
|
||||||
for img_id, pid, sha, mime, total in rows:
|
for img_id, pid, sha, mime, total in rows:
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""scan_library_for_rule Celery task — iterates image_record in keyset-
|
||||||
|
paginated batches, evaluates the audit rule per image, populates
|
||||||
|
LibraryAuditRun.matched_ids. Runs on the maintenance queue with a 2h soft
|
||||||
|
time limit (plenty of margin for 100k+ image libraries at ~100ms PIL
|
||||||
|
decode + histogram per image).
|
||||||
|
|
||||||
|
State machine:
|
||||||
|
start: status='running'
|
||||||
|
end success: status='ready'
|
||||||
|
end error: status='error', error=traceback
|
||||||
|
oversize: status='error', error='matched too many images; tighten threshold'
|
||||||
|
external cancel: scan sees status='cancelled' between batches, exits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from celery.exceptions import SoftTimeLimitExceeded
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||||
|
|
||||||
|
from ..celery_app import celery
|
||||||
|
from ..models import ImageRecord, LibraryAuditRun
|
||||||
|
from ..services.audits import single_color, transparency
|
||||||
|
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BATCH = 500
|
||||||
|
_PROGRESS_TICK = 100
|
||||||
|
_MAX_MATCHED = 50_000
|
||||||
|
|
||||||
|
_RULES = {
|
||||||
|
"transparency": transparency.evaluate,
|
||||||
|
"single_color": single_color.evaluate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="backend.app.tasks.library_audit.scan_library_for_rule",
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(OperationalError, DBAPIError),
|
||||||
|
retry_backoff=5,
|
||||||
|
retry_backoff_max=60,
|
||||||
|
retry_jitter=True,
|
||||||
|
max_retries=3,
|
||||||
|
soft_time_limit=7200,
|
||||||
|
time_limit=7500,
|
||||||
|
)
|
||||||
|
def scan_library_for_rule(self, audit_id: int) -> dict:
|
||||||
|
"""See module docstring. Returns a small summary dict for eager-mode
|
||||||
|
test assertions (real workers ignore the return value)."""
|
||||||
|
SessionLocal = _sync_session_factory()
|
||||||
|
try:
|
||||||
|
with SessionLocal() as session:
|
||||||
|
audit = session.get(LibraryAuditRun, audit_id)
|
||||||
|
if audit is None:
|
||||||
|
return {"audit_id": audit_id, "status": "missing"}
|
||||||
|
evaluate = _RULES.get(audit.rule)
|
||||||
|
if evaluate is None:
|
||||||
|
_mark_error(session, audit_id, f"unknown rule {audit.rule!r}")
|
||||||
|
return {"audit_id": audit_id, "status": "error"}
|
||||||
|
params = dict(audit.params or {})
|
||||||
|
matched: list[int] = []
|
||||||
|
scanned = 0
|
||||||
|
last_id = 0
|
||||||
|
while True:
|
||||||
|
# Cancellation check between batches.
|
||||||
|
current_status = session.execute(
|
||||||
|
select(LibraryAuditRun.status)
|
||||||
|
.where(LibraryAuditRun.id == audit_id)
|
||||||
|
).scalar_one()
|
||||||
|
if current_status == "cancelled":
|
||||||
|
return {"audit_id": audit_id, "status": "cancelled"}
|
||||||
|
rows = session.execute(
|
||||||
|
select(ImageRecord.id, ImageRecord.path)
|
||||||
|
.where(ImageRecord.id > last_id)
|
||||||
|
.where(ImageRecord.mime.like("image/%"))
|
||||||
|
.order_by(ImageRecord.id.asc())
|
||||||
|
.limit(_BATCH)
|
||||||
|
).all()
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for image_id, image_path in rows:
|
||||||
|
last_id = image_id
|
||||||
|
scanned += 1
|
||||||
|
try:
|
||||||
|
with Image.open(image_path) as im:
|
||||||
|
try:
|
||||||
|
if evaluate(im, **params):
|
||||||
|
matched.append(image_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning(
|
||||||
|
"audit %s: rule evaluate failed on %s: %s",
|
||||||
|
audit_id, image_path, exc,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
log.warning(
|
||||||
|
"audit %s: image_record %s file missing at %s; skipping",
|
||||||
|
audit_id, image_id, image_path,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning(
|
||||||
|
"audit %s: PIL load failed for %s: %s",
|
||||||
|
audit_id, image_path, exc,
|
||||||
|
)
|
||||||
|
if len(matched) > _MAX_MATCHED:
|
||||||
|
_mark_error(
|
||||||
|
session, audit_id,
|
||||||
|
f"matched > {_MAX_MATCHED} images; "
|
||||||
|
"tighten threshold and re-run",
|
||||||
|
)
|
||||||
|
return {"audit_id": audit_id, "status": "error"}
|
||||||
|
if scanned % _PROGRESS_TICK == 0:
|
||||||
|
session.execute(
|
||||||
|
update(LibraryAuditRun)
|
||||||
|
.where(LibraryAuditRun.id == audit_id)
|
||||||
|
.values(scanned_count=scanned)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
# Final state.
|
||||||
|
session.execute(
|
||||||
|
update(LibraryAuditRun)
|
||||||
|
.where(LibraryAuditRun.id == audit_id)
|
||||||
|
.values(
|
||||||
|
scanned_count=scanned,
|
||||||
|
matched_count=len(matched),
|
||||||
|
matched_ids=matched,
|
||||||
|
status="ready",
|
||||||
|
finished_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return {
|
||||||
|
"audit_id": audit_id,
|
||||||
|
"status": "ready",
|
||||||
|
"scanned": scanned,
|
||||||
|
"matched": len(matched),
|
||||||
|
}
|
||||||
|
except SoftTimeLimitExceeded:
|
||||||
|
with SessionLocal() as session:
|
||||||
|
_mark_error(session, audit_id, "soft_time_limit exceeded (>7200s)")
|
||||||
|
raise
|
||||||
|
except (OperationalError, DBAPIError):
|
||||||
|
# Retryable per the decorator; leave row in 'running' and let
|
||||||
|
# autoretry try again. Recovery sweep catches if all retries fail.
|
||||||
|
raise
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
with SessionLocal() as session:
|
||||||
|
_mark_error(session, audit_id, tb)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_error(session, audit_id: int, error_msg: str) -> None:
|
||||||
|
session.execute(
|
||||||
|
update(LibraryAuditRun)
|
||||||
|
.where(LibraryAuditRun.id == audit_id)
|
||||||
|
.values(
|
||||||
|
status="error",
|
||||||
|
error=error_msg,
|
||||||
|
finished_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
@@ -54,41 +54,40 @@ def recover_interrupted_tasks() -> int:
|
|||||||
processing_cutoff = now - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
|
processing_cutoff = now - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
|
||||||
orphan_cutoff = now - timedelta(minutes=ORPHAN_PENDING_THRESHOLD_MINUTES)
|
orphan_cutoff = now - timedelta(minutes=ORPHAN_PENDING_THRESHOLD_MINUTES)
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
stuck_ids = session.execute(
|
# Both sweeps used to be SELECT ids → UPDATE WHERE id IN (...) which
|
||||||
select(ImportTask.id)
|
# blew past psycopg's 65535-parameter ceiling once a sweep covered
|
||||||
|
# tens of thousands of rows (operator hit it 2026-05-26 after the
|
||||||
|
# /import deep scan piled up orphans). Folding the SELECT into the
|
||||||
|
# UPDATE eliminates the IN-list entirely. RETURNING gives us back
|
||||||
|
# exactly the ids that flipped so the stuck sweep can still
|
||||||
|
# .delay() each one.
|
||||||
|
stuck_result = session.execute(
|
||||||
|
update(ImportTask)
|
||||||
.where(ImportTask.status == "processing")
|
.where(ImportTask.status == "processing")
|
||||||
.where(ImportTask.started_at < processing_cutoff)
|
.where(ImportTask.started_at < processing_cutoff)
|
||||||
).scalars().all()
|
.values(
|
||||||
|
status="queued",
|
||||||
|
started_at=None,
|
||||||
|
error="recovered from stuck state",
|
||||||
|
)
|
||||||
|
.returning(ImportTask.id)
|
||||||
|
)
|
||||||
|
stuck_ids = [row[0] for row in stuck_result.all()]
|
||||||
|
|
||||||
orphan_ids = session.execute(
|
orphan_result = session.execute(
|
||||||
select(ImportTask.id)
|
update(ImportTask)
|
||||||
.where(ImportTask.status.in_(["pending", "queued"]))
|
.where(ImportTask.status.in_(["pending", "queued"]))
|
||||||
.where(ImportTask.created_at < orphan_cutoff)
|
.where(ImportTask.created_at < orphan_cutoff)
|
||||||
).scalars().all()
|
.values(
|
||||||
|
status="failed",
|
||||||
if not stuck_ids and not orphan_ids:
|
error=(
|
||||||
return 0
|
"orphan pending/queued swept by recover_interrupted_tasks "
|
||||||
|
"(scanner likely crashed mid-enqueue); retry via "
|
||||||
if stuck_ids:
|
"/api/import/retry-failed"
|
||||||
session.execute(
|
),
|
||||||
update(ImportTask)
|
|
||||||
.where(ImportTask.id.in_(stuck_ids))
|
|
||||||
.values(status="queued", started_at=None, error="recovered from stuck state")
|
|
||||||
)
|
|
||||||
|
|
||||||
if orphan_ids:
|
|
||||||
session.execute(
|
|
||||||
update(ImportTask)
|
|
||||||
.where(ImportTask.id.in_(orphan_ids))
|
|
||||||
.values(
|
|
||||||
status="failed",
|
|
||||||
error=(
|
|
||||||
"orphan pending/queued swept by recover_interrupted_tasks "
|
|
||||||
"(scanner likely crashed mid-enqueue); retry via "
|
|
||||||
"/api/import/retry-failed"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
orphan_count = orphan_result.rowcount or 0
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -97,7 +96,7 @@ def recover_interrupted_tasks() -> int:
|
|||||||
for tid in stuck_ids:
|
for tid in stuck_ids:
|
||||||
import_media_file.delay(tid)
|
import_media_file.delay(tid)
|
||||||
|
|
||||||
return len(stuck_ids) + len(orphan_ids)
|
return len(stuck_ids) + orphan_count
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="backend.app.tasks.maintenance.cleanup_old_tasks")
|
@celery.task(name="backend.app.tasks.maintenance.cleanup_old_tasks")
|
||||||
|
|||||||
@@ -17,6 +17,30 @@ from ._sync_engine import sync_session_factory as _sync_session_factory
|
|||||||
|
|
||||||
IMAGES_ROOT = Path("/images")
|
IMAGES_ROOT = Path("/images")
|
||||||
|
|
||||||
|
THUMB_MAGIC_JPEG = b"\xff\xd8\xff"
|
||||||
|
THUMB_MAGIC_PNG = b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _thumb_is_valid(path: Path) -> bool:
|
||||||
|
"""Return True iff `path` exists and starts with a JPEG or PNG magic header.
|
||||||
|
|
||||||
|
The on-disk thumbnail format is set by services/thumbnailer.py — JPEG for
|
||||||
|
opaque sources, PNG for alpha sources. Anything else (missing file, OSError,
|
||||||
|
truncated, wrong magic) is invalid.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with path.open("rb") as f:
|
||||||
|
head = f.read(12)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if len(head) < 8:
|
||||||
|
return False
|
||||||
|
if head[:3] == THUMB_MAGIC_JPEG:
|
||||||
|
return True
|
||||||
|
if head[:8] == THUMB_MAGIC_PNG:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@celery.task(
|
@celery.task(
|
||||||
name="backend.app.tasks.thumbnail.generate_thumbnail",
|
name="backend.app.tasks.thumbnail.generate_thumbnail",
|
||||||
@@ -50,3 +74,58 @@ def generate_thumbnail(self, image_id: int) -> dict:
|
|||||||
session.add(record)
|
session.add(record)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"status": "ok", "image_id": image_id, "path": str(result.path)}
|
return {"status": "ok", "image_id": image_id, "path": str(result.path)}
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="backend.app.tasks.thumbnail.backfill_thumbnails",
|
||||||
|
bind=True,
|
||||||
|
)
|
||||||
|
def backfill_thumbnails(self) -> dict:
|
||||||
|
"""Scan ImageRecord and enqueue generate_thumbnail for rows whose
|
||||||
|
thumbnail is missing, gone from disk, or has wrong magic bytes.
|
||||||
|
|
||||||
|
Keyset paginates by id ASC, page size 500. NULLs out thumbnail_path for
|
||||||
|
rows that point at a missing or corrupt file before enqueueing — keeps
|
||||||
|
the DB self-consistent on partial runs and makes re-runs safe.
|
||||||
|
|
||||||
|
Returns {"enqueued": N, "ok": M, "regenerated": K} where:
|
||||||
|
- enqueued = total generate_thumbnail.delay() calls
|
||||||
|
- ok = rows whose existing thumbnail file is valid (skipped)
|
||||||
|
- regenerated = subset of enqueued that had a non-NULL thumbnail_path
|
||||||
|
cleared (i.e. missing + corrupt)
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
SessionLocal = _sync_session_factory()
|
||||||
|
enqueued = 0
|
||||||
|
ok = 0
|
||||||
|
regenerated = 0
|
||||||
|
last_id = 0
|
||||||
|
with SessionLocal() as session:
|
||||||
|
while True:
|
||||||
|
rows = session.execute(
|
||||||
|
select(ImageRecord.id, ImageRecord.thumbnail_path)
|
||||||
|
.where(ImageRecord.id > last_id)
|
||||||
|
.order_by(ImageRecord.id.asc())
|
||||||
|
.limit(500)
|
||||||
|
).all()
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for image_id, thumb_path in rows:
|
||||||
|
if thumb_path is None:
|
||||||
|
generate_thumbnail.delay(image_id)
|
||||||
|
enqueued += 1
|
||||||
|
elif _thumb_is_valid(Path(thumb_path)):
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
session.execute(
|
||||||
|
update(ImageRecord)
|
||||||
|
.where(ImageRecord.id == image_id)
|
||||||
|
.values(thumbnail_path=None)
|
||||||
|
)
|
||||||
|
generate_thumbnail.delay(image_id)
|
||||||
|
enqueued += 1
|
||||||
|
regenerated += 1
|
||||||
|
session.commit()
|
||||||
|
last_id = rows[-1][0]
|
||||||
|
return {"enqueued": enqueued, "ok": ok, "regenerated": regenerated}
|
||||||
|
|||||||
@@ -13,8 +13,21 @@ HASH_SIZE = 8
|
|||||||
|
|
||||||
def compute_phash(pil_image) -> str | None:
|
def compute_phash(pil_image) -> str | None:
|
||||||
"""Perceptual hash of an opened PIL image, as a hex string. None on any
|
"""Perceptual hash of an opened PIL image, as a hex string. None on any
|
||||||
failure (videos/unreadable/non-image)."""
|
failure (videos/unreadable/non-image).
|
||||||
|
|
||||||
|
For animated images (multi-frame WebP/GIF/APNG), explicitly seek to
|
||||||
|
frame 0 first. Without this, some PIL operations downstream of
|
||||||
|
imagehash.phash (convert("L"), resize) can iterate all frames and
|
||||||
|
blow past Celery's hard time limit on large animations
|
||||||
|
(operator-flagged 2026-05-26 against animated WebPs). The pHash of
|
||||||
|
frame 0 is the conventional choice for animated content.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
if getattr(pil_image, "is_animated", False):
|
||||||
|
try:
|
||||||
|
pil_image.seek(0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return str(imagehash.phash(pil_image, hash_size=HASH_SIZE))
|
return str(imagehash.phash(pil_image, hash_size=HASH_SIZE))
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
"""Minimal gallery-dl sidecar parsing (one-time filesystem-import aid).
|
"""Minimal gallery-dl sidecar parsing (one-time filesystem-import aid).
|
||||||
|
|
||||||
No per-platform branching: a small common key set with fallbacks; the
|
Per-platform quirks (post_url synthesis, key-chain overrides) live in
|
||||||
full JSON is kept in raw so anything unmapped is recoverable later.
|
the platforms registry — `backend/app/services/platforms/`. This module
|
||||||
|
is platform-agnostic: it looks up `category` in the sidecar and asks
|
||||||
|
the registry for the right behavior.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..services.platforms import (
|
||||||
|
PLATFORMS,
|
||||||
|
description_keys_for,
|
||||||
|
external_post_id_keys_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SidecarData:
|
class SidecarData:
|
||||||
@@ -21,10 +30,28 @@ class SidecarData:
|
|||||||
raw: dict
|
raw: dict
|
||||||
|
|
||||||
|
|
||||||
|
# gallery-dl prefixes media filenames with `NN_` for in-post ordering
|
||||||
|
# (`01_HOLLOW-ICHIGO.png`, `02_HOLOW ICHIGO.zip`) but writes the sidecar
|
||||||
|
# under the attachment's stem WITHOUT that ordering prefix
|
||||||
|
# (`HOLLOW-ICHIGO.json`). Strip the prefix when looking for sidecars.
|
||||||
|
# Confirmed against real Patreon downloads 2026-05-26 — without this
|
||||||
|
# strip, every gallery-dl post-level sidecar was invisible to FC since
|
||||||
|
# FC-3 shipped (24 deep-refresh calls produced 0 Posts in operator's DB).
|
||||||
|
_NUMBERING_PREFIX = re.compile(r"^\d+_(.+)$")
|
||||||
|
|
||||||
|
|
||||||
def find_sidecar(media: Path) -> Path | None:
|
def find_sidecar(media: Path) -> Path | None:
|
||||||
|
# Attachment-level sidecars (image.jpg.json, image.json).
|
||||||
for cand in (media.with_suffix(".json"), Path(str(media) + ".json")):
|
for cand in (media.with_suffix(".json"), Path(str(media) + ".json")):
|
||||||
if cand.is_file():
|
if cand.is_file():
|
||||||
return cand
|
return cand
|
||||||
|
# gallery-dl post-numbered convention: strip the `NN_` prefix from
|
||||||
|
# the stem and look for that.json in the same directory.
|
||||||
|
m = _NUMBERING_PREFIX.match(media.stem)
|
||||||
|
if m:
|
||||||
|
cand = media.parent / f"{m.group(1)}.json"
|
||||||
|
if cand.is_file():
|
||||||
|
return cand
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +63,46 @@ def _first_str(data: dict, keys: tuple[str, ...]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_id(data: dict, keys: tuple[str, ...]) -> str | None:
|
||||||
|
"""Like `_first_str` but accepts ints and rejects bool (Python's
|
||||||
|
bool subclasses int, so a literal `"id": true` would otherwise
|
||||||
|
yield external_post_id="True")."""
|
||||||
|
for k in keys:
|
||||||
|
v = data.get(k)
|
||||||
|
if isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
if isinstance(v, (str, int)) and str(v).strip():
|
||||||
|
return str(v).strip()
|
||||||
|
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:
|
def _parse_date(v) -> datetime | None:
|
||||||
if isinstance(v, bool):
|
if isinstance(v, bool):
|
||||||
return None
|
return None
|
||||||
@@ -65,14 +132,7 @@ def parse_sidecar(data: dict) -> SidecarData:
|
|||||||
cat = data.get("category")
|
cat = data.get("category")
|
||||||
platform = cat if isinstance(cat, str) and cat.strip() else None
|
platform = cat if isinstance(cat, str) and cat.strip() else None
|
||||||
|
|
||||||
external_post_id = None
|
external_post_id = _first_id(data, external_post_id_keys_for(platform))
|
||||||
for k in ("id", "post_id", "index", "message_id"):
|
|
||||||
v = data.get(k)
|
|
||||||
if isinstance(v, bool):
|
|
||||||
continue
|
|
||||||
if isinstance(v, (str, int)) and str(v).strip():
|
|
||||||
external_post_id = str(v)
|
|
||||||
break
|
|
||||||
|
|
||||||
pc = data.get("page_count")
|
pc = data.get("page_count")
|
||||||
if isinstance(pc, bool):
|
if isinstance(pc, bool):
|
||||||
@@ -92,12 +152,32 @@ def parse_sidecar(data: dict) -> SidecarData:
|
|||||||
if post_date is not None:
|
if post_date is not None:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
description = _first_str(data, description_keys_for(platform))
|
||||||
|
|
||||||
|
# When `title` is empty (subscribestar always; sometimes elsewhere),
|
||||||
|
# synthesize from the description body's first non-empty text line.
|
||||||
|
# Patreon's explicit titles 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: ask the platform module to synthesize a permalink.
|
||||||
|
# When the platform registers a `derive_post_url`, it owns the
|
||||||
|
# field (the bare `url`/`post_url` value is a file CDN URL and
|
||||||
|
# must NEVER be persisted). When it doesn't register one, trust
|
||||||
|
# the sidecar's `url` (Patreon's case — real permalink).
|
||||||
|
info = PLATFORMS.get(platform) if platform else None
|
||||||
|
if info is not None and info.derive_post_url is not None:
|
||||||
|
post_url = info.derive_post_url(data)
|
||||||
|
else:
|
||||||
|
post_url = _first_str(data, ("url", "post_url"))
|
||||||
|
|
||||||
return SidecarData(
|
return SidecarData(
|
||||||
platform=platform,
|
platform=platform,
|
||||||
external_post_id=external_post_id,
|
external_post_id=external_post_id,
|
||||||
post_url=_first_str(data, ("url", "post_url")),
|
post_url=post_url,
|
||||||
post_title=_first_str(data, ("title",)),
|
post_title=post_title,
|
||||||
description=_first_str(data, ("content", "description", "caption")),
|
description=description,
|
||||||
attachment_count=attachment_count,
|
attachment_count=attachment_count,
|
||||||
post_date=post_date,
|
post_date=post_date,
|
||||||
raw=data,
|
raw=data,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Parse the user-facing `kind:name` shortcut used by the add-tag input.
|
||||||
|
|
||||||
|
Mirrors IR's app/utils/tag_prefix.py. Tag.name in FC is stored bare;
|
||||||
|
the `kind:` prefix only exists as an input convention at user-facing
|
||||||
|
places (image-modal add-tag input, future bulk-add forms). The parser
|
||||||
|
is the single owner of the kind-string list — anything not in
|
||||||
|
KNOWN_KINDS keeps its colon as literal text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Kinds the user can type as a prefix at the input boundary.
|
||||||
|
# Exclusions:
|
||||||
|
# - `general` is the default for un-prefixed input (never typed as prefix)
|
||||||
|
# - `archive`, `post` are system-managed
|
||||||
|
# - `artist` was retired in FC-2d-vii-c — artists are first-class
|
||||||
|
# entities (Artist row + ImageRecord.artist_id), browsed via the
|
||||||
|
# provenance axis rather than as tags. See project_provenance_separation.
|
||||||
|
# - `meta`, `rating` retired as user-typeable per operator 2026-05-26 —
|
||||||
|
# content classification only needs character/fandom/series.
|
||||||
|
KNOWN_KINDS: frozenset[str] = frozenset({
|
||||||
|
"character",
|
||||||
|
"fandom",
|
||||||
|
"series",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_kind_prefix(raw: str) -> tuple[str | None, str]:
|
||||||
|
"""Split a raw user-typed tag string into (kind, name).
|
||||||
|
|
||||||
|
Returns (kind, name) where kind is lowercase canonical and in
|
||||||
|
KNOWN_KINDS, or (None, raw.strip()) if no recognized prefix is
|
||||||
|
present. `name` is always whitespace-stripped.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
parse_kind_prefix("character:Saber") -> ("character", "Saber")
|
||||||
|
parse_kind_prefix("Character:Saber") -> ("character", "Saber")
|
||||||
|
parse_kind_prefix("sunset") -> (None, "sunset")
|
||||||
|
parse_kind_prefix("http://example") -> (None, "http://example")
|
||||||
|
parse_kind_prefix("fandom: FSN ") -> ("fandom", "FSN")
|
||||||
|
"""
|
||||||
|
if ":" in raw:
|
||||||
|
prefix, rest = raw.split(":", 1)
|
||||||
|
if prefix.lower() in KNOWN_KINDS:
|
||||||
|
return prefix.lower(), rest.strip()
|
||||||
|
return None, raw.strip()
|
||||||
@@ -38,11 +38,33 @@ function deduplicateCookies(cookies) {
|
|||||||
function toNetscapeFormat(cookies) {
|
function toNetscapeFormat(cookies) {
|
||||||
const lines = ['# Netscape HTTP Cookie File'];
|
const lines = ['# Netscape HTTP Cookie File'];
|
||||||
for (const c of cookies) {
|
for (const c of cookies) {
|
||||||
let domain = c.domain.replace(/^\.?www\./, '.');
|
// Preserve the browser's actual scope. Earlier versions rewrote
|
||||||
if (!domain.startsWith('.')) domain = '.' + domain;
|
// every cookie to a leading-dot subdomain-wide form, which broke
|
||||||
|
// gallery-dl's HF extractor: its `cookies.get(name,
|
||||||
|
// domain="www.hentai-foundry.com")` does EXACT domain matching and
|
||||||
|
// missed host-only PHPSESSID rewritten to `.hentai-foundry.com`.
|
||||||
|
// Operator-flagged 2026-05-27. Backend `_augment_cookies` covers
|
||||||
|
// the already-stored cookies; this fix is forward-compat for fresh
|
||||||
|
// captures.
|
||||||
|
//
|
||||||
|
// Cookie storage semantics (Firefox):
|
||||||
|
// c.hostOnly === true → cookie set without a Domain= attribute;
|
||||||
|
// applies to the exact host only.
|
||||||
|
// c.hostOnly === false → cookie set with Domain=X; applies to
|
||||||
|
// that domain and its subdomains.
|
||||||
|
//
|
||||||
|
// Netscape format:
|
||||||
|
// leading-dot domain + TRUE flag → subdomain-wide
|
||||||
|
// bare-host domain + FALSE flag → host-only
|
||||||
|
const hostOnly = c.hostOnly === true;
|
||||||
|
let domain = c.domain;
|
||||||
|
if (!hostOnly && !domain.startsWith('.')) {
|
||||||
|
domain = '.' + domain;
|
||||||
|
}
|
||||||
|
const subdomainFlag = hostOnly ? 'FALSE' : 'TRUE';
|
||||||
const secure = c.secure ? 'TRUE' : 'FALSE';
|
const secure = c.secure ? 'TRUE' : 'FALSE';
|
||||||
const expiration = c.expirationDate ? Math.floor(c.expirationDate) : 0;
|
const expiration = c.expirationDate ? Math.floor(c.expirationDate) : 0;
|
||||||
lines.push([domain, 'TRUE', c.path || '/', secure, String(expiration), c.name, c.value].join('\t'));
|
lines.push([domain, subdomainFlag, c.path || '/', secure, String(expiration), c.name, c.value].join('\t'));
|
||||||
}
|
}
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "FabledCurator",
|
"name": "FabledCurator",
|
||||||
"version": "1.0.3",
|
"version": "1.0.5",
|
||||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||||
|
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"content_security_policy": {
|
||||||
|
"_comment": "Override the MV3 default CSP to OMIT upgrade-insecure-requests. FC runs over plain HTTP per the homelab posture (feedback_homelab_http), and the default MV3 CSP would silently upgrade every fetch(http://curator.../...) to https:// and fail with NS_ERROR_GENERATE_FAILURE. Operator-flagged 2026-05-26 after the 'Test connection' button errored despite a working CORS preflight on the backend.",
|
||||||
|
"extension_pages": "script-src 'self'; object-src 'self';"
|
||||||
|
},
|
||||||
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage",
|
"storage",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fabledcurator-extension",
|
"name": "fabledcurator-extension",
|
||||||
"version": "1.0.3",
|
"version": "1.0.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Firefox extension for FabledCurator",
|
"description": "Firefox extension for FabledCurator",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-artist-gallery">
|
||||||
|
<MasonryGrid
|
||||||
|
:items="store.images"
|
||||||
|
:loading="store.imagesLoading"
|
||||||
|
:has-more="store.hasMoreImages"
|
||||||
|
@load-more="store.loadMoreImages(props.slug)"
|
||||||
|
@open="openImage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { useArtistStore } from '../../stores/artist.js'
|
||||||
|
import { useModalStore } from '../../stores/modal.js'
|
||||||
|
import MasonryGrid from '../discovery/MasonryGrid.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
slug: { type: String, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useArtistStore()
|
||||||
|
const modal = useModalStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const initial = parseInt(route.query.image, 10)
|
||||||
|
if (!isNaN(initial)) modal.open(initial)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.query.image, (q) => {
|
||||||
|
const id = parseInt(q, 10)
|
||||||
|
if (!isNaN(id) && id !== modal.currentImageId) modal.open(id)
|
||||||
|
else if (isNaN(id) && modal.currentImageId !== null) modal.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openImage (id) {
|
||||||
|
router.push({ query: { ...route.query, image: id } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-artist-gallery { min-width: 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<header class="fc-artist-header">
|
||||||
|
<div class="fc-artist-header__left">
|
||||||
|
<h1 class="fc-artist-header__name">{{ name }}</h1>
|
||||||
|
<span v-if="stats" class="fc-artist-header__stats">{{ stats }}</span>
|
||||||
|
</div>
|
||||||
|
<v-tabs
|
||||||
|
:model-value="modelValue"
|
||||||
|
color="accent"
|
||||||
|
density="compact"
|
||||||
|
class="fc-artist-header__tabs"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<v-tab value="posts">
|
||||||
|
Posts
|
||||||
|
<span v-if="postCount != null" class="fc-artist-header__tab-count">
|
||||||
|
({{ postCount }})
|
||||||
|
</span>
|
||||||
|
</v-tab>
|
||||||
|
<v-tab value="gallery">
|
||||||
|
Gallery
|
||||||
|
<span v-if="imageCount != null" class="fc-artist-header__tab-count">
|
||||||
|
({{ imageCount }})
|
||||||
|
</span>
|
||||||
|
</v-tab>
|
||||||
|
<v-tab value="management">Management</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
|
||||||
|
<!-- Right-side spacer: balances the left cell's flex weight so the
|
||||||
|
centered tabs stay geometrically centered regardless of the
|
||||||
|
artist-name length. Mirrors TopNav's 1fr | auto | 1fr layout. -->
|
||||||
|
<div class="fc-artist-header__right" />
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
imageCount: { type: Number, default: null },
|
||||||
|
postCount: { type: Number, default: null },
|
||||||
|
lastAdded: { type: String, default: null },
|
||||||
|
modelValue: { type: String, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const stats = computed(() => {
|
||||||
|
const parts = []
|
||||||
|
if (props.imageCount != null) {
|
||||||
|
parts.push(`${props.imageCount} image${props.imageCount === 1 ? '' : 's'}`)
|
||||||
|
}
|
||||||
|
if (props.lastAdded) {
|
||||||
|
parts.push(`last added ${props.lastAdded.slice(0, 10)}`)
|
||||||
|
}
|
||||||
|
return parts.join(' · ')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Matches TopNav.vue's frosted recipe exactly. top:48px parks it flush
|
||||||
|
against TopNav's bottom edge (TopNav is 0.75rem padding + ~24px content
|
||||||
|
= ~48px tall; operator-flagged 2026-05-26 that top:64px left a visible
|
||||||
|
gap). The two bars now read as one continuous frosted strip. */
|
||||||
|
.fc-artist-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 48px;
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(20, 23, 26, 0.92) 0%,
|
||||||
|
rgba(20, 23, 26, 0.65) 60%,
|
||||||
|
rgba(20, 23, 26, 0) 100%
|
||||||
|
);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
-webkit-backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-artist-header__left {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-artist-header__name {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-artist-header__stats {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-artist-header__tabs {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-artist-header__right {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-artist-header__tab-count {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-artist-mgmt">
|
||||||
|
<section class="fc-artist-mgmt__sec">
|
||||||
|
<h2 class="fc-h2">Overview</h2>
|
||||||
|
<div class="fc-artist-mgmt__chips">
|
||||||
|
<v-chip
|
||||||
|
size="small"
|
||||||
|
:variant="overview.is_subscription ? 'flat' : 'outlined'"
|
||||||
|
:color="overview.is_subscription ? 'accent' : undefined"
|
||||||
|
prepend-icon="mdi-rss"
|
||||||
|
>{{ overview.is_subscription ? 'Subscription' : 'One-off' }}</v-chip>
|
||||||
|
<v-chip
|
||||||
|
size="small" variant="outlined" prepend-icon="mdi-link-variant"
|
||||||
|
:to="`/subscriptions?artist_id=${overview.id}`"
|
||||||
|
>{{ overview.sources.length }} subscription{{ overview.sources.length === 1 ? '' : 's' }}</v-chip>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="overview.cooccurring_tags.length" class="fc-artist-mgmt__sec">
|
||||||
|
<h2 class="fc-h2">Frequent tags</h2>
|
||||||
|
<div class="fc-artist-mgmt__tags">
|
||||||
|
<v-chip
|
||||||
|
v-for="t in overview.cooccurring_tags" :key="t.id"
|
||||||
|
size="small" @click="openTag(t.id)"
|
||||||
|
>{{ t.name }} <span class="fc-artist-mgmt__tagc">{{ t.count }}</span></v-chip>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="overview.activity.length" class="fc-artist-mgmt__sec">
|
||||||
|
<h2 class="fc-h2">Activity</h2>
|
||||||
|
<svg
|
||||||
|
class="fc-artist-mgmt__spark" :viewBox="`0 0 ${sparkW} ${sparkH}`"
|
||||||
|
preserveAspectRatio="none" role="img" aria-label="posts over time"
|
||||||
|
>
|
||||||
|
<polyline :points="sparkPoints" fill="none"
|
||||||
|
stroke="rgb(var(--v-theme-accent))" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="overview.sources.length" class="fc-artist-mgmt__sec">
|
||||||
|
<div class="fc-artist-mgmt__sec-head">
|
||||||
|
<h2 class="fc-h2">Subscriptions</h2>
|
||||||
|
<RouterLink
|
||||||
|
:to="`/subscriptions?artist_id=${overview.id}`"
|
||||||
|
class="fc-artist-mgmt__manage"
|
||||||
|
>Manage subscriptions →</RouterLink>
|
||||||
|
</div>
|
||||||
|
<v-table density="compact">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Platform</th><th>URL</th><th class="text-right">Images</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="s in overview.sources" :key="s.id">
|
||||||
|
<td>{{ s.platform }}</td>
|
||||||
|
<td class="fc-artist-mgmt__url">{{ s.url }}</td>
|
||||||
|
<td class="text-right">{{ s.image_count }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fc-artist-mgmt__sec">
|
||||||
|
<h2 class="fc-h2">Danger zone</h2>
|
||||||
|
<ArtistDangerZone
|
||||||
|
:slug="overview.slug"
|
||||||
|
:artist-id="overview.id"
|
||||||
|
:artist-name="overview.name"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import ArtistDangerZone from './ArtistDangerZone.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
overview: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const sparkW = 600
|
||||||
|
const sparkH = 80
|
||||||
|
const sparkPoints = computed(() => {
|
||||||
|
const a = props.overview.activity ?? []
|
||||||
|
if (a.length === 0) return ''
|
||||||
|
const max = Math.max(...a.map(p => p.count), 1)
|
||||||
|
const stepX = a.length > 1 ? sparkW / (a.length - 1) : 0
|
||||||
|
return a.map((p, i) => {
|
||||||
|
const x = i * stepX
|
||||||
|
const y = sparkH - (p.count / max) * (sparkH - 4) - 2
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
}).join(' ')
|
||||||
|
})
|
||||||
|
|
||||||
|
function openTag (tagId) {
|
||||||
|
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-artist-mgmt { padding-top: 1rem; }
|
||||||
|
.fc-h2 {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 20px; font-weight: 500; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fc-artist-mgmt__sec { margin-bottom: 28px; }
|
||||||
|
.fc-artist-mgmt__chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.fc-artist-mgmt__tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.fc-artist-mgmt__tagc {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
margin-left: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.fc-artist-mgmt__spark { width: 100%; height: 80px; }
|
||||||
|
.fc-artist-mgmt__url {
|
||||||
|
max-width: 380px; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fc-artist-mgmt__sec-head {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.fc-artist-mgmt__manage {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgb(var(--v-theme-accent));
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.fc-artist-mgmt__manage:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-artist-posts">
|
||||||
|
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
|
||||||
|
{{ String(store.error) }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<div v-if="store.loading && store.items.length === 0" class="fc-artist-posts__loading">
|
||||||
|
<v-progress-circular indeterminate color="accent" size="36" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="store.items.length === 0 && store.done" class="fc-artist-posts__empty">
|
||||||
|
<p>No posts for this artist yet. Switch to
|
||||||
|
<a href="#" @click.prevent="$emit('switch-tab', 'gallery')">Gallery</a>
|
||||||
|
to see imported images, or visit
|
||||||
|
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
|
||||||
|
to start capturing posts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
|
||||||
|
<div ref="sentinel" class="fc-artist-posts__sentinel">
|
||||||
|
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
|
||||||
|
<span v-else-if="store.done" class="fc-artist-posts__end">End of stream</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import { usePostsStore } from '../../stores/posts.js'
|
||||||
|
import PostCard from '../posts/PostCard.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
artistId: { type: Number, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['switch-tab'])
|
||||||
|
|
||||||
|
const store = usePostsStore()
|
||||||
|
const sentinel = ref(null)
|
||||||
|
let observer = null
|
||||||
|
|
||||||
|
async function reload () {
|
||||||
|
await store.loadInitial({ artist_id: props.artistId, platform: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.artistId, reload)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await reload()
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries.some(e => e.isIntersecting)) {
|
||||||
|
store.loadMore()
|
||||||
|
}
|
||||||
|
}, { rootMargin: '400px 0px' })
|
||||||
|
if (sentinel.value) observer.observe(sentinel.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (observer) observer.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-artist-posts {
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.fc-artist-posts__loading,
|
||||||
|
.fc-artist-posts__empty {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
.fc-artist-posts__sentinel {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
.fc-artist-posts__end {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="fc-clean-card">
|
||||||
|
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||||||
|
<v-icon icon="mdi-image-size-select-small" size="small" />
|
||||||
|
<span>Minimum dimensions</span>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
|
Find and delete images smaller than the threshold. Mirrors the
|
||||||
|
import-time <code>min_width</code> / <code>min_height</code>
|
||||||
|
filter, applied retroactively to the existing library.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="minW" label="Min width (px)" type="number"
|
||||||
|
min="0" density="compact" hide-details
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="minH" label="Min height (px)" type="number"
|
||||||
|
min="0" density="compact" hide-details
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<div class="d-flex align-center mt-3" style="gap: 10px;">
|
||||||
|
<v-btn
|
||||||
|
color="accent" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-magnify"
|
||||||
|
:loading="busy"
|
||||||
|
@click="onPreview"
|
||||||
|
>Preview</v-btn>
|
||||||
|
|
||||||
|
<span v-if="preview" class="text-body-2">
|
||||||
|
<strong>{{ preview.count }}</strong> image(s) would be deleted.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-if="preview && preview.count > 0"
|
||||||
|
class="mt-3"
|
||||||
|
color="error" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-delete"
|
||||||
|
@click="onDeleteClick"
|
||||||
|
>Delete {{ preview.count }} matching...</v-btn>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<DestructiveConfirmModal
|
||||||
|
v-model="showModal"
|
||||||
|
action="delete"
|
||||||
|
kind="min-dim"
|
||||||
|
tier="C"
|
||||||
|
:expected-token-override="preview?.confirm_token || ''"
|
||||||
|
:projected-counts="projectedCounts"
|
||||||
|
:description="`Width < ${minW} OR height < ${minH}`"
|
||||||
|
@confirm="onConfirmedDelete"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
||||||
|
import { useCleanupStore } from '../../stores/cleanup.js'
|
||||||
|
|
||||||
|
// Backend's preview response hands the full Tier-C confirm token back
|
||||||
|
// as `confirm_token` (e.g. `delete-min-dim-1a2b3c4d`); passed straight
|
||||||
|
// to the modal via `expected-token-override`. We previously
|
||||||
|
// reconstructed via Web Crypto's SHA-256, but `crypto.subtle` is
|
||||||
|
// Secure-Context-gated and undefined on plain-HTTP origins, so the
|
||||||
|
// Delete button silently swallowed the TypeError. Operator-flagged
|
||||||
|
// 2026-05-27.
|
||||||
|
|
||||||
|
const store = useCleanupStore()
|
||||||
|
const minW = ref(0)
|
||||||
|
const minH = ref(0)
|
||||||
|
const preview = ref(null)
|
||||||
|
const busy = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const projectedCounts = ref({})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.loadDefaults()
|
||||||
|
minW.value = store.defaults.min_width
|
||||||
|
minH.value = store.defaults.min_height
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onPreview() {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
preview.value = await store.previewMinDim(minW.value, minH.value)
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({ text: `Preview failed: ${e.message}`, type: 'error' })
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteClick() {
|
||||||
|
projectedCounts.value = { 'Images to delete': preview.value.count }
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirmedDelete(token) {
|
||||||
|
try {
|
||||||
|
const res = await store.deleteMinDim(minW.value, minH.value, token)
|
||||||
|
window.__fcToast?.({
|
||||||
|
text: `Deleted ${res.deleted} image(s)`, type: 'success',
|
||||||
|
})
|
||||||
|
preview.value = null
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({ text: `Delete failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-clean-card { border-radius: 8px; }
|
||||||
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="fc-clean-card">
|
||||||
|
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||||||
|
<v-icon icon="mdi-palette-swatch" size="small" />
|
||||||
|
<span>Single-color audit</span>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
|
Scan library for images dominated by one color within the
|
||||||
|
tolerance. Catches placeholder / solid-fill / error-page images
|
||||||
|
that slipped through the import filter. Same background-scan
|
||||||
|
cadence as the transparency audit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="threshold" label="Threshold (0–1)"
|
||||||
|
type="number" min="0" max="1" step="0.01"
|
||||||
|
density="compact" hide-details
|
||||||
|
:disabled="audit && audit.status === 'running'"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="tolerance" label="Color tolerance (0–441)"
|
||||||
|
type="number" min="0" max="441"
|
||||||
|
density="compact" hide-details
|
||||||
|
:disabled="audit && audit.status === 'running'"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-if="!audit || audit.status !== 'running'"
|
||||||
|
class="mt-3"
|
||||||
|
color="accent" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-magnify-scan"
|
||||||
|
:loading="busy"
|
||||||
|
@click="onStart"
|
||||||
|
>Scan library</v-btn>
|
||||||
|
|
||||||
|
<div v-if="audit && audit.status === 'running'" class="mt-3">
|
||||||
|
<v-progress-linear indeterminate color="accent" />
|
||||||
|
<div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;">
|
||||||
|
<span>
|
||||||
|
Scanning… {{ audit.scanned_count }} checked,
|
||||||
|
{{ audit.matched_count }} matched
|
||||||
|
</span>
|
||||||
|
<v-btn
|
||||||
|
variant="text" size="small" color="warning" rounded="pill"
|
||||||
|
@click="onCancel"
|
||||||
|
>Cancel</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="audit && audit.status === 'ready'" class="mt-3">
|
||||||
|
<p class="text-body-2 mb-2">
|
||||||
|
Scan complete. <strong>{{ audit.matched_count }}</strong>
|
||||||
|
image(s) match.
|
||||||
|
</p>
|
||||||
|
<v-btn
|
||||||
|
v-if="audit.matched_count > 0"
|
||||||
|
color="error" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-delete"
|
||||||
|
@click="onApplyClick"
|
||||||
|
>Delete {{ audit.matched_count }} matching...</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="audit && audit.status === 'error'"
|
||||||
|
type="error" variant="tonal" density="compact" class="mt-3"
|
||||||
|
>Scan failed: {{ audit.error }}</v-alert>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="audit && audit.status === 'applied'"
|
||||||
|
type="success" variant="tonal" density="compact" class="mt-3"
|
||||||
|
>Applied — matched images deleted.</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<DestructiveConfirmModal
|
||||||
|
v-if="audit"
|
||||||
|
v-model="showModal"
|
||||||
|
action="delete"
|
||||||
|
kind="audit"
|
||||||
|
:run-id="audit.id"
|
||||||
|
tier="C"
|
||||||
|
:projected-counts="projectedCounts"
|
||||||
|
description="Permanently deletes images matched by the single-color scan."
|
||||||
|
@confirm="onConfirmedApply"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
||||||
|
import { useCleanupStore } from '../../stores/cleanup.js'
|
||||||
|
|
||||||
|
const store = useCleanupStore()
|
||||||
|
const threshold = ref(0.95)
|
||||||
|
const tolerance = ref(30)
|
||||||
|
const audit = ref(null)
|
||||||
|
const busy = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const projectedCounts = ref({})
|
||||||
|
let pollTimer = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.loadDefaults()
|
||||||
|
threshold.value = store.defaults.single_color_threshold
|
||||||
|
tolerance.value = store.defaults.single_color_tolerance
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => stopPoll())
|
||||||
|
|
||||||
|
function startPoll(id) {
|
||||||
|
stopPoll()
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const fresh = await store.getAudit(id)
|
||||||
|
audit.value = fresh
|
||||||
|
if (fresh.status !== 'running') stopPoll()
|
||||||
|
} catch (e) {
|
||||||
|
stopPoll()
|
||||||
|
window.__fcToast?.({ text: `Audit poll failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPoll() {
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onStart() {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const res = await store.startAudit('single_color', {
|
||||||
|
threshold: threshold.value, tolerance: tolerance.value,
|
||||||
|
})
|
||||||
|
audit.value = await store.getAudit(res.audit_id)
|
||||||
|
startPoll(res.audit_id)
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({ text: `Scan start failed: ${e.message}`, type: 'error' })
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCancel() {
|
||||||
|
if (!audit.value) return
|
||||||
|
try {
|
||||||
|
await store.cancelAudit(audit.value.id)
|
||||||
|
audit.value = await store.getAudit(audit.value.id)
|
||||||
|
stopPoll()
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({ text: `Cancel failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onApplyClick() {
|
||||||
|
projectedCounts.value = { 'Images to delete': audit.value.matched_count }
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirmedApply(token) {
|
||||||
|
try {
|
||||||
|
const res = await store.applyAudit(audit.value.id, token)
|
||||||
|
window.__fcToast?.({
|
||||||
|
text: `Deleted ${res.deleted} image(s)`, type: 'success',
|
||||||
|
})
|
||||||
|
audit.value = await store.getAudit(audit.value.id)
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({ text: `Apply failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-clean-card { border-radius: 8px; }
|
||||||
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="fc-clean-card">
|
||||||
|
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||||||
|
<v-icon icon="mdi-checkerboard" size="small" />
|
||||||
|
<span>Transparency audit</span>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
|
Scan library for images whose transparent-pixel fraction exceeds
|
||||||
|
the threshold. Animated WebPs / GIFs are skipped (the import-side
|
||||||
|
rule does the same). Runs as a background task — ~50ms per image,
|
||||||
|
so a 57k library takes ~50 minutes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="threshold" label="Transparency threshold (0–1)"
|
||||||
|
type="number" min="0" max="1" step="0.01" density="compact" hide-details
|
||||||
|
:disabled="audit && audit.status === 'running'"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-if="!audit || audit.status !== 'running'"
|
||||||
|
color="accent" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-magnify-scan"
|
||||||
|
:loading="busy"
|
||||||
|
@click="onStart"
|
||||||
|
>Scan library</v-btn>
|
||||||
|
|
||||||
|
<div v-if="audit && audit.status === 'running'" class="mt-3">
|
||||||
|
<v-progress-linear indeterminate color="accent" />
|
||||||
|
<div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;">
|
||||||
|
<span>
|
||||||
|
Scanning… {{ audit.scanned_count }} checked,
|
||||||
|
{{ audit.matched_count }} matched
|
||||||
|
</span>
|
||||||
|
<v-btn
|
||||||
|
variant="text" size="small" color="warning" rounded="pill"
|
||||||
|
@click="onCancel"
|
||||||
|
>Cancel</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="audit && audit.status === 'ready'" class="mt-3">
|
||||||
|
<p class="text-body-2 mb-2">
|
||||||
|
Scan complete. <strong>{{ audit.matched_count }}</strong>
|
||||||
|
image(s) match.
|
||||||
|
</p>
|
||||||
|
<v-btn
|
||||||
|
v-if="audit.matched_count > 0"
|
||||||
|
color="error" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-delete"
|
||||||
|
@click="onApplyClick"
|
||||||
|
>Delete {{ audit.matched_count }} matching...</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="audit && audit.status === 'error'"
|
||||||
|
type="error" variant="tonal" density="compact" class="mt-3"
|
||||||
|
>Scan failed: {{ audit.error }}</v-alert>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="audit && audit.status === 'applied'"
|
||||||
|
type="success" variant="tonal" density="compact" class="mt-3"
|
||||||
|
>Applied — matched images deleted.</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<DestructiveConfirmModal
|
||||||
|
v-if="audit"
|
||||||
|
v-model="showModal"
|
||||||
|
action="delete"
|
||||||
|
kind="audit"
|
||||||
|
:run-id="audit.id"
|
||||||
|
tier="C"
|
||||||
|
:projected-counts="projectedCounts"
|
||||||
|
description="Permanently deletes images matched by the transparency scan."
|
||||||
|
@confirm="onConfirmedApply"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
||||||
|
import { useCleanupStore } from '../../stores/cleanup.js'
|
||||||
|
|
||||||
|
const store = useCleanupStore()
|
||||||
|
const threshold = ref(0.9)
|
||||||
|
const audit = ref(null)
|
||||||
|
const busy = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const projectedCounts = ref({})
|
||||||
|
let pollTimer = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.loadDefaults()
|
||||||
|
threshold.value = store.defaults.transparency_threshold
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => stopPoll())
|
||||||
|
|
||||||
|
function startPoll(id) {
|
||||||
|
stopPoll()
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const fresh = await store.getAudit(id)
|
||||||
|
audit.value = fresh
|
||||||
|
if (fresh.status !== 'running') stopPoll()
|
||||||
|
} catch (e) {
|
||||||
|
stopPoll()
|
||||||
|
window.__fcToast?.({ text: `Audit poll failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPoll() {
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onStart() {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const res = await store.startAudit('transparency', { threshold: threshold.value })
|
||||||
|
audit.value = await store.getAudit(res.audit_id)
|
||||||
|
startPoll(res.audit_id)
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({ text: `Scan start failed: ${e.message}`, type: 'error' })
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCancel() {
|
||||||
|
if (!audit.value) return
|
||||||
|
try {
|
||||||
|
await store.cancelAudit(audit.value.id)
|
||||||
|
audit.value = await store.getAudit(audit.value.id)
|
||||||
|
stopPoll()
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({ text: `Cancel failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onApplyClick() {
|
||||||
|
projectedCounts.value = { 'Images to delete': audit.value.matched_count }
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirmedApply(token) {
|
||||||
|
try {
|
||||||
|
const res = await store.applyAudit(audit.value.id, token)
|
||||||
|
window.__fcToast?.({
|
||||||
|
text: `Deleted ${res.deleted} image(s)`, type: 'success',
|
||||||
|
})
|
||||||
|
audit.value = await store.getAudit(audit.value.id)
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({ text: `Apply failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-clean-card { border-radius: 8px; }
|
||||||
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog :model-value="modelValue" max-width="900"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex align-center" style="gap: 12px;">
|
||||||
|
<v-icon icon="mdi-alert-circle-outline" color="error" />
|
||||||
|
<span>{{ displayTitle }}</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn icon variant="text" size="small" @click="close">
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<dl v-if="contextRows.length" class="fc-err-context">
|
||||||
|
<template v-for="(row, idx) in contextRows" :key="idx">
|
||||||
|
<dt>{{ row[0] }}</dt>
|
||||||
|
<dd>{{ row[1] }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
<pre class="fc-err-pre">{{ displayMessage || '(no error message)' }}</pre>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-btn
|
||||||
|
variant="text" rounded="pill" size="small"
|
||||||
|
:prepend-icon="copied ? 'mdi-check' : 'mdi-content-copy'"
|
||||||
|
@click="onCopy"
|
||||||
|
>{{ copied ? 'Copied' : 'Copy' }}</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" rounded="pill" @click="close">Close</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { copyText } from '../../utils/clipboard.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
// Legacy mode: pass title + message strings. Used by callers whose row
|
||||||
|
// shape lacks structured context (e.g. ImportTaskList where `error` is
|
||||||
|
// a plain string on the import_task row, not a TaskRun).
|
||||||
|
title: { type: String, default: 'Error details' },
|
||||||
|
message: { type: String, default: '' },
|
||||||
|
// Row mode: pass the full TaskRun-shaped dict from /api/system_activity.
|
||||||
|
// When set, displayTitle/displayMessage derive from the row and a context
|
||||||
|
// panel of task_name/queue/target/duration/etc. renders above the error.
|
||||||
|
row: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
let copiedTimer = null
|
||||||
|
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
if (props.row) return props.row.error_type || 'Error details'
|
||||||
|
return props.title
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayMessage = computed(() => {
|
||||||
|
if (props.row) return props.row.error_message || ''
|
||||||
|
return props.message
|
||||||
|
})
|
||||||
|
|
||||||
|
function _shortTaskName (name) {
|
||||||
|
if (!name) return ''
|
||||||
|
const parts = String(name).split('.')
|
||||||
|
return parts[parts.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatDuration (ms) {
|
||||||
|
if (ms == null) return null
|
||||||
|
if (ms < 1000) return `${ms} ms`
|
||||||
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`
|
||||||
|
return `${(ms / 60_000).toFixed(1)} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextRows = computed(() => {
|
||||||
|
const r = props.row
|
||||||
|
if (!r) return []
|
||||||
|
const rows = []
|
||||||
|
if (r.task_name) rows.push(['Task', _shortTaskName(r.task_name)])
|
||||||
|
if (r.queue) rows.push(['Queue', r.queue])
|
||||||
|
if (r.target_id != null) rows.push(['Target', r.target_id])
|
||||||
|
const dur = _formatDuration(r.duration_ms)
|
||||||
|
if (dur != null) rows.push(['Duration', dur])
|
||||||
|
if (r.started_at) rows.push(['Started', r.started_at])
|
||||||
|
if (r.finished_at) rows.push(['Finished', r.finished_at])
|
||||||
|
if (r.retry_count) rows.push(['Retries', r.retry_count])
|
||||||
|
if (r.worker_hostname) rows.push(['Worker', r.worker_hostname])
|
||||||
|
if (r.celery_task_id) rows.push(['Celery ID', r.celery_task_id])
|
||||||
|
if (r.args_summary) rows.push(['Args', r.args_summary])
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
copied.value = false
|
||||||
|
if (copiedTimer) { clearTimeout(copiedTimer); copiedTimer = null }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function close () {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCopy () {
|
||||||
|
let text = displayMessage.value || ''
|
||||||
|
if (contextRows.value.length) {
|
||||||
|
const header = contextRows.value.map(([k, v]) => `${k}: ${v}`).join('\n')
|
||||||
|
text = `${header}\n\nError: ${displayTitle.value}\n${text}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await copyText(text)
|
||||||
|
copied.value = true
|
||||||
|
if (copiedTimer) clearTimeout(copiedTimer)
|
||||||
|
copiedTimer = setTimeout(() => { copied.value = false }, 1500)
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({ text: `Copy failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Context panel: muted labels in vellum, crisp values in parchment. The
|
||||||
|
2-column key/value grid keeps rows visually scannable when there are
|
||||||
|
many fields (Task, Queue, Target, Duration, Started, Finished, Retries,
|
||||||
|
Worker, Celery ID, Args). */
|
||||||
|
.fc-err-context {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
column-gap: 14px;
|
||||||
|
row-gap: 4px;
|
||||||
|
margin: 0 0 14px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.fc-err-context dt {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fc-err-context dd {
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
margin: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error pre block: high-contrast pairing. The page's `background` token
|
||||||
|
(obsidian #14171A) is darker than the modal card's `surface` (iron
|
||||||
|
#1E2228), so parchment text reads crisply against it. The prior pairing
|
||||||
|
used `surface-variant` which Vuetify auto-derives to a near-parchment
|
||||||
|
light value in this theme — pale-on-pale and unreadable. Operator-
|
||||||
|
flagged 2026-05-26 ("ui contrast is poor"). */
|
||||||
|
.fc-err-pre {
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: rgb(var(--v-theme-background));
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useCredentialsStore } from '../../stores/credentials.js'
|
import { useCredentialsStore } from '../../stores/credentials.js'
|
||||||
|
import { copyText } from '../../utils/clipboard.js'
|
||||||
|
|
||||||
const store = useCredentialsStore()
|
const store = useCredentialsStore()
|
||||||
const showRotateConfirm = ref(false)
|
const showRotateConfirm = ref(false)
|
||||||
@@ -37,7 +38,7 @@ onMounted(() => store.loadKey())
|
|||||||
async function copyKey() {
|
async function copyKey() {
|
||||||
if (!store.extensionKey) return
|
if (!store.extensionKey) return
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(store.extensionKey)
|
await copyText(store.extensionKey)
|
||||||
globalThis.window?.__fcToast?.({ text: 'Copied', type: 'success' })
|
globalThis.window?.__fcToast?.({ text: 'Copied', type: 'success' })
|
||||||
} catch {
|
} catch {
|
||||||
globalThis.window?.__fcToast?.({ text: 'Copy failed', type: 'error' })
|
globalThis.window?.__fcToast?.({ text: 'Copy failed', type: 'error' })
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
<div v-for="(col, ci) in columns" :key="ci" class="fc-masonry__col">
|
<div v-for="(col, ci) in columns" :key="ci" class="fc-masonry__col">
|
||||||
<button
|
<button
|
||||||
v-for="item in col" :key="item.id"
|
v-for="item in col" :key="item.id"
|
||||||
class="fc-masonry__item" type="button"
|
class="fc-masonry__item"
|
||||||
|
:class="{ 'fc-masonry__item--anim': shouldAnimate(item) }"
|
||||||
|
:style="itemStyle(item)"
|
||||||
|
type="button"
|
||||||
@click="$emit('open', item.id)"
|
@click="$emit('open', item.id)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -33,7 +36,13 @@ import { usePolyMasonry } from '../../composables/usePolyMasonry.js'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: { type: Array, default: () => [] },
|
items: { type: Array, default: () => [] },
|
||||||
loading: { type: Boolean, default: false },
|
loading: { type: Boolean, default: false },
|
||||||
hasMore: { type: Boolean, default: false }
|
hasMore: { type: Boolean, default: false },
|
||||||
|
// Items at indices >= animateFromIndex get the stagger fade-in. Opt-in
|
||||||
|
// — defaults to Infinity (no animation) so views that don't want it
|
||||||
|
// (ArtistView, etc.) don't pay the layout-shift cost. ShowcaseView
|
||||||
|
// uses 0 on initial load / shuffle and prevCount on infinite-scroll
|
||||||
|
// appends.
|
||||||
|
animateFromIndex: { type: Number, default: Number.POSITIVE_INFINITY },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['load-more', 'open'])
|
const emit = defineEmits(['load-more', 'open'])
|
||||||
|
|
||||||
@@ -43,6 +52,25 @@ const { columnCount, distribute } = usePolyMasonry(containerEl)
|
|||||||
|
|
||||||
const columns = computed(() => distribute(props.items, columnCount.value))
|
const columns = computed(() => distribute(props.items, columnCount.value))
|
||||||
|
|
||||||
|
// id → index lookup so we can derive the stagger from natural reading
|
||||||
|
// order even after the masonry distributes items across columns.
|
||||||
|
const idxById = computed(() => {
|
||||||
|
const m = new Map()
|
||||||
|
props.items.forEach((it, i) => m.set(it.id, i))
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
|
||||||
|
function shouldAnimate(item) {
|
||||||
|
const idx = idxById.value.get(item.id)
|
||||||
|
return idx !== undefined && idx >= props.animateFromIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemStyle(item) {
|
||||||
|
if (!shouldAnimate(item)) return {}
|
||||||
|
const idx = idxById.value.get(item.id) - props.animateFromIndex
|
||||||
|
return { '--stagger-index': idx }
|
||||||
|
}
|
||||||
|
|
||||||
function aspectStyle(item) {
|
function aspectStyle(item) {
|
||||||
const w = Number(item.width)
|
const w = Number(item.width)
|
||||||
const h = Number(item.height)
|
const h = Number(item.height)
|
||||||
@@ -81,4 +109,23 @@ onUnmounted(() => observer && observer.disconnect())
|
|||||||
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
|
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
|
||||||
}
|
}
|
||||||
.fc-masonry__end { text-align: center; padding: 32px 0; }
|
.fc-masonry__end { text-align: center; padding: 32px 0; }
|
||||||
|
|
||||||
|
/* IR-parity stagger fade-in for showcase entry / shuffle. 60ms between
|
||||||
|
items, 250ms each — matches IR's `itemFadeIn` keyframe (style.css
|
||||||
|
~line 1834). Honors prefers-reduced-motion. */
|
||||||
|
@keyframes fc-masonry-item-in {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.fc-masonry__item--anim {
|
||||||
|
animation: fc-masonry-item-in 0.25s ease forwards;
|
||||||
|
animation-delay: calc(var(--stagger-index, 0) * 60ms);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fc-masonry__item--anim {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
v-model="deleteModalOpen"
|
v-model="deleteModalOpen"
|
||||||
action="delete"
|
action="delete"
|
||||||
kind="images-selection"
|
kind="images-selection"
|
||||||
:run-id="bulkToken"
|
:expected-token-override="bulkProjected?.confirm_token || ''"
|
||||||
tier="C"
|
tier="C"
|
||||||
:projected-counts="bulkProjectedCounts"
|
:projected-counts="bulkProjectedCounts"
|
||||||
:description="bulkDescription"
|
:description="bulkDescription"
|
||||||
@@ -130,7 +130,6 @@ watch(() => sel.order.length, () => {
|
|||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
const deleteModalOpen = ref(false)
|
const deleteModalOpen = ref(false)
|
||||||
const bulkProjected = ref(null)
|
const bulkProjected = ref(null)
|
||||||
const bulkToken = ref('')
|
|
||||||
|
|
||||||
const bulkProjectedCounts = computed(() => bulkProjected.value
|
const bulkProjectedCounts = computed(() => bulkProjected.value
|
||||||
? {
|
? {
|
||||||
@@ -147,24 +146,22 @@ const bulkDescription = computed(
|
|||||||
: '',
|
: '',
|
||||||
)
|
)
|
||||||
|
|
||||||
async function _computeSha8(ids) {
|
// The dry-run response hands the canonical Tier-C confirm token back
|
||||||
const canon = [...ids].sort((a, b) => a - b).join(',')
|
// as `confirm_token` (e.g. `delete-images-1a2b3c4d`), passed straight
|
||||||
const buf = new TextEncoder().encode(canon)
|
// to the modal via `expected-token-override`. We used to compute the
|
||||||
const hashBuf = await crypto.subtle.digest('SHA-256', buf)
|
// hash client-side via `crypto.subtle.digest`, but (1) that's
|
||||||
const bytes = new Uint8Array(hashBuf)
|
// Secure-Context-gated and undefined on plain-HTTP origins
|
||||||
let hex = ''
|
// (homelab posture), so the click silently threw TypeError and the
|
||||||
for (let i = 0; i < 4; i++) {
|
// modal never opened, and (2) the modal's `kind="images-selection"`
|
||||||
hex += bytes[i].toString(16).padStart(2, '0')
|
// produced `delete-images-selection-<sha8>` while the backend
|
||||||
}
|
// expected `delete-images-<sha8>` — so it never would have worked
|
||||||
return hex
|
// even on HTTPS. Operator-flagged 2026-05-27.
|
||||||
}
|
|
||||||
|
|
||||||
async function onDeleteClick() {
|
async function onDeleteClick() {
|
||||||
if (!sel.order.length) return
|
if (!sel.order.length) return
|
||||||
deleting.value = true
|
deleting.value = true
|
||||||
try {
|
try {
|
||||||
bulkProjected.value = await adminStore.projectBulkImageDelete(sel.order)
|
bulkProjected.value = await adminStore.projectBulkImageDelete(sel.order)
|
||||||
bulkToken.value = await _computeSha8(sel.order)
|
|
||||||
deleteModalOpen.value = true
|
deleteModalOpen.value = true
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false
|
deleting.value = false
|
||||||
|
|||||||
@@ -58,17 +58,27 @@ import { computed, ref, watch } from 'vue'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: Boolean, required: true },
|
modelValue: { type: Boolean, required: true },
|
||||||
action: { type: String, required: true }, // 'restore' | 'delete'
|
action: { type: String, required: true }, // 'restore' | 'delete'
|
||||||
kind: { type: String, required: true }, // 'db' | 'images' | 'artist' | 'tag' | 'images-selection'
|
kind: { type: String, required: true }, // 'db' | 'images' | 'artist' | 'tag' | 'images-selection' | 'audit' | 'min-dim'
|
||||||
runId: { type: [Number, String], default: '' }, // numeric id or sha8 string
|
runId: { type: [Number, String], default: '' }, // numeric id or sha8 string
|
||||||
description: { type: String, default: '' },
|
description: { type: String, default: '' },
|
||||||
tier: { type: String, default: 'C' }, // 'B' | 'C'
|
tier: { type: String, default: 'C' }, // 'B' | 'C'
|
||||||
projectedCounts: { type: Object, default: null },
|
projectedCounts: { type: Object, default: null },
|
||||||
|
// Override the `${action}-${kind}-${runId}` token formula. Use when
|
||||||
|
// the backend computes the canonical confirm token (e.g. bulk-delete
|
||||||
|
// and min-dim cleanup both return `confirm_token` from their dry-run
|
||||||
|
// endpoints) and the UI's kind/runId would otherwise produce a
|
||||||
|
// mismatched string. Operator-flagged 2026-05-27 after the
|
||||||
|
// BulkEditor's kind="images-selection" produced
|
||||||
|
// `delete-images-selection-<sha8>` while the backend expected
|
||||||
|
// `delete-images-<sha8>`.
|
||||||
|
expectedTokenOverride: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
const typed = ref('')
|
const typed = ref('')
|
||||||
const expectedToken = computed(
|
const expectedToken = computed(
|
||||||
() => `${props.action}-${props.kind}-${props.runId}`,
|
() => props.expectedTokenOverride
|
||||||
|
|| `${props.action}-${props.kind}-${props.runId}`,
|
||||||
)
|
)
|
||||||
const titleVerb = computed(
|
const titleVerb = computed(
|
||||||
() => props.action === 'restore' ? 'Restore' : 'Delete',
|
() => props.action === 'restore' ? 'Restore' : 'Delete',
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-tag-autocomplete">
|
<div class="fc-tag-autocomplete">
|
||||||
<div class="d-flex" style="gap: 6px;">
|
<v-text-field
|
||||||
<v-select
|
v-model="query"
|
||||||
v-model="kind" :items="kindOptions" :item-title="(k) => k.label" :item-value="(k) => k.value"
|
placeholder="Add tag (or kind:name — character/fandom/series)"
|
||||||
density="compact" hide-details style="max-width: 140px;"
|
density="compact" hide-details
|
||||||
/>
|
@keydown.down.prevent="moveHighlight(1)"
|
||||||
<v-text-field
|
@keydown.up.prevent="moveHighlight(-1)"
|
||||||
v-model="query" placeholder="Add tag…" density="compact" hide-details
|
@keydown.enter.prevent="onEnter"
|
||||||
@keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)"
|
@keydown.esc="$emit('cancel')"
|
||||||
@keydown.enter.prevent="onEnter" @keydown.esc="$emit('cancel')"
|
/>
|
||||||
/>
|
<v-list
|
||||||
</div>
|
v-if="hits.length || allowCreate"
|
||||||
<v-list v-if="hits.length || allowCreate" density="compact" class="fc-tag-autocomplete__list">
|
density="compact" class="fc-tag-autocomplete__list"
|
||||||
|
>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="(h, idx) in hits" :key="h.id"
|
v-for="(h, idx) in hits" :key="h.id"
|
||||||
:active="idx === highlight" @click="onPick(h)"
|
:active="idx === highlight" @click="onPick(h)"
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
<span v-if="h.fandom_name" class="text-caption">— {{ h.fandom_name }}</span>
|
<span v-if="h.fandom_name" class="text-caption">— {{ h.fandom_name }}</span>
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<template #append>
|
<template #append>
|
||||||
<span class="text-caption">{{ h.image_count }}</span>
|
<span class="text-caption">{{ h.kind }}</span>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
@@ -34,9 +35,13 @@
|
|||||||
@click="onCreate"
|
@click="onCreate"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon size="small" :color="store.colorFor(kind)">{{ iconFor(kind) }}</v-icon>
|
<v-icon size="small" :color="store.colorFor(parsedKind)">
|
||||||
|
{{ iconFor(parsedKind) }}
|
||||||
|
</v-icon>
|
||||||
</template>
|
</template>
|
||||||
<v-list-item-title>Create "{{ query }}" as {{ kind }}</v-list-item-title>
|
<v-list-item-title>
|
||||||
|
Create "{{ parsedName }}" as {{ parsedKind }}
|
||||||
|
</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
|
|
||||||
@@ -54,65 +59,95 @@ import FandomPicker from './FandomPicker.vue'
|
|||||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||||
const store = useTagStore()
|
const store = useTagStore()
|
||||||
|
|
||||||
const kind = ref('general')
|
// Single text input; no kind dropdown. Client-side mirror of the
|
||||||
|
// backend's parse_kind_prefix lives below — kept in sync with
|
||||||
|
// KNOWN_KINDS in backend/app/utils/tag_prefix.py. The backend is the
|
||||||
|
// canonical parser; this mirror just powers the live Create-label
|
||||||
|
// preview ("Create 'Eric' as artist") and the autocomplete query.
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const hits = ref([])
|
const hits = ref([])
|
||||||
const highlight = ref(0)
|
const highlight = ref(0)
|
||||||
const fandomDialog = ref(false)
|
const fandomDialog = ref(false)
|
||||||
let pendingNewName = null
|
let pendingNewName = null
|
||||||
|
|
||||||
const kindOptions = store.kindOptions()
|
const KNOWN_KINDS = new Set([
|
||||||
|
'character', 'fandom', 'series',
|
||||||
|
])
|
||||||
const KIND_ICONS = {
|
const KIND_ICONS = {
|
||||||
general: 'mdi-tag', artist: 'mdi-palette', character: 'mdi-account-circle',
|
general: 'mdi-tag', character: 'mdi-account-circle',
|
||||||
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
|
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
|
||||||
meta: 'mdi-cog-outline', rating: 'mdi-shield-check-outline'
|
|
||||||
}
|
}
|
||||||
function iconFor(k) { return KIND_ICONS[k] || 'mdi-tag' }
|
function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
|
||||||
|
|
||||||
|
const parsed = computed(() => {
|
||||||
|
const raw = query.value.trim()
|
||||||
|
if (raw.includes(':')) {
|
||||||
|
const idx = raw.indexOf(':')
|
||||||
|
const prefix = raw.slice(0, idx).toLowerCase()
|
||||||
|
if (KNOWN_KINDS.has(prefix)) {
|
||||||
|
return { kind: prefix, name: raw.slice(idx + 1).trim() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind: 'general', name: raw }
|
||||||
|
})
|
||||||
|
const parsedKind = computed(() => parsed.value.kind)
|
||||||
|
const parsedName = computed(() => parsed.value.name)
|
||||||
|
|
||||||
let debounceId = null
|
let debounceId = null
|
||||||
watch([query, kind], () => {
|
watch(query, () => {
|
||||||
highlight.value = 0
|
highlight.value = 0
|
||||||
if (debounceId) clearTimeout(debounceId)
|
if (debounceId) clearTimeout(debounceId)
|
||||||
debounceId = setTimeout(async () => {
|
debounceId = setTimeout(async () => {
|
||||||
const q = query.value.trim()
|
const q = parsedName.value
|
||||||
if (!q) { hits.value = []; return }
|
if (!q) { hits.value = []; return }
|
||||||
hits.value = await store.autocomplete(q, kind.value, 10)
|
// Autocomplete across ALL kinds. When the user typed a prefix the
|
||||||
|
// matches list is naturally narrower because the parsed name is
|
||||||
|
// shorter; we don't filter server-side by kind.
|
||||||
|
hits.value = await store.autocomplete(q, null, 10)
|
||||||
}, 200)
|
}, 200)
|
||||||
})
|
})
|
||||||
|
|
||||||
const allowCreate = computed(() => {
|
const allowCreate = computed(() => {
|
||||||
const q = query.value.trim()
|
const q = parsedName.value
|
||||||
return q && !hits.value.some(h => h.name.toLowerCase() === q.toLowerCase() && h.kind === kind.value)
|
if (!q) return false
|
||||||
|
return !hits.value.some(h =>
|
||||||
|
h.name.toLowerCase() === q.toLowerCase() && h.kind === parsedKind.value,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function moveHighlight(delta) {
|
function moveHighlight (delta) {
|
||||||
const total = hits.value.length + (allowCreate.value ? 1 : 0)
|
const total = hits.value.length + (allowCreate.value ? 1 : 0)
|
||||||
if (total === 0) return
|
if (total === 0) return
|
||||||
highlight.value = (highlight.value + delta + total) % total
|
highlight.value = (highlight.value + delta + total) % total
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPick(hit) { emit('pick-existing', hit); reset() }
|
function onPick (hit) { emit('pick-existing', hit); reset() }
|
||||||
|
|
||||||
function onCreate() {
|
function onCreate () {
|
||||||
const name = query.value.trim()
|
const name = parsedName.value
|
||||||
if (kind.value === 'character') {
|
const kind = parsedKind.value
|
||||||
// Character requires a fandom — open the picker.
|
if (kind === 'character') {
|
||||||
pendingNewName = name
|
pendingNewName = name
|
||||||
fandomDialog.value = true
|
fandomDialog.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
emit('pick-new', { name, kind: kind.value, fandom_id: null })
|
// Pass explicit kind here; the backend accepts both shapes. Passing
|
||||||
|
// it makes the parsed kind preview match the actual server outcome
|
||||||
|
// for users who didn't use a prefix (general goes through cleanly).
|
||||||
|
emit('pick-new', { name, kind, fandom_id: null })
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFandomChosen(fandom) {
|
function onFandomChosen (fandom) {
|
||||||
fandomDialog.value = false
|
fandomDialog.value = false
|
||||||
emit('pick-new', { name: pendingNewName, kind: 'character', fandom_id: fandom.id })
|
emit('pick-new', {
|
||||||
|
name: pendingNewName, kind: 'character', fandom_id: fandom.id,
|
||||||
|
})
|
||||||
pendingNewName = null
|
pendingNewName = null
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEnter() {
|
function onEnter () {
|
||||||
if (highlight.value < hits.value.length) {
|
if (highlight.value < hits.value.length) {
|
||||||
onPick(hits.value[highlight.value])
|
onPick(hits.value[highlight.value])
|
||||||
} else if (allowCreate.value) {
|
} else if (allowCreate.value) {
|
||||||
@@ -120,7 +155,7 @@ function onEnter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() { query.value = ''; hits.value = []; highlight.value = 0 }
|
function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,75 +1,133 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card class="fc-post-card" variant="outlined">
|
<v-card
|
||||||
|
:class="['fc-post-card', expanded && 'fc-post-card--expanded']"
|
||||||
|
variant="outlined"
|
||||||
|
:tabindex="expanded ? -1 : 0"
|
||||||
|
@click="onCardClick"
|
||||||
|
@keydown.enter="onCardClick"
|
||||||
|
>
|
||||||
<div class="fc-post-card__head">
|
<div class="fc-post-card__head">
|
||||||
<v-chip size="x-small" variant="tonal">{{ post.source.platform }}</v-chip>
|
<v-chip size="x-small" variant="tonal">{{ post.source.platform }}</v-chip>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
|
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
|
||||||
class="fc-post-card__artist"
|
class="fc-post-card__artist"
|
||||||
|
@click.stop
|
||||||
>{{ post.artist.name }}</RouterLink>
|
>{{ post.artist.name }}</RouterLink>
|
||||||
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
|
<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-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="post.post_url"
|
v-if="post.post_url"
|
||||||
:href="post.post_url" target="_blank" rel="noopener"
|
:href="post.post_url" target="_blank" rel="noopener"
|
||||||
icon="mdi-open-in-new" size="x-small" variant="text"
|
icon="mdi-open-in-new" size="x-small" variant="text"
|
||||||
:aria-label="`open original post on ${post.source.platform}`"
|
: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>
|
||||||
|
|
||||||
<h3 v-if="post.post_title" class="fc-post-card__title">{{ post.post_title }}</h3>
|
<!-- 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="images.length">
|
||||||
|
<div class="fc-post-card__hero">
|
||||||
|
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div v-if="rail.length" class="fc-post-card__rail">
|
||||||
|
<div v-for="t in rail" :key="t.image_id" class="fc-post-card__rail-cell">
|
||||||
|
<img :src="t.thumbnail_url" :alt="`thumbnail`" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div v-if="moreCount > 0" class="fc-post-card__rail-more">
|
||||||
|
+{{ moreCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<PostEmptyThumbs v-else />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="descriptionToShow" class="fc-post-card__desc">
|
<div class="fc-post-card__text">
|
||||||
<span class="fc-post-card__desc-text">{{ descriptionToShow }}</span>
|
<h3 v-if="post.post_title" class="fc-post-card__title">
|
||||||
<button
|
{{ post.post_title }}
|
||||||
v-if="post.description_truncated && !expanded"
|
</h3>
|
||||||
class="fc-post-card__more" type="button"
|
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
||||||
@click="expand"
|
Post {{ post.external_post_id }}
|
||||||
>Show more</button>
|
</h3>
|
||||||
<button
|
|
||||||
v-if="expanded"
|
<p v-if="post.description_plain" class="fc-post-card__desc">
|
||||||
class="fc-post-card__more" type="button"
|
{{ post.description_plain }}
|
||||||
@click="expanded = false"
|
</p>
|
||||||
>Show less</button>
|
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
||||||
|
(no description)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="post.attachments?.length" class="fc-post-card__atts">
|
||||||
|
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
||||||
|
{{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="post.thumbnails.length" class="fc-post-card__thumbs">
|
<!-- Expanded body: title, full mosaic, full sanitized HTML description,
|
||||||
<RouterLink
|
attachments. Lazy-loaded detail via getPostFull. -->
|
||||||
v-for="t in post.thumbnails"
|
<div v-else class="fc-post-card__expanded">
|
||||||
:key="t.image_id"
|
<h2 v-if="post.post_title" class="fc-post-card__title-full">
|
||||||
:to="{ path: '/gallery', query: { post_id: post.id } }"
|
{{ post.post_title }}
|
||||||
class="fc-post-card__thumb"
|
</h2>
|
||||||
>
|
<h2 v-else class="fc-post-card__title-full fc-post-card__title--missing">
|
||||||
<v-img
|
Post {{ post.external_post_id }}
|
||||||
:src="t.thumbnail_url" cover :alt="`thumbnail`"
|
</h2>
|
||||||
width="96" height="96" class="fc-post-card__thumb-img"
|
|
||||||
/>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-if="post.thumbnails_more > 0"
|
|
||||||
:to="{ path: '/gallery', query: { post_id: post.id } }"
|
|
||||||
class="fc-post-card__more-thumbs"
|
|
||||||
>+{{ post.thumbnails_more }} more</RouterLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="post.attachments.length" class="fc-post-card__attachments">
|
<section v-if="images.length" class="fc-post-card__sec">
|
||||||
<a
|
<PostImageGrid :thumbnails="images" />
|
||||||
v-for="att in post.attachments"
|
<div v-if="!detailLoaded" class="fc-post-card__loading-hint">
|
||||||
:key="att.id"
|
Loading full image list…
|
||||||
:href="att.download_url"
|
</div>
|
||||||
download
|
</section>
|
||||||
class="fc-post-card__att"
|
|
||||||
>
|
<section v-if="descriptionHtml" class="fc-post-card__sec">
|
||||||
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
<div class="fc-post-card__desc-full" v-html="descriptionHtml" />
|
||||||
<span class="fc-post-card__att-name">{{ att.original_filename }}</span>
|
</section>
|
||||||
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
|
<section v-else-if="detailLoaded" class="fc-post-card__sec">
|
||||||
</a>
|
<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>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
import { usePostsStore } from '../../stores/posts.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({
|
const props = defineProps({
|
||||||
post: { type: Object, required: true },
|
post: { type: Object, required: true },
|
||||||
@@ -77,8 +135,29 @@ const props = defineProps({
|
|||||||
|
|
||||||
const postsStore = usePostsStore()
|
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 expanded = ref(false)
|
||||||
const fullDescription = ref(null)
|
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
|
||||||
|
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
|
||||||
|
return more + extraShown
|
||||||
|
})
|
||||||
|
|
||||||
const sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
|
const sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
|
||||||
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
|
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
|
||||||
@@ -92,20 +171,52 @@ const relativeDate = computed(() => {
|
|||||||
return new Date(sortDateIso.value).toLocaleDateString()
|
return new Date(sortDateIso.value).toLocaleDateString()
|
||||||
})
|
})
|
||||||
|
|
||||||
const descriptionToShow = computed(() => {
|
const descriptionHtml = computed(() => {
|
||||||
if (expanded.value && fullDescription.value) return fullDescription.value
|
// Detail endpoint returns description_full as plain text (the service
|
||||||
return props.post.description_plain
|
// 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
return esc
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
|
||||||
|
.join('')
|
||||||
})
|
})
|
||||||
|
|
||||||
async function expand() {
|
async function loadDetailIfNeeded () {
|
||||||
if (!fullDescription.value) {
|
if (detailLoaded.value || detail.value) return
|
||||||
const detail = await postsStore.getPostFull(props.post.id)
|
try {
|
||||||
fullDescription.value = detail?.description_full || props.post.description_plain
|
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.
|
||||||
}
|
}
|
||||||
expanded.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(n) {
|
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) return '0 B'
|
||||||
if (n < 1024) return `${n} B`
|
if (n < 1024) return `${n} B`
|
||||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||||
@@ -118,13 +229,31 @@ function formatBytes(n) {
|
|||||||
.fc-post-card {
|
.fc-post-card {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
container-type: inline-size;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
.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 {
|
.fc-post-card__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.fc-post-card__artist {
|
.fc-post-card__artist {
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
@@ -132,69 +261,154 @@ function formatBytes(n) {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); }
|
.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__title {
|
.fc-post-card__meta { white-space: nowrap; }
|
||||||
font-size: 1.05rem;
|
|
||||||
margin: 0.6rem 0 0.4rem;
|
/* ---- COMPACT BODY ---- */
|
||||||
}
|
.fc-post-card__body {
|
||||||
.fc-post-card__desc {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
color: rgb(var(--v-theme-on-surface));
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
}
|
|
||||||
.fc-post-card__more {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: rgb(var(--v-theme-accent));
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
font-size: inherit;
|
|
||||||
}
|
|
||||||
.fc-post-card__thumbs {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 16px;
|
||||||
align-items: center;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
}
|
||||||
.fc-post-card__thumb { line-height: 0; }
|
@container (min-width: 800px) {
|
||||||
.fc-post-card__thumb-img {
|
.fc-post-card__body {
|
||||||
border-radius: 4px;
|
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__hero {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.fc-post-card__more-thumbs {
|
.fc-post-card__hero img {
|
||||||
display: inline-flex;
|
width: 100%; height: 100%;
|
||||||
align-items: center;
|
object-fit: cover; display: block;
|
||||||
justify-content: center;
|
}
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
.fc-post-card__rail {
|
||||||
|
display: flex; gap: 6px; margin-top: 6px;
|
||||||
|
}
|
||||||
|
.fc-post-card__rail-cell {
|
||||||
|
width: 80px; height: 80px;
|
||||||
|
overflow: hidden; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.fc-post-card__rail-cell img {
|
||||||
|
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;
|
||||||
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
|
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.fc-post-card__more-thumbs:hover {
|
|
||||||
color: rgb(var(--v-theme-accent));
|
.fc-post-card__title {
|
||||||
border-color: rgb(var(--v-theme-accent));
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 18px; font-weight: 500;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.fc-post-card__attachments {
|
.fc-post-card__title--missing {
|
||||||
|
font-style: italic;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
@container (min-width: 800px) {
|
||||||
|
.fc-post-card__title {
|
||||||
|
font-size: 20px;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-post-card__desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fc-post-card__desc--missing {
|
||||||
|
font-style: italic;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
@container (min-width: 800px) {
|
||||||
|
.fc-post-card__desc { -webkit-line-clamp: 5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-post-card__atts {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 20px;
|
||||||
margin-top: 0.6rem;
|
}
|
||||||
|
.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 {
|
.fc-post-card__att {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 6px;
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 6px 12px;
|
||||||
border: 1px solid rgb(var(--v-theme-on-surface-variant));
|
border: 1px solid rgb(var(--v-theme-on-surface-variant));
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.82rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.fc-post-card__att:hover {
|
.fc-post-card__att:hover {
|
||||||
color: rgb(var(--v-theme-accent));
|
color: rgb(var(--v-theme-accent));
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-post-empty">
|
||||||
|
<v-icon size="48" class="fc-post-empty__icon">mdi-image-off-outline</v-icon>
|
||||||
|
<div class="fc-post-empty__text">No images attached to this post</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// No props — pure presentational placeholder.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-post-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 32px 16px;
|
||||||
|
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(20, 23, 26, 0.3);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.fc-post-empty__icon {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.fc-post-empty__text {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-post-grid">
|
||||||
|
<button
|
||||||
|
v-for="(t, idx) in thumbnails"
|
||||||
|
:key="t.image_id"
|
||||||
|
type="button"
|
||||||
|
class="fc-post-grid__cell"
|
||||||
|
:aria-label="`Open image ${idx + 1} of ${thumbnails.length}`"
|
||||||
|
@click="openImage(t.image_id, idx)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="t.thumbnail_url"
|
||||||
|
:alt="`thumbnail ${idx + 1}`"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useModalStore } from '../../stores/modal.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
thumbnails: { type: Array, required: true }, // [{ image_id, thumbnail_url, ... }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const modal = useModalStore()
|
||||||
|
|
||||||
|
const imageIds = computed(() => props.thumbnails.map(t => t.image_id))
|
||||||
|
|
||||||
|
function openImage (id, idx) {
|
||||||
|
modal.open(id, { postImageIds: imageIds.value, initialIndex: idx })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-post-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.fc-post-grid__cell {
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: rgb(var(--v-theme-background));
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.fc-post-grid__cell:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 0 0 2px rgb(var(--v-theme-accent));
|
||||||
|
}
|
||||||
|
.fc-post-grid__cell img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -34,11 +34,18 @@
|
|||||||
|
|
||||||
<template v-else-if="manifest?.installed">
|
<template v-else-if="manifest?.installed">
|
||||||
<div class="fc-ext-install mt-3">
|
<div class="fc-ext-install mt-3">
|
||||||
|
<!-- Install button: direct :href anchor click (no programmatic
|
||||||
|
window.location.assign). Firefox's XPI-install gesture
|
||||||
|
requires a user-clicked anchor pointing at an
|
||||||
|
application/x-xpinstall response; programmatic navigation
|
||||||
|
sometimes triggered nothing instead of the install dialog
|
||||||
|
(operator-flagged 2026-05-26). No `download` attribute —
|
||||||
|
that would force a save dialog instead of install. -->
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isFirefox"
|
v-if="isFirefox"
|
||||||
color="accent" variant="flat" rounded="pill"
|
color="accent" variant="flat" rounded="pill"
|
||||||
prepend-icon="mdi-firefox"
|
prepend-icon="mdi-firefox"
|
||||||
@click="installXpi"
|
:href="manifest.latest_url"
|
||||||
>Install Firefox extension</v-btn>
|
>Install Firefox extension</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -103,6 +110,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useApi } from '../../composables/useApi.js'
|
import { useApi } from '../../composables/useApi.js'
|
||||||
|
import { copyText } from '../../utils/clipboard.js'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -146,11 +154,6 @@ async function loadKey() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function installXpi() {
|
|
||||||
if (!manifest.value?.latest_url) return
|
|
||||||
window.location.assign(manifest.value.latest_url)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rotateKey() {
|
async function rotateKey() {
|
||||||
rotating.value = true
|
rotating.value = true
|
||||||
try {
|
try {
|
||||||
@@ -170,7 +173,7 @@ async function rotateKey() {
|
|||||||
|
|
||||||
async function copy(text, label) {
|
async function copy(text, label) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await copyText(text)
|
||||||
window.__fcToast?.({ text: `${label} copied.`, type: 'success' })
|
window.__fcToast?.({ text: `${label} copied.`, type: 'success' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
window.__fcToast?.({ text: `Copy failed: ${e.message}`, type: 'error' })
|
window.__fcToast?.({ text: `Copy failed: ${e.message}`, type: 'error' })
|
||||||
|
|||||||
@@ -51,68 +51,6 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</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>
|
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
|
||||||
{{ store.settingsError }}
|
{{ store.settingsError }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
@@ -128,16 +66,14 @@ import { reactive, watch } from 'vue'
|
|||||||
import { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
|
||||||
const store = useImportStore()
|
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({
|
const local = reactive({
|
||||||
min_width: 0, min_height: 0,
|
min_width: 0, min_height: 0,
|
||||||
skip_transparent: false, transparency_threshold: 0.9,
|
skip_transparent: false, transparency_threshold: 0.9,
|
||||||
skip_single_color: false, single_color_threshold: 0.95,
|
skip_single_color: false, single_color_threshold: 0.95,
|
||||||
phash_threshold: 10,
|
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 })
|
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||||
|
|||||||
@@ -45,9 +45,11 @@
|
|||||||
<template #item.size_bytes="{ item }">{{ formatBytes(item.size_bytes) }}</template>
|
<template #item.size_bytes="{ item }">{{ formatBytes(item.size_bytes) }}</template>
|
||||||
<template #item.created_at="{ item }">{{ formatDate(item.created_at) }}</template>
|
<template #item.created_at="{ item }">{{ formatDate(item.created_at) }}</template>
|
||||||
<template #item.error="{ item }">
|
<template #item.error="{ item }">
|
||||||
<span v-if="item.error" :title="item.error" class="text-caption">
|
<button
|
||||||
{{ shorten(item.error, 60) }}
|
v-if="item.error" type="button" class="fc-err-link text-caption"
|
||||||
</span>
|
@click="openError(`Task ${item.id} failed`, item.error)"
|
||||||
|
title="Click for full error"
|
||||||
|
>{{ shorten(item.error, 60) }}</button>
|
||||||
</template>
|
</template>
|
||||||
</v-data-table-virtual>
|
</v-data-table-virtual>
|
||||||
<div v-if="store.hasMore" class="d-flex justify-center py-3">
|
<div v-if="store.hasMore" class="d-flex justify-center py-3">
|
||||||
@@ -100,16 +102,35 @@
|
|||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<ErrorDetailModal
|
||||||
|
v-model="showErrorModal"
|
||||||
|
:title="errorModalTitle"
|
||||||
|
:message="errorModalMessage"
|
||||||
|
/>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
|
||||||
|
|
||||||
const store = useImportStore()
|
const store = useImportStore()
|
||||||
const statusFilter = ref(null)
|
const statusFilter = ref(null)
|
||||||
const clearDialog = ref(false)
|
const clearDialog = ref(false)
|
||||||
|
// Click-to-open modal for full error text (operator-flagged 2026-05-26
|
||||||
|
// — the prior :title="..." tooltip cramped multi-line SQLAlchemy
|
||||||
|
// tracebacks into an unusable popup with no copy-paste affordance).
|
||||||
|
const showErrorModal = ref(false)
|
||||||
|
const errorModalTitle = ref('')
|
||||||
|
const errorModalMessage = ref('')
|
||||||
|
|
||||||
|
function openError(title, message) {
|
||||||
|
errorModalTitle.value = title
|
||||||
|
errorModalMessage.value = message || ''
|
||||||
|
showErrorModal.value = true
|
||||||
|
}
|
||||||
const clearAgeDays = ref(7)
|
const clearAgeDays = ref(7)
|
||||||
const clearStuckDialog = ref(false)
|
const clearStuckDialog = ref(false)
|
||||||
|
|
||||||
@@ -179,3 +200,21 @@ async function onClearStuckConfirm() {
|
|||||||
clearStuckDialog.value = false
|
clearStuckDialog.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-err-link {
|
||||||
|
/* Truncated error preview as a clickable button — opens
|
||||||
|
ErrorDetailModal with the full text. Inherits the row's font
|
||||||
|
sizing so it doesn't visually drift from the prior tooltip-bearing
|
||||||
|
span. */
|
||||||
|
color: rgb(var(--v-theme-error, 220 80 80));
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fc-err-link:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-maint">
|
<div class="fc-maint">
|
||||||
<p class="text-body-2 mb-4">
|
<p class="text-body-2 mb-4">
|
||||||
Machine-assisted tagging controls. Backfill and centroid recompute run
|
Operational backfills and tagging controls. The ML backfill and centroid
|
||||||
nightly automatically; the allowlist auto-applies accepted tags to new
|
recompute run nightly automatically; the allowlist auto-applies accepted
|
||||||
and existing images.
|
tags to new and existing images. Use the cards below to trigger a
|
||||||
|
one-off pass.
|
||||||
</p>
|
</p>
|
||||||
<div class="fc-maint__grid">
|
<div class="fc-maint__grid">
|
||||||
<MLBackfillCard />
|
<MLBackfillCard />
|
||||||
<CentroidRecomputeCard />
|
<CentroidRecomputeCard />
|
||||||
|
<ThumbnailBackfillCard />
|
||||||
</div>
|
</div>
|
||||||
<MLThresholdSliders class="mt-4" />
|
<MLThresholdSliders class="mt-4" />
|
||||||
<AllowlistTable class="mt-4" />
|
<AllowlistTable class="mt-4" />
|
||||||
<AliasTable class="mt-4" />
|
<AliasTable class="mt-4" />
|
||||||
<BackupCard class="mt-6" />
|
<BackupCard class="mt-6" />
|
||||||
<TagMaintenanceCard class="mt-6" />
|
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) — it
|
||||||
|
operates on the existing library which fits the Cleanup-tab
|
||||||
|
theme, and clusters with the other audit cards. -->
|
||||||
<LegacyMigrationCard class="mt-6" />
|
<LegacyMigrationCard class="mt-6" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -21,11 +25,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import MLBackfillCard from './MLBackfillCard.vue'
|
import MLBackfillCard from './MLBackfillCard.vue'
|
||||||
import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
|
import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
|
||||||
|
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
||||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||||
import AllowlistTable from './AllowlistTable.vue'
|
import AllowlistTable from './AllowlistTable.vue'
|
||||||
import AliasTable from './AliasTable.vue'
|
import AliasTable from './AliasTable.vue'
|
||||||
import BackupCard from './BackupCard.vue'
|
import BackupCard from './BackupCard.vue'
|
||||||
import TagMaintenanceCard from './TagMaintenanceCard.vue'
|
|
||||||
import LegacyMigrationCard from './LegacyMigrationCard.vue'
|
import LegacyMigrationCard from './LegacyMigrationCard.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,12 @@
|
|||||||
<td>{{ r.queue }}</td>
|
<td>{{ r.queue }}</td>
|
||||||
<td><code>{{ shortTaskName(r.task_name) }}</code></td>
|
<td><code>{{ shortTaskName(r.task_name) }}</code></td>
|
||||||
<td class="fc-tabular">{{ r.target_id ?? '—' }}</td>
|
<td class="fc-tabular">{{ r.target_id ?? '—' }}</td>
|
||||||
<td class="fc-err" :title="r.error_message">
|
<td>
|
||||||
{{ r.error_type }}
|
<button
|
||||||
|
type="button" class="fc-err-link"
|
||||||
|
@click="openError(r)"
|
||||||
|
:title="'Click for full error'"
|
||||||
|
>{{ r.error_type }}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!filteredFailures.length">
|
<tr v-if="!filteredFailures.length">
|
||||||
@@ -138,6 +142,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<ErrorDetailModal
|
||||||
|
v-model="showErrorModal"
|
||||||
|
:row="errorModalRow"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -145,8 +154,21 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||||
|
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
|
||||||
import QueuesTable from './QueuesTable.vue'
|
import QueuesTable from './QueuesTable.vue'
|
||||||
|
|
||||||
|
// Click-to-open modal for full error text. Replaces the unusable
|
||||||
|
// :title="..." tooltip (operator-flagged 2026-05-26: SQLAlchemy
|
||||||
|
// rollback + traceback content rendered as a cramped browser tooltip
|
||||||
|
// you couldn't copy from or scroll within).
|
||||||
|
const showErrorModal = ref(false)
|
||||||
|
const errorModalRow = ref(null)
|
||||||
|
|
||||||
|
function openError(row) {
|
||||||
|
errorModalRow.value = row
|
||||||
|
showErrorModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const store = useSystemActivityStore()
|
const store = useSystemActivityStore()
|
||||||
|
|
||||||
const filterQueue = ref(null)
|
const filterQueue = ref(null)
|
||||||
@@ -276,5 +298,18 @@ function formatRelative(iso) {
|
|||||||
font-feature-settings: 'tnum';
|
font-feature-settings: 'tnum';
|
||||||
}
|
}
|
||||||
.fc-err { color: rgb(var(--v-theme-error, 220 80 80)); }
|
.fc-err { color: rgb(var(--v-theme-error, 220 80 80)); }
|
||||||
|
.fc-err-link {
|
||||||
|
/* Styled as a text-only button so the error_type cell stays
|
||||||
|
visually identical to the prior tooltip-bearing row, but is
|
||||||
|
now a real clickable target with hover affordance. */
|
||||||
|
color: rgb(var(--v-theme-error, 220 80 80));
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fc-err-link:hover { text-decoration: underline; }
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Thumbnail backfill</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="text-body-2 mb-3">
|
||||||
|
Scan the library for images with no thumbnail, or whose thumbnail file
|
||||||
|
is missing or corrupt on disk. Repair candidates are re-enqueued for
|
||||||
|
thumbnail generation. Safe to re-run.
|
||||||
|
</p>
|
||||||
|
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
||||||
|
<v-icon start>mdi-image-refresh</v-icon> Run backfill now
|
||||||
|
</v-btn>
|
||||||
|
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useThumbnailsStore } from '../../stores/thumbnails.js'
|
||||||
|
const store = useThumbnailsStore()
|
||||||
|
const busy = ref(false)
|
||||||
|
const done = ref(false)
|
||||||
|
async function run () {
|
||||||
|
busy.value = true
|
||||||
|
try { await store.triggerBackfill(); done.value = true }
|
||||||
|
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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>
|
||||||
@@ -7,8 +7,6 @@ import ArtistView from './views/ArtistView.vue'
|
|||||||
import SeriesManageView from './views/SeriesManageView.vue'
|
import SeriesManageView from './views/SeriesManageView.vue'
|
||||||
import SeriesReaderView from './views/SeriesReaderView.vue'
|
import SeriesReaderView from './views/SeriesReaderView.vue'
|
||||||
import SubscriptionsView from './views/SubscriptionsView.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 PostsView from './views/PostsView.vue'
|
||||||
import ArtistsView from './views/ArtistsView.vue'
|
import ArtistsView from './views/ArtistsView.vue'
|
||||||
|
|
||||||
@@ -34,10 +32,16 @@ const routes = [
|
|||||||
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
|
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
|
||||||
|
|
||||||
// FC-3: subscription backbone
|
// 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: '/posts', name: 'posts', component: PostsView, meta: { title: 'Posts' } },
|
||||||
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } },
|
{ 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
|
// Browser uses HTML5 history; non-browser (Vitest/SSR) falls back to memory
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
import { usePostsStore } from './posts.js'
|
||||||
|
|
||||||
const PAGE = 60
|
const PAGE = 60
|
||||||
|
|
||||||
@@ -15,7 +16,11 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
const notFound = ref(false)
|
const notFound = ref(false)
|
||||||
let started = false
|
let started = false
|
||||||
|
|
||||||
async function load(slug) {
|
async function load (slug) {
|
||||||
|
// Cross-artist reset: clear this store AND the posts store so the new
|
||||||
|
// artist doesn't briefly render with the previous artist's content
|
||||||
|
// when the user is on the Posts tab. (Gallery tab uses this artist
|
||||||
|
// store's own images list — cleared above.)
|
||||||
overview.value = null
|
overview.value = null
|
||||||
images.value = []
|
images.value = []
|
||||||
nextCursor.value = null
|
nextCursor.value = null
|
||||||
@@ -23,6 +28,7 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
started = false
|
started = false
|
||||||
error.value = null
|
error.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
usePostsStore().$reset?.()
|
||||||
try {
|
try {
|
||||||
overview.value = await api.get(`/api/artist/${encodeURIComponent(slug)}`)
|
overview.value = await api.get(`/api/artist/${encodeURIComponent(slug)}`)
|
||||||
await loadMoreImages(slug)
|
await loadMoreImages(slug)
|
||||||
@@ -34,7 +40,7 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMoreImages(slug) {
|
async function loadMoreImages (slug) {
|
||||||
if (imagesLoading.value) return
|
if (imagesLoading.value) return
|
||||||
if (started && nextCursor.value === null) return
|
if (started && nextCursor.value === null) return
|
||||||
imagesLoading.value = true
|
imagesLoading.value = true
|
||||||
@@ -55,9 +61,13 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasMoreImages = computed(() => !started || nextCursor.value !== null)
|
const hasMoreImages = computed(() => !started || nextCursor.value !== null)
|
||||||
|
const postCount = computed(() => overview.value?.post_count ?? null)
|
||||||
|
const imageCount = computed(() => overview.value?.image_count ?? null)
|
||||||
|
const lastAdded = computed(() => overview.value?.date_range?.max ?? null)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
overview, images, loading, imagesLoading, error, notFound,
|
overview, images, loading, imagesLoading, error, notFound,
|
||||||
hasMoreImages, load, loadMoreImages
|
hasMoreImages, postCount, imageCount, lastAdded,
|
||||||
|
load, loadMoreImages,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
|
||||||
|
export const useCleanupStore = defineStore('cleanup', () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
// Defaults sourced from ImportSettings on mount. Cards pre-fill from
|
||||||
|
// these so the common case ("apply current import filters
|
||||||
|
// retroactively") is one click; operator can override per-audit.
|
||||||
|
const defaults = ref({
|
||||||
|
min_width: 0,
|
||||||
|
min_height: 0,
|
||||||
|
transparency_threshold: 0.9,
|
||||||
|
single_color_threshold: 0.95,
|
||||||
|
single_color_tolerance: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
const recentRuns = ref([])
|
||||||
|
|
||||||
|
async function loadDefaults() {
|
||||||
|
const s = await api.get('/api/settings/import')
|
||||||
|
defaults.value = {
|
||||||
|
min_width: s.min_width ?? 0,
|
||||||
|
min_height: s.min_height ?? 0,
|
||||||
|
transparency_threshold: s.transparency_threshold ?? 0.9,
|
||||||
|
single_color_threshold: s.single_color_threshold ?? 0.95,
|
||||||
|
single_color_tolerance: s.single_color_tolerance ?? 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewMinDim(min_width, min_height) {
|
||||||
|
return await api.post('/api/cleanup/min-dimension/preview', {
|
||||||
|
body: { min_width, min_height },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMinDim(min_width, min_height, confirm) {
|
||||||
|
return await api.post('/api/cleanup/min-dimension/delete', {
|
||||||
|
body: { min_width, min_height, confirm },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAudit(rule, params) {
|
||||||
|
return await api.post('/api/cleanup/audit', { body: { rule, params } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAudit(id) {
|
||||||
|
return await api.get(`/api/cleanup/audit/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory(limit = 20) {
|
||||||
|
const body = await api.get(`/api/cleanup/audit?limit=${limit}`)
|
||||||
|
recentRuns.value = body.runs
|
||||||
|
return body.runs
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAudit(id, confirm) {
|
||||||
|
return await api.post(`/api/cleanup/audit/${id}/apply`, { body: { confirm } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelAudit(id) {
|
||||||
|
return await api.post(`/api/cleanup/audit/${id}/cancel`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaults, recentRuns,
|
||||||
|
loadDefaults,
|
||||||
|
previewMinDim, deleteMinDim,
|
||||||
|
startAudit, getAudit, loadHistory, applyAudit, cancelAudit,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -8,10 +8,14 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
const events = ref([])
|
const events = ref([])
|
||||||
const cursor = ref(null)
|
const cursor = ref(null)
|
||||||
const hasMore = ref(true)
|
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 selected = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 })
|
||||||
|
|
||||||
function _params(extra = {}) {
|
function _params(extra = {}) {
|
||||||
const out = { limit: 50, ...extra }
|
const out = { limit: 50, ...extra }
|
||||||
@@ -65,8 +69,13 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
selected.value = null
|
selected.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadStats(windowHours = 24) {
|
||||||
|
stats.value = await api.get('/api/downloads/stats', { params: { window_hours: windowHours } })
|
||||||
|
return stats.value
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
events, cursor, hasMore, filter, selected, loading, error,
|
events, cursor, hasMore, filter, selected, loading, error, stats,
|
||||||
loadFirst, loadMore, loadOne, applyFilter, closeDetail,
|
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,14 +6,31 @@ export const useModalStore = defineStore('modal', () => {
|
|||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const currentImageId = ref(null)
|
const currentImageId = ref(null)
|
||||||
const current = ref(null) // full image detail from API
|
const current = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
|
||||||
async function open(id) {
|
// Post-scoped cycle. When set, prev/next cycles within this array
|
||||||
|
// (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)
|
||||||
|
|
||||||
|
async function open (id, opts = {}) {
|
||||||
currentImageId.value = id
|
currentImageId.value = id
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
// Update post-scoped state if caller passed it; otherwise clear so
|
||||||
|
// the next open() from gallery context uses neighbors mode.
|
||||||
|
if (opts.postImageIds != null) {
|
||||||
|
postImageIds.value = opts.postImageIds
|
||||||
|
postImageIndex.value = opts.postImageIds.indexOf(id)
|
||||||
|
if (postImageIndex.value < 0) postImageIndex.value = 0
|
||||||
|
} else if (opts.clearPostScope !== false && postImageIds.value != null) {
|
||||||
|
postImageIds.value = null
|
||||||
|
postImageIndex.value = 0
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
current.value = await api.get(`/api/gallery/image/${id}`)
|
current.value = await api.get(`/api/gallery/image/${id}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -24,41 +41,58 @@ export const useModalStore = defineStore('modal', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function close() {
|
async function close () {
|
||||||
currentImageId.value = null
|
currentImageId.value = null
|
||||||
current.value = null
|
current.value = null
|
||||||
error.value = null
|
error.value = null
|
||||||
|
postImageIds.value = null
|
||||||
|
postImageIndex.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goPrev() {
|
async function goPrev () {
|
||||||
if (current.value && current.value.neighbors.prev_id) {
|
if (postImageIds.value != null) {
|
||||||
|
if (postImageIndex.value > 0) {
|
||||||
|
const newIdx = postImageIndex.value - 1
|
||||||
|
const newId = postImageIds.value[newIdx]
|
||||||
|
postImageIndex.value = newIdx
|
||||||
|
await open(newId, { postImageIds: postImageIds.value })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (current.value && current.value.neighbors?.prev_id) {
|
||||||
await open(current.value.neighbors.prev_id)
|
await open(current.value.neighbors.prev_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function goNext() {
|
|
||||||
if (current.value && current.value.neighbors.next_id) {
|
async function goNext () {
|
||||||
|
if (postImageIds.value != null) {
|
||||||
|
if (postImageIndex.value < postImageIds.value.length - 1) {
|
||||||
|
const newIdx = postImageIndex.value + 1
|
||||||
|
const newId = postImageIds.value[newIdx]
|
||||||
|
postImageIndex.value = newIdx
|
||||||
|
await open(newId, { postImageIds: postImageIds.value })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (current.value && current.value.neighbors?.next_id) {
|
||||||
await open(current.value.neighbors.next_id)
|
await open(current.value.neighbors.next_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reloadTags() {
|
async function reloadTags () {
|
||||||
if (!currentImageId.value) return
|
if (!currentImageId.value) return
|
||||||
const tags = await api.get(`/api/images/${currentImageId.value}/tags`)
|
const tags = await api.get(`/api/images/${currentImageId.value}/tags`)
|
||||||
if (current.value) current.value.tags = tags
|
if (current.value) current.value.tags = tags
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeTag(tagId) {
|
async function removeTag (tagId) {
|
||||||
if (!currentImageId.value) return
|
if (!currentImageId.value) return
|
||||||
// Optimistic UI: remove locally first, restore on error.
|
|
||||||
const prev = current.value.tags
|
const prev = current.value.tags
|
||||||
current.value.tags = current.value.tags.filter(t => t.id !== tagId)
|
current.value.tags = current.value.tags.filter(t => t.id !== tagId)
|
||||||
try {
|
try {
|
||||||
// FC-2b: removal also records a per-image rejection (suggestions/dismiss
|
|
||||||
// is the rejection-recording endpoint) so the allowlist maintenance
|
|
||||||
// task won't re-apply this tag to this image.
|
|
||||||
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`)
|
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`)
|
||||||
await api.post(`/api/images/${currentImageId.value}/suggestions/dismiss`, {
|
await api.post(`/api/images/${currentImageId.value}/suggestions/dismiss`, {
|
||||||
body: { tag_id: tagId }
|
body: { tag_id: tagId },
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
current.value.tags = prev
|
current.value.tags = prev
|
||||||
@@ -67,25 +101,36 @@ export const useModalStore = defineStore('modal', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addExistingTag(tagId) {
|
async function addExistingTag (tagId) {
|
||||||
if (!currentImageId.value) return
|
if (!currentImageId.value) return
|
||||||
await api.post(`/api/images/${currentImageId.value}/tags`, {
|
await api.post(`/api/images/${currentImageId.value}/tags`, {
|
||||||
body: { tag_id: tagId, source: 'manual' }
|
body: { tag_id: tagId, source: 'manual' },
|
||||||
})
|
})
|
||||||
await reloadTags()
|
await reloadTags()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAndAdd({ name, kind, fandom_id = null }) {
|
async function createAndAdd ({ name, kind, fandom_id = null }) {
|
||||||
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
|
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
|
||||||
await addExistingTag(tag.id)
|
await addExistingTag(tag.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOpen = computed(() => currentImageId.value !== null)
|
const isOpen = computed(() => currentImageId.value !== null)
|
||||||
const canPrev = computed(() => current.value?.neighbors?.prev_id != null)
|
const canPrev = computed(() => {
|
||||||
const canNext = computed(() => current.value?.neighbors?.next_id != null)
|
if (postImageIds.value != null) return postImageIndex.value > 0
|
||||||
|
return current.value?.neighbors?.prev_id != null
|
||||||
|
})
|
||||||
|
const canNext = computed(() => {
|
||||||
|
if (postImageIds.value != null) {
|
||||||
|
return postImageIndex.value < (postImageIds.value.length - 1)
|
||||||
|
}
|
||||||
|
return current.value?.neighbors?.next_id != null
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentImageId, current, loading, error, isOpen, canPrev, canNext,
|
currentImageId, current, loading, error,
|
||||||
open, close, goPrev, goNext, reloadTags, removeTag, addExistingTag, createAndAdd
|
postImageIds, postImageIndex,
|
||||||
|
isOpen, canPrev, canNext,
|
||||||
|
open, close, goPrev, goNext,
|
||||||
|
reloadTags, removeTag, addExistingTag, createAndAdd,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,24 +2,22 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
|
||||||
|
// `artist` retired in FC-2d-vii-c (provenance is its own axis), `meta` +
|
||||||
|
// `rating` retired by operator 2026-05-26 (alembic 0023 drops them from
|
||||||
|
// the enum). KIND_COLOR keeps `archive` + `post` so any legacy
|
||||||
|
// system-managed tag still renders with a neutral color.
|
||||||
const KIND_OPTIONS = [
|
const KIND_OPTIONS = [
|
||||||
{ value: 'general', label: 'General', icon: 'mdi-tag' },
|
{ value: 'general', label: 'General', icon: 'mdi-tag' },
|
||||||
{ value: 'artist', label: 'Artist', icon: 'mdi-palette' },
|
|
||||||
{ value: 'character', label: 'Character', icon: 'mdi-account-circle' },
|
{ value: 'character', label: 'Character', icon: 'mdi-account-circle' },
|
||||||
{ value: 'fandom', label: 'Fandom', icon: 'mdi-book-open-page-variant' },
|
{ value: 'fandom', label: 'Fandom', icon: 'mdi-book-open-page-variant' },
|
||||||
{ value: 'series', label: 'Series', icon: 'mdi-bookshelf' },
|
{ value: 'series', label: 'Series', icon: 'mdi-bookshelf' }
|
||||||
{ value: 'meta', label: 'Meta', icon: 'mdi-cog-outline' },
|
|
||||||
{ value: 'rating', label: 'Rating', icon: 'mdi-shield-check-outline' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const KIND_COLOR = {
|
const KIND_COLOR = {
|
||||||
artist: 'accent',
|
|
||||||
character: 'info',
|
character: 'info',
|
||||||
fandom: 'secondary',
|
fandom: 'secondary',
|
||||||
series: 'warning',
|
series: 'warning',
|
||||||
general: 'on-surface',
|
general: 'on-surface',
|
||||||
meta: 'on-surface',
|
|
||||||
rating: 'on-surface',
|
|
||||||
archive: 'on-surface',
|
archive: 'on-surface',
|
||||||
post: 'on-surface'
|
post: 'on-surface'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
|
||||||
|
export const useThumbnailsStore = defineStore('thumbnails', () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function triggerBackfill () {
|
||||||
|
await api.post('/api/thumbnails/backfill')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { triggerBackfill }
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Clipboard write that works on plain-HTTP self-hosted deployments.
|
||||||
|
//
|
||||||
|
// navigator.clipboard is gated by the browser's Secure Context restriction
|
||||||
|
// (HTTPS or localhost only). FabledCurator runs over plain HTTP per the
|
||||||
|
// homelab posture, so the modern API is undefined in production. We fall
|
||||||
|
// back to the legacy execCommand('copy') path via a temporary off-screen
|
||||||
|
// textarea — wide browser support, no HTTPS requirement.
|
||||||
|
|
||||||
|
export async function copyText (text) {
|
||||||
|
const str = text == null ? '' : String(text)
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(str)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// Fall through to the legacy path. Some browsers throw even when
|
||||||
|
// the API exists (permission denied, focus loss, etc.).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = str
|
||||||
|
ta.setAttribute('readonly', '')
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.top = '0'
|
||||||
|
ta.style.left = '0'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
ta.style.pointerEvents = 'none'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
ta.setSelectionRange(0, str.length)
|
||||||
|
let ok = false
|
||||||
|
try { ok = document.execCommand('copy') } catch { ok = false }
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
if (!ok) throw new Error('clipboard copy not supported')
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Whitelist-based HTML sanitizer for rendering third-party post
|
||||||
|
// descriptions (e.g. Patreon) via v-html. Strips script/style/iframe
|
||||||
|
// + event handlers + dangerous hrefs. Tag whitelist covers what
|
||||||
|
// Patreon, SubscribeStar, and similar platforms ship in normal posts.
|
||||||
|
//
|
||||||
|
// Not a substitute for server-side sanitization in higher-stakes
|
||||||
|
// contexts. This is for FC's single-operator homelab posture where
|
||||||
|
// the content source is the operator's own subscriptions.
|
||||||
|
|
||||||
|
const ALLOWED_TAGS = new Set([
|
||||||
|
'a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li',
|
||||||
|
'ol', 'p', 'pre', 's', 'span', 'strong', 'sub', 'sup', 'u', 'ul',
|
||||||
|
])
|
||||||
|
|
||||||
|
const ALLOWED_ATTRS = {
|
||||||
|
a: new Set(['href', 'title', 'rel', 'target']),
|
||||||
|
img: new Set(['src', 'alt', 'title', 'width', 'height']),
|
||||||
|
// any tag → these always allowed
|
||||||
|
'*': new Set(['class']),
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_URL_RE = /^(https?:|mailto:|#|\/)/i
|
||||||
|
|
||||||
|
export function sanitizeHtml (html) {
|
||||||
|
if (typeof html !== 'string' || !html) return ''
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
_scrubNode(doc.body)
|
||||||
|
return doc.body.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scrubNode (node) {
|
||||||
|
const children = Array.from(node.children)
|
||||||
|
for (const child of children) {
|
||||||
|
const tag = child.tagName.toLowerCase()
|
||||||
|
if (!ALLOWED_TAGS.has(tag)) {
|
||||||
|
// Strip the tag but keep its text content as a fallback.
|
||||||
|
const text = document.createTextNode(child.textContent || '')
|
||||||
|
child.replaceWith(text)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const allowed = new Set([
|
||||||
|
...(ALLOWED_ATTRS[tag] || []),
|
||||||
|
...(ALLOWED_ATTRS['*'] || []),
|
||||||
|
])
|
||||||
|
for (const attr of Array.from(child.attributes)) {
|
||||||
|
const name = attr.name.toLowerCase()
|
||||||
|
if (name.startsWith('on') || !allowed.has(name)) {
|
||||||
|
child.removeAttribute(attr.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ((name === 'href' || name === 'src') && !SAFE_URL_RE.test(attr.value)) {
|
||||||
|
child.removeAttribute(attr.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tag === 'a' && child.getAttribute('target') === '_blank') {
|
||||||
|
child.setAttribute('rel', 'noopener noreferrer')
|
||||||
|
}
|
||||||
|
_scrubNode(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,220 +1,96 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<div v-if="store.loading && !store.overview" class="fc-artist__loading">
|
||||||
<div v-if="store.loading && !store.overview" class="fc-artist__loading">
|
<v-progress-circular indeterminate color="accent" size="36" />
|
||||||
<v-progress-circular indeterminate color="accent" size="36" />
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-alert v-else-if="store.notFound" type="warning" variant="tonal">
|
<v-container v-else-if="store.notFound" class="pt-2 pb-6">
|
||||||
Artist not found.
|
<v-alert type="warning" variant="tonal">Artist not found.</v-alert>
|
||||||
</v-alert>
|
</v-container>
|
||||||
|
|
||||||
<v-alert v-else-if="store.error" type="error" variant="tonal" closable>
|
<v-container v-else-if="store.error && !store.overview" class="pt-2 pb-6">
|
||||||
{{ store.error }}
|
<v-alert type="error" variant="tonal" closable>{{ store.error }}</v-alert>
|
||||||
</v-alert>
|
</v-container>
|
||||||
|
|
||||||
<template v-else-if="store.overview">
|
|
||||||
<header class="fc-artist__head">
|
|
||||||
<h1 class="fc-h1">{{ store.overview.name }}</h1>
|
|
||||||
<div class="fc-artist__stats">
|
|
||||||
<span>{{ store.overview.image_count }} images</span>
|
|
||||||
<span v-if="dateRange">· {{ dateRange }}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="fc-artist__fc4">
|
|
||||||
<v-chip
|
|
||||||
size="small"
|
|
||||||
:variant="store.overview.is_subscription ? 'flat' : 'outlined'"
|
|
||||||
:color="store.overview.is_subscription ? 'accent' : undefined"
|
|
||||||
prepend-icon="mdi-rss"
|
|
||||||
>{{ store.overview.is_subscription ? 'Subscription' : 'One-off' }}</v-chip>
|
|
||||||
|
|
||||||
<v-chip
|
|
||||||
size="small" variant="outlined" prepend-icon="mdi-link-variant"
|
|
||||||
:to="`/subscriptions?artist_id=${store.overview.id}`"
|
|
||||||
>{{ store.overview.sources.length }} source{{ store.overview.sources.length === 1 ? '' : 's' }}</v-chip>
|
|
||||||
|
|
||||||
<v-chip
|
|
||||||
size="small" variant="outlined" prepend-icon="mdi-rss"
|
|
||||||
:to="`/posts?artist_id=${store.overview.id}`"
|
|
||||||
>View posts</v-chip>
|
|
||||||
|
|
||||||
<v-chip
|
|
||||||
size="small" variant="outlined" disabled
|
|
||||||
prepend-icon="mdi-clock-outline"
|
|
||||||
>Credential health · FC-3b</v-chip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabs split (2026-05-25): Settings was previously slotted at the
|
|
||||||
bottom of the page after the infinite-scroll image grid, which
|
|
||||||
made it effectively unreachable for any artist with more than
|
|
||||||
a couple of pages of content. The Settings tab now hosts
|
|
||||||
destructive admin actions (artist+content cascade-delete) and
|
|
||||||
any future per-artist management UI. v-tabs is `position:
|
|
||||||
sticky; top: 64px` (under the 64px AppShell TopNav) so it
|
|
||||||
stays parked while the gallery scrolls. -->
|
|
||||||
<v-tabs
|
|
||||||
v-model="tab" color="accent" class="mb-4"
|
|
||||||
style="position: sticky; top: 64px; z-index: 4;
|
|
||||||
background: rgb(var(--v-theme-surface));"
|
|
||||||
>
|
|
||||||
<v-tab value="overview">Overview</v-tab>
|
|
||||||
<v-tab value="settings">Settings</v-tab>
|
|
||||||
</v-tabs>
|
|
||||||
|
|
||||||
|
<template v-else-if="store.overview">
|
||||||
|
<ArtistHeader
|
||||||
|
v-model="tab"
|
||||||
|
:name="store.overview.name"
|
||||||
|
:image-count="store.imageCount"
|
||||||
|
:post-count="store.postCount"
|
||||||
|
:last-added="store.lastAdded"
|
||||||
|
/>
|
||||||
|
<v-container fluid class="pt-2 pb-4">
|
||||||
<v-window v-model="tab">
|
<v-window v-model="tab">
|
||||||
<v-window-item value="overview">
|
<v-window-item value="posts">
|
||||||
<section v-if="store.overview.cooccurring_tags.length" class="fc-artist__sec">
|
<ArtistPostsTab
|
||||||
<h2 class="fc-h2">Frequent tags</h2>
|
|
||||||
<div class="fc-artist__tags">
|
|
||||||
<v-chip
|
|
||||||
v-for="t in store.overview.cooccurring_tags" :key="t.id"
|
|
||||||
size="small" @click="openTag(t.id)"
|
|
||||||
>{{ t.name }} <span class="fc-artist__tagc">{{ t.count }}</span></v-chip>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="store.overview.activity.length" class="fc-artist__sec">
|
|
||||||
<h2 class="fc-h2">Activity</h2>
|
|
||||||
<svg class="fc-artist__spark" :viewBox="`0 0 ${sparkW} ${sparkH}`"
|
|
||||||
preserveAspectRatio="none" role="img" aria-label="posts over time">
|
|
||||||
<polyline :points="sparkPoints" fill="none"
|
|
||||||
stroke="rgb(var(--v-theme-accent))" stroke-width="2" />
|
|
||||||
</svg>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="store.overview.sources.length" class="fc-artist__sec">
|
|
||||||
<div class="fc-artist__sec-head">
|
|
||||||
<h2 class="fc-h2">Sources</h2>
|
|
||||||
<RouterLink
|
|
||||||
:to="`/subscriptions?artist_id=${store.overview.id}`"
|
|
||||||
class="fc-artist__manage"
|
|
||||||
>Manage subscriptions →</RouterLink>
|
|
||||||
</div>
|
|
||||||
<v-table density="compact">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Platform</th><th>URL</th><th class="text-right">Images</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="s in store.overview.sources" :key="s.id">
|
|
||||||
<td>{{ s.platform }}</td>
|
|
||||||
<td class="fc-artist__url">{{ s.url }}</td>
|
|
||||||
<td class="text-right">{{ s.image_count }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</v-table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="fc-artist__sec">
|
|
||||||
<h2 class="fc-h2">Images</h2>
|
|
||||||
<MasonryGrid
|
|
||||||
:items="store.images"
|
|
||||||
:loading="store.imagesLoading"
|
|
||||||
:has-more="store.hasMoreImages"
|
|
||||||
@load-more="store.loadMoreImages(slug)"
|
|
||||||
@open="openImage"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</v-window-item>
|
|
||||||
|
|
||||||
<v-window-item value="settings">
|
|
||||||
<ArtistDangerZone
|
|
||||||
:slug="slug"
|
|
||||||
:artist-id="store.overview.id"
|
:artist-id="store.overview.id"
|
||||||
:artist-name="store.overview.name"
|
@switch-tab="(t) => tab = t"
|
||||||
/>
|
/>
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
<v-window-item value="gallery">
|
||||||
|
<ArtistGalleryTab :slug="slug" />
|
||||||
|
</v-window-item>
|
||||||
|
<v-window-item value="management">
|
||||||
|
<ArtistManagementTab :overview="store.overview" />
|
||||||
|
</v-window-item>
|
||||||
</v-window>
|
</v-window>
|
||||||
</template>
|
</v-container>
|
||||||
</v-container>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useArtistStore } from '../stores/artist.js'
|
import { useArtistStore } from '../stores/artist.js'
|
||||||
import { useModalStore } from '../stores/modal.js'
|
import ArtistHeader from '../components/artist/ArtistHeader.vue'
|
||||||
import MasonryGrid from '../components/discovery/MasonryGrid.vue'
|
import ArtistPostsTab from '../components/artist/ArtistPostsTab.vue'
|
||||||
import ArtistDangerZone from '../components/artist/ArtistDangerZone.vue'
|
import ArtistGalleryTab from '../components/artist/ArtistGalleryTab.vue'
|
||||||
|
import ArtistManagementTab from '../components/artist/ArtistManagementTab.vue'
|
||||||
|
|
||||||
|
const VALID_TABS = ['posts', 'gallery', 'management']
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useArtistStore()
|
const store = useArtistStore()
|
||||||
const modal = useModalStore()
|
|
||||||
|
|
||||||
const slug = computed(() => route.params.slug)
|
const slug = computed(() => route.params.slug)
|
||||||
// Per-artist tab — defaults to Overview. Settings tab hosts destructive
|
const tab = ref('posts')
|
||||||
// admin actions (DangerZone). Switching artists resets to Overview so the
|
|
||||||
// destructive surface isn't re-shown by accident when navigating between
|
|
||||||
// artists.
|
|
||||||
const tab = ref('overview')
|
|
||||||
|
|
||||||
watch(slug, (s) => {
|
function resolveDefaultTab () {
|
||||||
if (s) {
|
const fromUrl = route.query.tab
|
||||||
store.load(s)
|
if (VALID_TABS.includes(fromUrl)) return fromUrl
|
||||||
tab.value = 'overview'
|
if ((store.postCount ?? 0) > 0) return 'posts'
|
||||||
}
|
return 'gallery'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(slug, async (s) => {
|
||||||
|
if (!s) return
|
||||||
|
await store.load(s)
|
||||||
|
document.title = store.overview
|
||||||
|
? `${store.overview.name} — FabledCurator`
|
||||||
|
: 'FabledCurator'
|
||||||
|
tab.value = resolveDefaultTab()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
const dateRange = computed(() => {
|
// Reflect tab changes back into the URL so refresh/back/forward work.
|
||||||
const r = store.overview?.date_range
|
watch(tab, (newTab) => {
|
||||||
if (!r || !r.min) return null
|
if (route.query.tab === newTab) return
|
||||||
const fmt = (iso) => iso.slice(0, 10)
|
router.replace({
|
||||||
return r.min === r.max ? fmt(r.min) : `${fmt(r.min)} → ${fmt(r.max)}`
|
query: { ...route.query, tab: newTab },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const sparkW = 600
|
// React to URL-tab changes (e.g., back/forward).
|
||||||
const sparkH = 80
|
watch(() => route.query.tab, (q) => {
|
||||||
const sparkPoints = computed(() => {
|
if (q && VALID_TABS.includes(q) && tab.value !== q) {
|
||||||
const a = store.overview?.activity ?? []
|
tab.value = q
|
||||||
if (a.length === 0) return ''
|
}
|
||||||
const max = Math.max(...a.map(p => p.count), 1)
|
|
||||||
const stepX = a.length > 1 ? sparkW / (a.length - 1) : 0
|
|
||||||
return a.map((p, i) => {
|
|
||||||
const x = i * stepX
|
|
||||||
const y = sparkH - (p.count / max) * (sparkH - 4) - 2
|
|
||||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
|
||||||
}).join(' ')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function openImage(id) {
|
|
||||||
modal.open(id)
|
|
||||||
}
|
|
||||||
function openTag(tagId) {
|
|
||||||
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-h1 {
|
.fc-artist__loading {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
display: flex; justify-content: center; padding: 64px 0;
|
||||||
font-size: 32px; font-weight: 500;
|
|
||||||
color: rgb(var(--v-theme-on-surface));
|
|
||||||
}
|
}
|
||||||
.fc-h2 {
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-size: 20px; font-weight: 500; margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.fc-artist__loading { display: flex; justify-content: center; padding: 64px 0; }
|
|
||||||
.fc-artist__head { margin-bottom: 12px; }
|
|
||||||
.fc-artist__stats { opacity: 0.75; margin-top: 4px; }
|
|
||||||
.fc-artist__fc4 { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }
|
|
||||||
.fc-artist__sec { margin-bottom: 28px; }
|
|
||||||
.fc-artist__tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
||||||
.fc-artist__tagc { opacity: 0.6; margin-left: 4px; font-variant-numeric: tabular-nums; }
|
|
||||||
.fc-artist__spark { width: 100%; height: 80px; }
|
|
||||||
.fc-artist__url {
|
|
||||||
max-width: 380px; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.fc-artist__sec-head {
|
|
||||||
display: flex; align-items: baseline; justify-content: space-between;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
.fc-artist__manage {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgb(var(--v-theme-accent));
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.fc-artist__manage:hover { text-decoration: underline; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
|
|
||||||
<div class="fc-artists__controls">
|
<div class="fc-artists__controls">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-cleanup">
|
||||||
|
<p class="fc-muted text-body-2 mb-4">
|
||||||
|
Retroactive enforcement of import-filter rules. Each card scans the
|
||||||
|
existing library for content that the current import filters would
|
||||||
|
now exclude. Destructive — typed-token confirmation required.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<MinDimensionCard class="mb-4" />
|
||||||
|
<TransparencyAuditCard class="mb-4" />
|
||||||
|
<SingleColorAuditCard class="mb-4" />
|
||||||
|
<TagMaintenanceCard />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import MinDimensionCard from '../components/cleanup/MinDimensionCard.vue'
|
||||||
|
import TransparencyAuditCard from '../components/cleanup/TransparencyAuditCard.vue'
|
||||||
|
import SingleColorAuditCard from '../components/cleanup/SingleColorAuditCard.vue'
|
||||||
|
// Reuse existing TagMaintenanceCard (FC-3k) as-is — it already handles
|
||||||
|
// preview + commit of prune-unused-tags via the admin store. Operator
|
||||||
|
// confirmed 2026-05-26: don't duplicate into a new UnusedTagsCard.
|
||||||
|
// MaintenancePanel drops its TagMaintenanceCard reference in the
|
||||||
|
// SettingsView edit (Task 16) so this is now the sole rendering site.
|
||||||
|
import TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-cleanup { max-width: 900px; }
|
||||||
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
</style>
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-container fluid class="py-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>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-container fluid class="py-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>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
<Teleport to="#fc-nav-actions">
|
<Teleport to="#fc-nav-actions">
|
||||||
<v-btn
|
<v-btn
|
||||||
:color="sel.isSelectMode ? 'accent' : undefined"
|
:color="sel.isSelectMode ? 'accent' : undefined"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container class="py-8">
|
<v-container class="pt-3 pb-8">
|
||||||
<h1 class="fc-h1 mb-4">{{ title }}</h1>
|
<h1 class="fc-h1 mb-4">{{ title }}</h1>
|
||||||
<v-alert type="info" variant="tonal" icon="mdi-toolbox">
|
<v-alert type="info" variant="tonal" icon="mdi-toolbox">
|
||||||
This surface is a placeholder. It will be implemented in
|
This surface is a placeholder. It will be implemented in
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container class="py-6" max-width="900">
|
<v-container class="pt-2 pb-6" max-width="900">
|
||||||
<PostsFilterBar
|
<PostsFilterBar
|
||||||
:artist-id="artistFilter"
|
:artist-id="artistFilter"
|
||||||
:platform="platformFilter"
|
:platform="platformFilter"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
<div class="fc-series__head">
|
<div class="fc-series__head">
|
||||||
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
|
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
|
||||||
<span class="fc-series__count">{{ store.pages.length }} page(s)</span>
|
<span class="fc-series__count">{{ store.pages.length }} page(s)</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
||||||
panels pushed the tab strip out of the viewport, forcing a scroll-
|
panels pushed the tab strip out of the viewport, forcing a scroll-
|
||||||
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
|
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<v-tab value="overview">Overview</v-tab>
|
<v-tab value="overview">Overview</v-tab>
|
||||||
<v-tab value="activity">Activity</v-tab>
|
<v-tab value="activity">Activity</v-tab>
|
||||||
<v-tab value="import">Import</v-tab>
|
<v-tab value="import">Import</v-tab>
|
||||||
|
<v-tab value="cleanup">Cleanup</v-tab>
|
||||||
<v-tab value="maintenance">Maintenance</v-tab>
|
<v-tab value="maintenance">Maintenance</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
|
|
||||||
@@ -53,6 +54,10 @@
|
|||||||
<ImportFiltersForm />
|
<ImportFiltersForm />
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
|
||||||
|
<v-window-item value="cleanup">
|
||||||
|
<CleanupView />
|
||||||
|
</v-window-item>
|
||||||
|
|
||||||
<v-window-item value="maintenance">
|
<v-window-item value="maintenance">
|
||||||
<MaintenancePanel />
|
<MaintenancePanel />
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
@@ -72,6 +77,7 @@ import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue'
|
|||||||
import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue'
|
import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue'
|
||||||
import ImportTaskList from '../components/settings/ImportTaskList.vue'
|
import ImportTaskList from '../components/settings/ImportTaskList.vue'
|
||||||
import MaintenancePanel from '../components/settings/MaintenancePanel.vue'
|
import MaintenancePanel from '../components/settings/MaintenancePanel.vue'
|
||||||
|
import CleanupView from './CleanupView.vue'
|
||||||
import { useMLStore } from '../stores/ml.js'
|
import { useMLStore } from '../stores/ml.js'
|
||||||
|
|
||||||
const tab = ref('overview')
|
const tab = ref('overview')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
<Teleport to="#fc-nav-actions">
|
<Teleport to="#fc-nav-actions">
|
||||||
<v-btn
|
<v-btn
|
||||||
prepend-icon="mdi-shuffle-variant" variant="tonal" color="accent"
|
prepend-icon="mdi-shuffle-variant" variant="tonal" color="accent"
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
:items="store.images"
|
:items="store.images"
|
||||||
:loading="store.loading"
|
:loading="store.loading"
|
||||||
:has-more="store.hasMore"
|
:has-more="store.hasMore"
|
||||||
|
:animate-from-index="animateFromIndex"
|
||||||
@load-more="store.fetchPage()"
|
@load-more="store.fetchPage()"
|
||||||
@open="openImage"
|
@open="openImage"
|
||||||
/>
|
/>
|
||||||
@@ -25,17 +26,52 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useShowcaseStore } from '../stores/showcase.js'
|
|
||||||
import { useModalStore } from '../stores/modal.js'
|
|
||||||
import MasonryGrid from '../components/discovery/MasonryGrid.vue'
|
import MasonryGrid from '../components/discovery/MasonryGrid.vue'
|
||||||
|
import { useModalStore } from '../stores/modal.js'
|
||||||
|
import { useShowcaseStore } from '../stores/showcase.js'
|
||||||
|
|
||||||
const store = useShowcaseStore()
|
const store = useShowcaseStore()
|
||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
|
|
||||||
onMounted(() => { if (store.images.length === 0) store.fetchPage() })
|
// Track when items were appended vs replaced so MasonryGrid only animates
|
||||||
|
// items new to the current batch (mirrors IR's behavior: animate on
|
||||||
|
// initial load and on shuffle, but skip silent infinite-scroll appends).
|
||||||
|
const animateFromIndex = ref(0)
|
||||||
|
let prevCount = 0
|
||||||
|
watch(() => store.images.length, (newCount) => {
|
||||||
|
if (newCount < prevCount || prevCount === 0) {
|
||||||
|
// Reset (shuffle) or initial load — animate everything from 0.
|
||||||
|
animateFromIndex.value = 0
|
||||||
|
} else {
|
||||||
|
// Append — only animate the newly-added tail.
|
||||||
|
animateFromIndex.value = prevCount
|
||||||
|
}
|
||||||
|
prevCount = newCount
|
||||||
|
})
|
||||||
|
|
||||||
function openImage(id) {
|
function openImage(id) {
|
||||||
modal.open(id)
|
modal.open(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IR-parity keyboard shuffle: press R anywhere on the page (not inside a
|
||||||
|
// text input, not while a Vuetify overlay is open) to reshuffle.
|
||||||
|
function onKeydown(e) {
|
||||||
|
const t = e.target
|
||||||
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
|
||||||
|
// Vuetify marks the active overlay (v-dialog, v-menu) on the body when
|
||||||
|
// open. Skip shuffle when a modal is in the way.
|
||||||
|
if (document.querySelector('.v-overlay--active')) return
|
||||||
|
if (e.key === 'r' || e.key === 'R') {
|
||||||
|
e.preventDefault()
|
||||||
|
store.shuffle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (store.images.length === 0) store.fetchPage()
|
||||||
|
window.addEventListener('keydown', onKeydown)
|
||||||
|
})
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user