Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5526b8dc78 | |||
| c9a3f12847 | |||
| 538c1591e8 | |||
| 16eb7075c4 | |||
| 4437488899 | |||
| 96039fc983 | |||
| 9f008af6c8 | |||
| be0f472894 | |||
| dd4874ae8d | |||
| 4ae9815d61 | |||
| 4da8538054 | |||
| d85c108cec | |||
| cd838ec904 | |||
| 2e1aaffd93 | |||
| 2065672a31 | |||
| 46d199450d | |||
| 379445c244 | |||
| b067a3eec1 | |||
| b7832c941d | |||
| df82abe75e | |||
| c1d3046778 | |||
| 9b01b19666 | |||
| a06c4f009f | |||
| 885dcf64f3 | |||
| 05b398c352 | |||
| f2f6b6d25e | |||
| 7a5a71471e | |||
| 38a45baad5 | |||
| c9ddcd0f60 | |||
| 95bc761a69 | |||
| 6acf273267 |
@@ -0,0 +1,59 @@
|
|||||||
|
name: extension
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
paths: ['extension/**']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install web-ext
|
||||||
|
run: cd extension && npm install --no-save --no-audit --no-fund
|
||||||
|
- name: Lint
|
||||||
|
run: cd extension && npm run lint
|
||||||
|
|
||||||
|
sign-and-publish:
|
||||||
|
needs: lint
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
- name: Install web-ext + git
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||||
|
cd extension && npm install --no-save --no-audit --no-fund
|
||||||
|
- name: Sign XPI
|
||||||
|
run: cd extension && npm run sign
|
||||||
|
env:
|
||||||
|
WEB_EXT_API_KEY: ${{ secrets.MOZILLA_AMO_JWT_KEY }}
|
||||||
|
WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }}
|
||||||
|
- name: Commit signed XPI to frontend/public/extension/
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
XPI=$(ls extension/web-ext-artifacts/fabledcurator-*.xpi | head -1)
|
||||||
|
if [ -z "$XPI" ]; then
|
||||||
|
echo "No XPI produced by web-ext sign — exiting"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p frontend/public/extension
|
||||||
|
cp "$XPI" frontend/public/extension/
|
||||||
|
# Also copy as -latest.xpi so the FC server can serve a stable URL.
|
||||||
|
cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi"
|
||||||
|
git config user.name "FC extension CI"
|
||||||
|
git config user.email "noreply@fabledsword.com"
|
||||||
|
git add frontend/public/extension/
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No changes to commit"
|
||||||
|
else
|
||||||
|
git commit -m "ext: publish signed XPI $(basename $XPI)"
|
||||||
|
git push origin HEAD:main
|
||||||
|
fi
|
||||||
@@ -21,6 +21,7 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
from .attachments import attachments_bp
|
from .attachments import attachments_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 .gallery import gallery_bp
|
from .gallery import gallery_bp
|
||||||
from .import_admin import import_admin_bp
|
from .import_admin import import_admin_bp
|
||||||
from .migrate import migrate_bp
|
from .migrate import migrate_bp
|
||||||
@@ -53,5 +54,6 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
platforms_bp,
|
platforms_bp,
|
||||||
posts_bp,
|
posts_bp,
|
||||||
credentials_bp,
|
credentials_bp,
|
||||||
|
extension_bp,
|
||||||
downloads_bp,
|
downloads_bp,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""FC-3g: /api/extension — quick-add-source for the Firefox extension
|
||||||
|
+ install-time manifest for the Settings card.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from quart import Blueprint, jsonify, request
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..extensions import get_session
|
||||||
|
from ..models import AppSetting
|
||||||
|
from ..services.extension_service import (
|
||||||
|
ExtensionService,
|
||||||
|
InvalidUrlError,
|
||||||
|
UnknownPlatformError,
|
||||||
|
)
|
||||||
|
from ..services.source_service import KNOWN_PLATFORMS
|
||||||
|
|
||||||
|
extension_bp = Blueprint("extension", __name__, url_prefix="/api/extension")
|
||||||
|
|
||||||
|
# Default XPI directory; tests override via monkeypatching this module-
|
||||||
|
# level constant.
|
||||||
|
XPI_DIR = Path("/app/frontend/dist/extension")
|
||||||
|
|
||||||
|
_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$")
|
||||||
|
|
||||||
|
|
||||||
|
def _bad(error: str, *, status: int = 400, **extra):
|
||||||
|
body = {"error": error}
|
||||||
|
body.update(extra)
|
||||||
|
return jsonify(body), status
|
||||||
|
|
||||||
|
|
||||||
|
async def _ext_key_required(session) -> bool:
|
||||||
|
"""Unlike /api/credentials (which accepts the browser path with no
|
||||||
|
header), quick-add-source writes server state and must be explicitly
|
||||||
|
authenticated."""
|
||||||
|
supplied = request.headers.get("X-Extension-Key")
|
||||||
|
if supplied is None:
|
||||||
|
return False
|
||||||
|
stored = (await session.execute(
|
||||||
|
select(AppSetting.value).where(AppSetting.key == "extension_api_key")
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
return stored is not None and supplied == stored
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_version(xpi_name: str) -> str:
|
||||||
|
m = _XPI_VERSION_RE.search(xpi_name)
|
||||||
|
return m.group("version") if m else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as fp:
|
||||||
|
for chunk in iter(lambda: fp.read(65536), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@extension_bp.route("/quick-add-source", methods=["POST"])
|
||||||
|
async def quick_add_source():
|
||||||
|
body = await request.get_json(silent=True)
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return _bad("invalid_body", detail="body must be a JSON object")
|
||||||
|
url = body.get("url")
|
||||||
|
if not isinstance(url, str) or not url.strip():
|
||||||
|
return _bad("invalid_body", detail="url is required")
|
||||||
|
|
||||||
|
async with get_session() as session:
|
||||||
|
if not await _ext_key_required(session):
|
||||||
|
return _bad("unauthorized", status=401)
|
||||||
|
try:
|
||||||
|
result = await ExtensionService(session).quick_add_source(url)
|
||||||
|
except UnknownPlatformError as exc:
|
||||||
|
return _bad(
|
||||||
|
"unknown_platform",
|
||||||
|
detail=str(exc),
|
||||||
|
known=sorted(KNOWN_PLATFORMS),
|
||||||
|
)
|
||||||
|
except InvalidUrlError as exc:
|
||||||
|
return _bad("invalid_url", detail=str(exc))
|
||||||
|
return jsonify(result), (201 if result["created_source"] else 200)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_manifest_sync() -> dict | None:
|
||||||
|
"""All the filesystem-touching work for /api/extension/manifest,
|
||||||
|
in a sync helper so the async route can dispatch it via
|
||||||
|
asyncio.to_thread (ASYNC240: no pathlib I/O in async functions)."""
|
||||||
|
if not XPI_DIR.is_dir():
|
||||||
|
return None
|
||||||
|
xpis = sorted(XPI_DIR.glob("fabledcurator-*.xpi"), key=lambda p: p.stat().st_mtime)
|
||||||
|
if not xpis:
|
||||||
|
return None
|
||||||
|
latest = xpis[-1]
|
||||||
|
return {
|
||||||
|
"installed": True,
|
||||||
|
"version": _extract_version(latest.name),
|
||||||
|
"xpi_url": f"/extension/{latest.name}",
|
||||||
|
"latest_url": "/extension/fabledcurator-latest.xpi",
|
||||||
|
"sha256": _sha256(latest),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@extension_bp.route("/manifest", methods=["GET"])
|
||||||
|
async def extension_manifest():
|
||||||
|
info = await asyncio.to_thread(_read_manifest_sync)
|
||||||
|
if info is None:
|
||||||
|
return jsonify({"installed": False}), 404
|
||||||
|
return jsonify(info)
|
||||||
@@ -141,11 +141,23 @@ async def system_stats():
|
|||||||
).all()
|
).all()
|
||||||
integrity_counts = {row[0]: row[1] for row in integrity_rows}
|
integrity_counts = {row[0]: row[1] for row in integrity_rows}
|
||||||
|
|
||||||
# Active batch (most recent running)
|
# Active batch = running batch that still has outstanding work.
|
||||||
|
# Plain "most recent running" picks a freshly-created scan that
|
||||||
|
# enqueued zero new files and hides the older batch that's
|
||||||
|
# actually being processed; the EXISTS clause filters those
|
||||||
|
# empty batches out.
|
||||||
active_batch_row = (
|
active_batch_row = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(ImportBatch)
|
select(ImportBatch)
|
||||||
.where(ImportBatch.status == "running")
|
.where(
|
||||||
|
ImportBatch.status == "running",
|
||||||
|
select(ImportTask.id)
|
||||||
|
.where(
|
||||||
|
ImportTask.batch_id == ImportBatch.id,
|
||||||
|
ImportTask.status.in_(["pending", "queued", "processing"]),
|
||||||
|
)
|
||||||
|
.exists(),
|
||||||
|
)
|
||||||
.order_by(ImportBatch.started_at.desc())
|
.order_by(ImportBatch.started_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
|
|||||||
+44
-2
@@ -1,13 +1,18 @@
|
|||||||
"""Serves the built Vue SPA from frontend/dist/ with history-mode fallback,
|
"""Serves the built Vue SPA from frontend/dist/ with history-mode fallback,
|
||||||
and the on-disk image library + thumbnails from /images.
|
the on-disk image library + thumbnails from /images, and the signed
|
||||||
|
Firefox extension XPI from frontend/dist/extension/.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, abort, send_from_directory
|
from quart import Blueprint, abort, send_file, send_from_directory
|
||||||
|
|
||||||
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
||||||
IMAGES_ROOT = Path("/images")
|
IMAGES_ROOT = Path("/images")
|
||||||
|
XPI_DIR = FRONTEND_DIST / "extension"
|
||||||
|
|
||||||
|
_XPI_NAME_RE = re.compile(r"^fabledcurator-[\w.-]+\.xpi$")
|
||||||
|
|
||||||
frontend_bp = Blueprint("frontend", __name__)
|
frontend_bp = Blueprint("frontend", __name__)
|
||||||
|
|
||||||
@@ -31,6 +36,43 @@ async def serve_image(subpath: str):
|
|||||||
return await send_from_directory(IMAGES_ROOT, subpath)
|
return await send_from_directory(IMAGES_ROOT, subpath)
|
||||||
|
|
||||||
|
|
||||||
|
@frontend_bp.route("/extension/<filename>")
|
||||||
|
async def serve_extension(filename: str):
|
||||||
|
"""Serve the signed FC Firefox extension XPI.
|
||||||
|
|
||||||
|
Path whitelist: filename must match fabledcurator-*.xpi. The special
|
||||||
|
name fabledcurator-latest.xpi serves the most-recently-modified XPI
|
||||||
|
in the directory.
|
||||||
|
|
||||||
|
The application/x-xpinstall MIME tells Firefox to show its native
|
||||||
|
install prompt instead of downloading the file as a blob.
|
||||||
|
"""
|
||||||
|
if not _XPI_NAME_RE.fullmatch(filename):
|
||||||
|
abort(404)
|
||||||
|
if not XPI_DIR.is_dir():
|
||||||
|
abort(404)
|
||||||
|
if filename == "fabledcurator-latest.xpi":
|
||||||
|
xpis = sorted(XPI_DIR.glob("fabledcurator-*.xpi"), key=lambda p: p.stat().st_mtime)
|
||||||
|
if not xpis:
|
||||||
|
abort(404)
|
||||||
|
latest = xpis[-1]
|
||||||
|
return await send_file(
|
||||||
|
latest, mimetype="application/x-xpinstall",
|
||||||
|
attachment_filename=latest.name,
|
||||||
|
)
|
||||||
|
target = (XPI_DIR / filename).resolve()
|
||||||
|
try:
|
||||||
|
target.relative_to(XPI_DIR)
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
if not target.is_file():
|
||||||
|
abort(404)
|
||||||
|
return await send_file(
|
||||||
|
target, mimetype="application/x-xpinstall",
|
||||||
|
attachment_filename=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@frontend_bp.route("/")
|
@frontend_bp.route("/")
|
||||||
@frontend_bp.route("/<path:subpath>")
|
@frontend_bp.route("/<path:subpath>")
|
||||||
async def serve_spa(subpath: str = ""):
|
async def serve_spa(subpath: str = ""):
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""FC-3g: backend support for the Firefox extension.
|
||||||
|
|
||||||
|
`ExtensionService.quick_add_source(url)` derives platform + artist
|
||||||
|
slug from a URL using regex patterns mirrored from
|
||||||
|
extension/lib/platforms.js, then find-or-creates Artist + Source rows
|
||||||
|
and returns a JSON-shaped dict for the API layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import Artist, Source
|
||||||
|
from ..utils.slug import slugify
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownPlatformError(Exception):
|
||||||
|
"""URL didn't match any platform pattern."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidUrlError(Exception):
|
||||||
|
"""URL was empty or missing a scheme."""
|
||||||
|
|
||||||
|
|
||||||
|
# Mirrored byte-for-byte from extension/lib/platforms.js
|
||||||
|
# PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand —
|
||||||
|
# reviewers catch drift.
|
||||||
|
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||||
|
("patreon", re.compile(
|
||||||
|
r"^https?://(?:www\.)?patreon\.com/"
|
||||||
|
r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)"
|
||||||
|
r"(?P<slug>[^/?#]+)/?$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)),
|
||||||
|
("subscribestar", re.compile(
|
||||||
|
r"^https?://(?:www\.)?subscribestar\.(?:com|adult)/"
|
||||||
|
r"(?!feed$|messages$|library$)"
|
||||||
|
r"(?P<slug>[^/?#]+)/?$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)),
|
||||||
|
("hentaifoundry", re.compile(
|
||||||
|
r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)),
|
||||||
|
("deviantart", re.compile(
|
||||||
|
r"^https?://(?:www\.)?deviantart\.com/"
|
||||||
|
r"(?!home$|watch\b|tag\b|browse\b)"
|
||||||
|
r"(?P<slug>[^/?#]+)/?$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)),
|
||||||
|
("pixiv", re.compile(
|
||||||
|
r"^https?://(?:www\.)?pixiv\.net/(?:en/)?users/(?P<slug>\d+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionService:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def quick_add_source(self, url: str) -> dict:
|
||||||
|
platform, raw_slug = self._derive(url)
|
||||||
|
artist, created_artist = await self._find_or_create_artist(raw_slug)
|
||||||
|
source, created_source = await self._find_or_create_source(
|
||||||
|
artist_id=artist.id, platform=platform, url=url,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"source": {
|
||||||
|
"id": source.id,
|
||||||
|
"artist_id": source.artist_id,
|
||||||
|
"platform": source.platform,
|
||||||
|
"url": source.url,
|
||||||
|
"enabled": source.enabled,
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"id": artist.id,
|
||||||
|
"name": artist.name,
|
||||||
|
"slug": artist.slug,
|
||||||
|
},
|
||||||
|
"created_source": created_source,
|
||||||
|
"created_artist": created_artist,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _derive(self, url: str) -> tuple[str, str]:
|
||||||
|
if not isinstance(url, str) or not url.strip():
|
||||||
|
raise InvalidUrlError("url is empty")
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
raise InvalidUrlError(f"url must include http:// or https:// scheme: {url!r}")
|
||||||
|
for platform, pattern in _PLATFORM_PATTERNS:
|
||||||
|
m = pattern.match(url)
|
||||||
|
if m:
|
||||||
|
return platform, m.group("slug")
|
||||||
|
raise UnknownPlatformError(f"no platform pattern matched {url!r}")
|
||||||
|
|
||||||
|
async def _find_or_create_artist(self, raw_name: str) -> tuple[Artist, bool]:
|
||||||
|
slug = slugify(raw_name)
|
||||||
|
existing = (await self.session.execute(
|
||||||
|
select(Artist).where(Artist.slug == slug)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing, False
|
||||||
|
artist = Artist(name=raw_name, slug=slug, is_subscription=True)
|
||||||
|
self.session.add(artist)
|
||||||
|
await self.session.flush()
|
||||||
|
return artist, True
|
||||||
|
|
||||||
|
async def _find_or_create_source(
|
||||||
|
self, *, artist_id: int, platform: str, url: str,
|
||||||
|
) -> tuple[Source, bool]:
|
||||||
|
existing = (await 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, False
|
||||||
|
src = Source(artist_id=artist_id, platform=platform, url=url, enabled=True)
|
||||||
|
self.session.add(src)
|
||||||
|
await self.session.flush()
|
||||||
|
await self.session.commit()
|
||||||
|
return src, True
|
||||||
@@ -237,6 +237,15 @@ class GalleryService:
|
|||||||
)
|
)
|
||||||
tags = (await self.session.execute(tag_stmt)).scalars().all()
|
tags = (await self.session.execute(tag_stmt)).scalars().all()
|
||||||
neighbors = await self._neighbors(record)
|
neighbors = await self._neighbors(record)
|
||||||
|
# Direct artist FK — used by the modal's ProvenancePanel as a
|
||||||
|
# fallback when ImageProvenance is empty (i.e., filesystem-
|
||||||
|
# imported images without a post-track provenance row). The
|
||||||
|
# source of truth for richer post-level data is still
|
||||||
|
# ImageProvenance/Post; this is just the "we at least know who
|
||||||
|
# made it" line.
|
||||||
|
artist = None
|
||||||
|
if record.artist_id is not None:
|
||||||
|
artist = await self.session.get(Artist, record.artist_id)
|
||||||
return {
|
return {
|
||||||
"id": record.id,
|
"id": record.id,
|
||||||
"path": record.path,
|
"path": record.path,
|
||||||
@@ -249,6 +258,10 @@ class GalleryService:
|
|||||||
"created_at": record.created_at.isoformat(),
|
"created_at": record.created_at.isoformat(),
|
||||||
"thumbnail_url": thumbnail_url(record.sha256, record.mime),
|
"thumbnail_url": thumbnail_url(record.sha256, record.mime),
|
||||||
"image_url": f"/images/{record.path.split('/images/', 1)[-1]}",
|
"image_url": f"/images/{record.path.split('/images/', 1)[-1]}",
|
||||||
|
"artist": (
|
||||||
|
{"id": artist.id, "name": artist.name, "slug": artist.slug}
|
||||||
|
if artist is not None else None
|
||||||
|
),
|
||||||
"tags": [
|
"tags": [
|
||||||
{
|
{
|
||||||
"id": t.id,
|
"id": t.id,
|
||||||
|
|||||||
@@ -37,12 +37,21 @@ def _backups_dir(images_root: Path | None = None) -> Path:
|
|||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_SUBPROCESS_TIMEOUT_S = 30 * 60 # 30 minutes
|
||||||
|
|
||||||
|
|
||||||
def _run_subprocess(cmd: list[str], **kwargs: Any):
|
def _run_subprocess(cmd: list[str], **kwargs: Any):
|
||||||
# Overridable for tests via monkeypatch.
|
# Overridable for tests via monkeypatch. Hard wall-clock timeout
|
||||||
|
# guards against pg_dump / tar / zstd hangs on NFS — without it the
|
||||||
|
# task pretends to be 'running' forever (operator hit this 2026-05-
|
||||||
|
# 23 with two backups stuck in MigrationRun). On timeout
|
||||||
|
# subprocess.run raises TimeoutExpired which the caller surfaces as
|
||||||
|
# a task error.
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=True,
|
check=True,
|
||||||
|
timeout=_DEFAULT_SUBPROCESS_TIMEOUT_S,
|
||||||
**{k: v for k, v in kwargs.items() if not k.startswith("_")},
|
**{k: v for k, v in kwargs.items() if not k.startswith("_")},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async def migrate_async(
|
|||||||
"""
|
"""
|
||||||
if data.get("source_app") != "imagerepo":
|
if data.get("source_app") != "imagerepo":
|
||||||
raise ValueError("export source_app must be 'imagerepo'")
|
raise ValueError("export source_app must be 'imagerepo'")
|
||||||
if data.get("schema_version") != 1:
|
if data.get("schema_version") not in (1, 2):
|
||||||
raise ValueError(f"unsupported schema_version: {data.get('schema_version')}")
|
raise ValueError(f"unsupported schema_version: {data.get('schema_version')}")
|
||||||
|
|
||||||
counts = _zero_counts()
|
counts = _zero_counts()
|
||||||
@@ -126,15 +126,20 @@ async def migrate_async(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Phase 2: write the per-image manifest for tag_apply.py to consume later.
|
# Phase 2: write the per-image manifest for tag_apply.py to consume later.
|
||||||
|
# schema_version 2 (added 2026-05-24) carries `image_posts` for
|
||||||
|
# Post + Source + ImageProvenance restore; schema 1 manifests
|
||||||
|
# without it stay valid (tag_apply treats the missing field as []).
|
||||||
manifest = {
|
manifest = {
|
||||||
"schema_version": 1,
|
"schema_version": data.get("schema_version", 1),
|
||||||
"image_artist_assignments": data.get("image_artist_assignments", []),
|
"image_artist_assignments": data.get("image_artist_assignments", []),
|
||||||
"image_tag_associations": data.get("image_tag_associations", []),
|
"image_tag_associations": data.get("image_tag_associations", []),
|
||||||
"series_pages": data.get("series_pages", []),
|
"series_pages": data.get("series_pages", []),
|
||||||
|
"image_posts": data.get("image_posts", []),
|
||||||
}
|
}
|
||||||
counts["rows_processed"] += len(manifest["image_artist_assignments"])
|
counts["rows_processed"] += len(manifest["image_artist_assignments"])
|
||||||
counts["rows_processed"] += len(manifest["image_tag_associations"])
|
counts["rows_processed"] += len(manifest["image_tag_associations"])
|
||||||
counts["rows_processed"] += len(manifest["series_pages"])
|
counts["rows_processed"] += len(manifest["series_pages"])
|
||||||
|
counts["rows_processed"] += len(manifest["image_posts"])
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
manifest_path(images_root).write_text(json.dumps(manifest, indent=2))
|
manifest_path(images_root).write_text(json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ FC's filesystem scan over the mounted IR images dir).
|
|||||||
- image_artist_assignments → ImageRecord.artist_id (find_or_create Artist by slug).
|
- image_artist_assignments → ImageRecord.artist_id (find_or_create Artist by slug).
|
||||||
- image_tag_associations → image_tag insert (idempotent).
|
- image_tag_associations → image_tag insert (idempotent).
|
||||||
- series_pages → series_page insert (idempotent on image_id unique).
|
- series_pages → series_page insert (idempotent on image_id unique).
|
||||||
|
- image_posts (schema v2) → Source + Post + ImageProvenance restore.
|
||||||
|
|
||||||
Unmatched sha256s are logged into the result's `unmatched` list so the
|
Unmatched sha256s are logged into the result's `unmatched` list so the
|
||||||
Celery task can drop them into MigrationRun.metadata for the operator
|
Celery task can drop them into MigrationRun.metadata for the operator
|
||||||
@@ -15,15 +16,118 @@ to inspect.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...models import Artist, ImageRecord, SeriesPage, Tag, TagKind, image_tag
|
from ...models import (
|
||||||
|
Artist,
|
||||||
|
ImageProvenance,
|
||||||
|
ImageRecord,
|
||||||
|
Post,
|
||||||
|
SeriesPage,
|
||||||
|
Source,
|
||||||
|
Tag,
|
||||||
|
TagKind,
|
||||||
|
image_tag,
|
||||||
|
)
|
||||||
from ...utils.slug import slugify
|
from ...utils.slug import slugify
|
||||||
from .ir_ingest import manifest_path
|
from .ir_ingest import manifest_path
|
||||||
|
|
||||||
|
# Per-platform artist-profile URL — used as Source.url when restoring
|
||||||
|
# IR PostMetadata into FC. Keep this table in sync with
|
||||||
|
# backend/app/services/extension_service.py:_PLATFORM_PATTERNS and
|
||||||
|
# extension/lib/platforms.js.
|
||||||
|
_PLATFORM_PROFILE_URL = {
|
||||||
|
"patreon": "https://www.patreon.com/{slug}",
|
||||||
|
"subscribestar": "https://www.subscribestar.com/{slug}",
|
||||||
|
"hentaifoundry": "https://www.hentai-foundry.com/user/{slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_url(platform: str, artist_slug: str) -> str | None:
|
||||||
|
fmt = _PLATFORM_PROFILE_URL.get(platform)
|
||||||
|
return fmt.format(slug=artist_slug) if fmt else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_or_create_source(
|
||||||
|
db: AsyncSession, *, artist_id: int, platform: str, url: str, dry_run: bool,
|
||||||
|
) -> int | None:
|
||||||
|
existing = (await db.execute(
|
||||||
|
select(Source.id).where(
|
||||||
|
Source.artist_id == artist_id,
|
||||||
|
Source.platform == platform,
|
||||||
|
Source.url == url,
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
if dry_run:
|
||||||
|
return None
|
||||||
|
s = Source(artist_id=artist_id, platform=platform, url=url, enabled=False)
|
||||||
|
db.add(s)
|
||||||
|
await db.flush()
|
||||||
|
return s.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_or_create_post(
|
||||||
|
db: AsyncSession, *,
|
||||||
|
source_id: int, external_post_id: str,
|
||||||
|
title: str | None, description: str | None, post_url: str | None,
|
||||||
|
post_date_iso: str | None, attachment_count: int, dry_run: bool,
|
||||||
|
) -> int | None:
|
||||||
|
existing = (await db.execute(
|
||||||
|
select(Post.id).where(
|
||||||
|
Post.source_id == source_id,
|
||||||
|
Post.external_post_id == external_post_id,
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
if dry_run:
|
||||||
|
return None
|
||||||
|
post_date = None
|
||||||
|
if post_date_iso:
|
||||||
|
post_date = datetime.fromisoformat(post_date_iso)
|
||||||
|
p = Post(
|
||||||
|
source_id=source_id,
|
||||||
|
external_post_id=external_post_id,
|
||||||
|
post_title=title,
|
||||||
|
description=description,
|
||||||
|
post_url=post_url,
|
||||||
|
post_date=post_date,
|
||||||
|
attachment_count=attachment_count,
|
||||||
|
raw_metadata={"migrated_from": "imagerepo"},
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
await db.flush()
|
||||||
|
return p.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_provenance(
|
||||||
|
db: AsyncSession, *,
|
||||||
|
image_id: int, post_id: int, source_id: int, dry_run: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""Returns True if a new ImageProvenance row was inserted."""
|
||||||
|
existing = (await db.execute(
|
||||||
|
select(ImageProvenance.id).where(
|
||||||
|
ImageProvenance.image_record_id == image_id,
|
||||||
|
ImageProvenance.post_id == post_id,
|
||||||
|
ImageProvenance.source_id == source_id,
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return False
|
||||||
|
if dry_run:
|
||||||
|
return True
|
||||||
|
db.add(ImageProvenance(
|
||||||
|
image_record_id=image_id, post_id=post_id, source_id=source_id,
|
||||||
|
))
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _zero_counts() -> dict:
|
def _zero_counts() -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -167,6 +271,67 @@ async def apply_async(
|
|||||||
))
|
))
|
||||||
counts["rows_inserted"] += 1
|
counts["rows_inserted"] += 1
|
||||||
|
|
||||||
|
# 4. Image posts (schema v2) → Source + Post + ImageProvenance.
|
||||||
|
# Restores IR PostMetadata as FC's downloader-track provenance,
|
||||||
|
# so the modal's ProvenancePanel surfaces title/description/
|
||||||
|
# source URL/publish date the same way it does for live
|
||||||
|
# gallery-dl downloads.
|
||||||
|
for entry in manifest.get("image_posts", []):
|
||||||
|
counts["rows_processed"] += 1
|
||||||
|
platform = entry.get("platform")
|
||||||
|
artist_name = entry.get("artist")
|
||||||
|
if not platform or not artist_name:
|
||||||
|
counts["rows_skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
aid = await _ensure_artist_id(db, artist_name, dry_run)
|
||||||
|
if aid is None:
|
||||||
|
counts["rows_skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = _profile_url(platform, slugify(artist_name))
|
||||||
|
if url is None:
|
||||||
|
counts["rows_skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_id = await _find_or_create_source(
|
||||||
|
db, artist_id=aid, platform=platform, url=url, dry_run=dry_run,
|
||||||
|
)
|
||||||
|
if source_id is None:
|
||||||
|
counts["rows_skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
post_id = await _find_or_create_post(
|
||||||
|
db, source_id=source_id,
|
||||||
|
external_post_id=entry.get("post_id") or "",
|
||||||
|
title=entry.get("title"),
|
||||||
|
description=entry.get("description"),
|
||||||
|
post_url=entry.get("source_url"),
|
||||||
|
post_date_iso=entry.get("published_at"),
|
||||||
|
attachment_count=entry.get("attachment_count") or 0,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
if post_id is None:
|
||||||
|
counts["rows_skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
for sha in entry.get("image_sha256s", []):
|
||||||
|
img_id = await _sha_to_image_id(db, sha)
|
||||||
|
if img_id is None:
|
||||||
|
unmatched.append({
|
||||||
|
"kind": "post", "sha256": sha,
|
||||||
|
"post_id": entry.get("post_id"),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
inserted = await _ensure_provenance(
|
||||||
|
db, image_id=img_id, post_id=post_id,
|
||||||
|
source_id=source_id, dry_run=dry_run,
|
||||||
|
)
|
||||||
|
if inserted:
|
||||||
|
counts["rows_inserted"] += 1
|
||||||
|
else:
|
||||||
|
counts["rows_skipped"] += 1
|
||||||
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"counts": counts, "unmatched": unmatched}
|
return {"counts": counts, "unmatched": unmatched}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from ..celery_app import celery
|
from ..celery_app import celery
|
||||||
@@ -27,7 +28,18 @@ def _async_session_factory():
|
|||||||
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
|
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="backend.app.tasks.download.download_source", bind=True, acks_late=True)
|
@celery.task(
|
||||||
|
name="backend.app.tasks.download.download_source",
|
||||||
|
bind=True,
|
||||||
|
acks_late=True,
|
||||||
|
autoretry_for=(OperationalError, DBAPIError, OSError),
|
||||||
|
retry_backoff=10,
|
||||||
|
retry_backoff_max=120,
|
||||||
|
retry_jitter=True,
|
||||||
|
max_retries=3,
|
||||||
|
soft_time_limit=900,
|
||||||
|
time_limit=1200,
|
||||||
|
)
|
||||||
def download_source(self, source_id: int) -> int:
|
def download_source(self, source_id: int) -> int:
|
||||||
"""Returns the DownloadEvent.id."""
|
"""Returns the DownloadEvent.id."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
"""import_media_file task: imports one file via the Importer service and
|
"""import_media_file task: imports one file via the Importer service and
|
||||||
updates the ImportTask state machine + ImportBatch counters atomically.
|
updates the ImportTask state machine + ImportBatch counters atomically.
|
||||||
|
|
||||||
|
Resilience contract (2026-05-24, operator-mandated): once a row has been
|
||||||
|
flipped to 'processing' inside this task, EVERY exit path MUST flip it
|
||||||
|
to a terminal state (complete / skipped / failed) or rely on Celery's
|
||||||
|
autoretry to attempt the work again. No exit path is allowed to leave
|
||||||
|
the row stuck in 'processing'. The `recover_interrupted_tasks`
|
||||||
|
maintenance sweep is the safety net (5 min threshold) for the case
|
||||||
|
where even the failure-marking commit can't be written.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from celery.exceptions import SoftTimeLimitExceeded
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||||
|
|
||||||
from ..celery_app import celery
|
from ..celery_app import celery
|
||||||
from ..models import ImportBatch, ImportSettings, ImportTask
|
from ..models import ImportBatch, ImportSettings, ImportTask
|
||||||
@@ -29,9 +39,54 @@ def _map_result_to_status(result):
|
|||||||
return ("failed", False)
|
return ("failed", False)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="backend.app.tasks.import_file.import_media_file", bind=True)
|
def _mark_failed(session, task, error_msg: str) -> None:
|
||||||
|
"""Best-effort flip of a 'processing' row to 'failed' + batch counter
|
||||||
|
increment. Wrapped in its own try because if the DB is what just
|
||||||
|
broke, this commit will also fail — that's why the maintenance sweep
|
||||||
|
exists as a backstop."""
|
||||||
|
try:
|
||||||
|
task.status = "failed"
|
||||||
|
task.error = error_msg
|
||||||
|
task.finished_at = datetime.now(UTC)
|
||||||
|
session.add(task)
|
||||||
|
session.execute(
|
||||||
|
update(ImportBatch)
|
||||||
|
.where(ImportBatch.id == task.batch_id)
|
||||||
|
.values(failed=ImportBatch.failed + 1)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except Exception: # noqa: BLE001 — best-effort, sweep catches the rest
|
||||||
|
try:
|
||||||
|
session.rollback()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="backend.app.tasks.import_file.import_media_file",
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(OperationalError, DBAPIError, OSError),
|
||||||
|
retry_backoff=5,
|
||||||
|
retry_backoff_max=60,
|
||||||
|
retry_jitter=True,
|
||||||
|
max_retries=3,
|
||||||
|
soft_time_limit=300,
|
||||||
|
time_limit=360,
|
||||||
|
)
|
||||||
def import_media_file(self, import_task_id: int) -> dict:
|
def import_media_file(self, import_task_id: int) -> dict:
|
||||||
"""Returns a dict so the eager-mode tests can assert without DB."""
|
"""Returns a dict so the eager-mode tests can assert without DB.
|
||||||
|
|
||||||
|
Decorator notes:
|
||||||
|
- autoretry_for: transient DB / filesystem errors retry with
|
||||||
|
exponential backoff (5s base, jitter, max 3 attempts). On final
|
||||||
|
give-up the task raises and acks_late=True (set globally on the
|
||||||
|
Celery app) does NOT redeliver — the recovery sweep catches the
|
||||||
|
row instead.
|
||||||
|
- soft_time_limit (300s) raises SoftTimeLimitExceeded in this
|
||||||
|
process so the task can mark its row failed before being killed.
|
||||||
|
- time_limit (360s) is the hard cap; SIGKILL if the soft signal
|
||||||
|
was swallowed.
|
||||||
|
"""
|
||||||
SessionLocal = _sync_session_factory()
|
SessionLocal = _sync_session_factory()
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
task = session.get(ImportTask, import_task_id)
|
task = session.get(ImportTask, import_task_id)
|
||||||
@@ -43,99 +98,104 @@ def import_media_file(self, import_task_id: int) -> dict:
|
|||||||
session.add(task)
|
session.add(task)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
settings = session.execute(
|
|
||||||
select(ImportSettings).where(ImportSettings.id == 1)
|
|
||||||
).scalar_one()
|
|
||||||
import_root = Path(settings.import_scan_path)
|
|
||||||
batch = session.get(ImportBatch, task.batch_id)
|
|
||||||
deep = bool(batch and batch.scan_mode == "deep")
|
|
||||||
importer = Importer(
|
|
||||||
session=session,
|
|
||||||
images_root=IMAGES_ROOT,
|
|
||||||
import_root=import_root,
|
|
||||||
thumbnailer=Thumbnailer(images_root=IMAGES_ROOT),
|
|
||||||
settings=settings,
|
|
||||||
deep=deep,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = importer.import_one(Path(task.source_path))
|
return _do_import(session, task, import_task_id)
|
||||||
except Exception as exc: # pragma: no cover — pipeline crash
|
except SoftTimeLimitExceeded:
|
||||||
task.status = "failed"
|
_mark_failed(session, task, "soft_time_limit exceeded (>300s)")
|
||||||
task.error = f"{type(exc).__name__}: {exc}"
|
raise
|
||||||
task.finished_at = datetime.now(UTC)
|
except (OperationalError, DBAPIError, OSError):
|
||||||
session.execute(
|
# Retryable per the decorator; do NOT mark failed (let
|
||||||
update(ImportBatch)
|
# autoretry have a clean go at it). If autoretry exhausts,
|
||||||
.where(ImportBatch.id == task.batch_id)
|
# the row stays 'processing' and the maintenance sweep
|
||||||
.values(failed=ImportBatch.failed + 1)
|
# flips it within 5 min.
|
||||||
)
|
raise
|
||||||
session.add(task)
|
except Exception as exc: # noqa: BLE001 — pipeline crash, mark + re-raise
|
||||||
session.commit()
|
_mark_failed(session, task, f"{type(exc).__name__}: {exc}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if result.status in ("imported", "superseded"):
|
|
||||||
task.status = "complete"
|
|
||||||
task.result_image_id = result.image_id
|
|
||||||
counter_col_name = "imported"
|
|
||||||
counter_col = ImportBatch.imported
|
|
||||||
elif result.status == "attached":
|
|
||||||
task.status = "complete"
|
|
||||||
counter_col_name = "attachments"
|
|
||||||
counter_col = ImportBatch.attachments
|
|
||||||
elif result.status == "skipped":
|
|
||||||
task.status = "skipped"
|
|
||||||
task.error = (
|
|
||||||
f"{result.skip_reason.value}: {result.error}"
|
|
||||||
if result.skip_reason
|
|
||||||
else result.error
|
|
||||||
)
|
|
||||||
task.result_image_id = result.image_id
|
|
||||||
counter_col_name = "skipped"
|
|
||||||
counter_col = ImportBatch.skipped
|
|
||||||
else:
|
|
||||||
task.status = "failed"
|
|
||||||
task.error = result.error
|
|
||||||
counter_col_name = "failed"
|
|
||||||
counter_col = ImportBatch.failed
|
|
||||||
|
|
||||||
task.finished_at = datetime.now(UTC)
|
def _do_import(session, task, import_task_id: int) -> dict:
|
||||||
|
"""Actual work, called from inside the resilience wrapper."""
|
||||||
|
settings = session.execute(
|
||||||
|
select(ImportSettings).where(ImportSettings.id == 1)
|
||||||
|
).scalar_one()
|
||||||
|
import_root = Path(settings.import_scan_path)
|
||||||
|
batch = session.get(ImportBatch, task.batch_id)
|
||||||
|
deep = bool(batch and batch.scan_mode == "deep")
|
||||||
|
importer = Importer(
|
||||||
|
session=session,
|
||||||
|
images_root=IMAGES_ROOT,
|
||||||
|
import_root=import_root,
|
||||||
|
thumbnailer=Thumbnailer(images_root=IMAGES_ROOT),
|
||||||
|
settings=settings,
|
||||||
|
deep=deep,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = importer.import_one(Path(task.source_path))
|
||||||
|
|
||||||
|
if result.status in ("imported", "superseded"):
|
||||||
|
task.status = "complete"
|
||||||
|
task.result_image_id = result.image_id
|
||||||
|
counter_col_name = "imported"
|
||||||
|
counter_col = ImportBatch.imported
|
||||||
|
elif result.status == "attached":
|
||||||
|
task.status = "complete"
|
||||||
|
counter_col_name = "attachments"
|
||||||
|
counter_col = ImportBatch.attachments
|
||||||
|
elif result.status == "skipped":
|
||||||
|
task.status = "skipped"
|
||||||
|
task.error = (
|
||||||
|
f"{result.skip_reason.value}: {result.error}"
|
||||||
|
if result.skip_reason
|
||||||
|
else result.error
|
||||||
|
)
|
||||||
|
task.result_image_id = result.image_id
|
||||||
|
counter_col_name = "skipped"
|
||||||
|
counter_col = ImportBatch.skipped
|
||||||
|
else:
|
||||||
|
task.status = "failed"
|
||||||
|
task.error = result.error
|
||||||
|
counter_col_name = "failed"
|
||||||
|
counter_col = ImportBatch.failed
|
||||||
|
|
||||||
|
task.finished_at = datetime.now(UTC)
|
||||||
|
session.execute(
|
||||||
|
update(ImportBatch)
|
||||||
|
.where(ImportBatch.id == task.batch_id)
|
||||||
|
.values({counter_col_name: counter_col + 1})
|
||||||
|
)
|
||||||
|
session.add(task)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Enqueue thumbnail + ML for newly imported AND superseded images
|
||||||
|
# (a superseded row has cleared ML + no thumbnail).
|
||||||
|
if result.status in ("imported", "superseded"):
|
||||||
|
from .ml import tag_and_embed
|
||||||
|
from .thumbnail import generate_thumbnail
|
||||||
|
|
||||||
|
ids = list(result.member_image_ids)
|
||||||
|
if result.image_id is not None and result.image_id not in ids:
|
||||||
|
ids.append(result.image_id)
|
||||||
|
for img_id in ids:
|
||||||
|
generate_thumbnail.delay(img_id)
|
||||||
|
tag_and_embed.delay(img_id)
|
||||||
|
|
||||||
|
# If this was the last task in the batch, mark the batch complete.
|
||||||
|
remaining = session.execute(
|
||||||
|
select(ImportTask.id)
|
||||||
|
.where(ImportTask.batch_id == task.batch_id)
|
||||||
|
.where(ImportTask.status.in_(["pending", "queued", "processing"]))
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if remaining is None:
|
||||||
session.execute(
|
session.execute(
|
||||||
update(ImportBatch)
|
update(ImportBatch)
|
||||||
.where(ImportBatch.id == task.batch_id)
|
.where(ImportBatch.id == task.batch_id)
|
||||||
.values({counter_col_name: counter_col + 1})
|
.values(
|
||||||
|
status="complete",
|
||||||
|
finished_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
session.add(task)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
# Enqueue thumbnail + ML for newly imported AND superseded images
|
return {"task_id": import_task_id, "status": task.status}
|
||||||
# (a superseded row has cleared ML + no thumbnail).
|
|
||||||
if result.status in ("imported", "superseded"):
|
|
||||||
from .ml import tag_and_embed
|
|
||||||
from .thumbnail import generate_thumbnail
|
|
||||||
|
|
||||||
ids = list(result.member_image_ids)
|
|
||||||
if result.image_id is not None and result.image_id not in ids:
|
|
||||||
ids.append(result.image_id)
|
|
||||||
for img_id in ids:
|
|
||||||
generate_thumbnail.delay(img_id)
|
|
||||||
tag_and_embed.delay(img_id)
|
|
||||||
|
|
||||||
# If this was the last task in the batch, mark the batch complete.
|
|
||||||
remaining = session.execute(
|
|
||||||
select(ImportTask.id)
|
|
||||||
.where(ImportTask.batch_id == task.batch_id)
|
|
||||||
.where(ImportTask.status.in_(["pending", "queued", "processing"]))
|
|
||||||
.limit(1)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if remaining is None:
|
|
||||||
session.execute(
|
|
||||||
update(ImportBatch)
|
|
||||||
.where(ImportBatch.id == task.batch_id)
|
|
||||||
.values(
|
|
||||||
status="complete",
|
|
||||||
finished_at=datetime.now(UTC),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
return {"task_id": import_task_id, "status": task.status}
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from ._sync_engine import sync_session_factory as _sync_session_factory
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
STUCK_THRESHOLD_MINUTES = 30
|
STUCK_THRESHOLD_MINUTES = 5
|
||||||
OLD_TASK_DAYS = 7
|
OLD_TASK_DAYS = 7
|
||||||
PHASH_PAGE = 500
|
PHASH_PAGE = 500
|
||||||
VERIFY_PAGE = 200
|
VERIFY_PAGE = 200
|
||||||
@@ -24,11 +24,15 @@ FFPROBE_TIMEOUT_SECONDS = 10
|
|||||||
|
|
||||||
@celery.task(name="backend.app.tasks.maintenance.recover_interrupted_tasks")
|
@celery.task(name="backend.app.tasks.maintenance.recover_interrupted_tasks")
|
||||||
def recover_interrupted_tasks() -> int:
|
def recover_interrupted_tasks() -> int:
|
||||||
"""Find ImportTask rows stuck in 'processing' for >30 min and re-queue them.
|
"""Find ImportTask rows stuck in 'processing' for >5 min and re-queue them.
|
||||||
|
|
||||||
Why 30 min: large videos can legitimately take many minutes to import;
|
Why 5 min: import_media_file is sub-second for the vast majority of
|
||||||
30 is a safe gate that catches actual crashes (which leave the row stuck
|
files; even a large-video transcode caps at the per-task soft_time_limit
|
||||||
forever) without resetting slow-but-still-running jobs.
|
(5 min) defined on the task itself. Anything still 'processing' after
|
||||||
|
that window is a confirmed crash (worker died, DB disconnect mid-flush,
|
||||||
|
OOM) and must be recycled. Was 30 min historically; tightened
|
||||||
|
2026-05-24 after operator hit a 2224-row zombie pile during the IR
|
||||||
|
migration scan.
|
||||||
"""
|
"""
|
||||||
SessionLocal = _sync_session_factory()
|
SessionLocal = _sync_session_factory()
|
||||||
cutoff = datetime.now(UTC) - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
|
cutoff = datetime.now(UTC) - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
|
||||||
|
|||||||
+12
-1
@@ -9,6 +9,7 @@ apply_allowlist_tags sweeps which are 'maintenance' lane. Sync sessions
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||||
|
|
||||||
from ..celery_app import celery
|
from ..celery_app import celery
|
||||||
from ..models import ImageRecord, MLSettings
|
from ..models import ImageRecord, MLSettings
|
||||||
@@ -22,7 +23,17 @@ def _is_video(path: Path) -> bool:
|
|||||||
return path.suffix.lower() in VIDEO_EXTS
|
return path.suffix.lower() in VIDEO_EXTS
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="backend.app.tasks.ml.tag_and_embed", bind=True)
|
@celery.task(
|
||||||
|
name="backend.app.tasks.ml.tag_and_embed",
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(OperationalError, DBAPIError, OSError),
|
||||||
|
retry_backoff=5,
|
||||||
|
retry_backoff_max=60,
|
||||||
|
retry_jitter=True,
|
||||||
|
max_retries=3,
|
||||||
|
soft_time_limit=300,
|
||||||
|
time_limit=420,
|
||||||
|
)
|
||||||
def tag_and_embed(self, image_id: int) -> dict:
|
def tag_and_embed(self, image_id: int) -> dict:
|
||||||
"""Run Camie + SigLIP on one image; store predictions + embedding;
|
"""Run Camie + SigLIP on one image; store predictions + embedding;
|
||||||
then enqueue per-image allowlist application.
|
then enqueue per-image allowlist application.
|
||||||
|
|||||||
@@ -95,6 +95,15 @@ def scan_directory(self, triggered_by: str = "manual",
|
|||||||
files_seen += 1
|
files_seen += 1
|
||||||
|
|
||||||
batch.total_files = files_seen
|
batch.total_files = files_seen
|
||||||
|
# If the walk enqueued nothing (every file was already on a
|
||||||
|
# non-failed ImportTask from a prior scan), there's no
|
||||||
|
# import_media_file message that would ever flip this batch to
|
||||||
|
# 'complete' — finalize it now so the active-batch query in
|
||||||
|
# /api/system/stats doesn't get stuck reporting all-zero
|
||||||
|
# counters from an empty scan.
|
||||||
|
if files_seen == 0:
|
||||||
|
batch.status = "complete"
|
||||||
|
batch.finished_at = datetime.now(UTC)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
# Now enqueue import_media_file for each pending task.
|
# Now enqueue import_media_file for each pending task.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ so they deserve their own queue lane.
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||||
|
|
||||||
from ..celery_app import celery
|
from ..celery_app import celery
|
||||||
from ..models import ImageRecord
|
from ..models import ImageRecord
|
||||||
from ..services.importer import is_video
|
from ..services.importer import is_video
|
||||||
@@ -16,7 +18,17 @@ from ._sync_engine import sync_session_factory as _sync_session_factory
|
|||||||
IMAGES_ROOT = Path("/images")
|
IMAGES_ROOT = Path("/images")
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="backend.app.tasks.thumbnail.generate_thumbnail", bind=True)
|
@celery.task(
|
||||||
|
name="backend.app.tasks.thumbnail.generate_thumbnail",
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(OperationalError, DBAPIError, OSError),
|
||||||
|
retry_backoff=5,
|
||||||
|
retry_backoff_max=60,
|
||||||
|
retry_jitter=True,
|
||||||
|
max_retries=3,
|
||||||
|
soft_time_limit=120,
|
||||||
|
time_limit=180,
|
||||||
|
)
|
||||||
def generate_thumbnail(self, image_id: int) -> dict:
|
def generate_thumbnail(self, image_id: int) -> dict:
|
||||||
SessionLocal = _sync_session_factory()
|
SessionLocal = _sync_session_factory()
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
web-ext-artifacts/
|
||||||
|
.web-ext-id
|
||||||
|
.amo-upload-uuid
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# FabledCurator Firefox Extension
|
||||||
|
|
||||||
|
Self-hosted Firefox extension that pushes session cookies from supported
|
||||||
|
platforms (Patreon, SubscribeStar, Hentai-Foundry, Discord, Pixiv,
|
||||||
|
DeviantArt) into FabledCurator, and lets you add a creator as a Source
|
||||||
|
from their page in one click.
|
||||||
|
|
||||||
|
## Install (operator)
|
||||||
|
|
||||||
|
The signed XPI is bundled into the FC Docker image. Open FC →
|
||||||
|
Settings → Maintenance → Browser extension → click "Install Firefox
|
||||||
|
extension". Firefox shows its native install prompt. After installing,
|
||||||
|
open the extension's options page (about:addons → FabledCurator →
|
||||||
|
Preferences) and paste in the FC URL + extension API key shown on the
|
||||||
|
same card.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd extension/
|
||||||
|
npm install --no-save # web-ext only
|
||||||
|
npm run lint # web-ext lint
|
||||||
|
npm run start # launches Firefox with extension loaded
|
||||||
|
npm run build # unsigned XPI in web-ext-artifacts/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke checklist (after every release that touches `extension/**`)
|
||||||
|
|
||||||
|
- [ ] `npm run lint` passes
|
||||||
|
- [ ] `npm run start` loads the extension in a clean Firefox profile
|
||||||
|
- [ ] Options page accepts FC URL + key, indicator turns green
|
||||||
|
- [ ] Cookie export: log into patreon.com, click Patreon card → "X cookies exported"
|
||||||
|
- [ ] Discord token: open discord.com, click Discord card → "Token captured"
|
||||||
|
- [ ] Pixiv OAuth: click Pixiv card → login redirects, token stored
|
||||||
|
- [ ] Add as source: visit patreon.com/<creator>, click floating button → toast
|
||||||
|
- [ ] Subscriptions list: popup → "Sources" tab → list renders
|
||||||
|
- [ ] Check now: click play icon on source row → no error toast
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
Bump `manifest.json` + `package.json` SemVer (both files) and commit
|
||||||
|
under `extension/**`. The `.forgejo/workflows/extension.yml` workflow
|
||||||
|
runs `web-ext sign` on main, commits the signed XPI to
|
||||||
|
`frontend/public/extension/`, and the next FC server build bundles it
|
||||||
|
into the Docker image.
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Background script — message router + Discord token capture
|
||||||
|
* (webRequest) + Pixiv PKCE OAuth. Direct port of GS background.js;
|
||||||
|
* api.js client points at FC instead of GS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let discordToken = null;
|
||||||
|
let discordTokenCapturedAt = null;
|
||||||
|
|
||||||
|
let pixivRefreshToken = null;
|
||||||
|
let pixivTokenCapturedAt = null;
|
||||||
|
let pixivOAuthPending = null;
|
||||||
|
|
||||||
|
const PIXIV_CLIENT_ID = 'MOBrBDS8blbauoSck0ZfDbtuzpyT';
|
||||||
|
const PIXIV_CLIENT_SECRET = 'lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj';
|
||||||
|
const PIXIV_OAUTH_URL = 'https://app-api.pixiv.net/web/v1/login';
|
||||||
|
const PIXIV_TOKEN_URL = 'https://oauth.secure.pixiv.net/auth/token';
|
||||||
|
const PIXIV_REDIRECT_URI = 'https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback';
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
async function ensureInitialized() {
|
||||||
|
if (initialized) return;
|
||||||
|
await api.init();
|
||||||
|
await loadDiscordToken();
|
||||||
|
await loadPixivToken();
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.runtime.onInstalled.addListener(() => ensureInitialized());
|
||||||
|
browser.runtime.onStartup.addListener(() => ensureInitialized());
|
||||||
|
ensureInitialized().catch(e => console.error('init failed:', e));
|
||||||
|
|
||||||
|
// ---- Discord token capture via webRequest ----
|
||||||
|
|
||||||
|
browser.webRequest.onBeforeSendHeaders.addListener(
|
||||||
|
(details) => {
|
||||||
|
const auth = details.requestHeaders?.find(h => h.name.toLowerCase() === 'authorization');
|
||||||
|
if (auth?.value && auth.value !== discordToken) {
|
||||||
|
saveDiscordToken(auth.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ urls: ['https://discord.com/api/*'] },
|
||||||
|
['requestHeaders'],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadDiscordToken() {
|
||||||
|
const s = await browser.storage.local.get(['discordToken', 'discordTokenCapturedAt']);
|
||||||
|
discordToken = s.discordToken || null;
|
||||||
|
discordTokenCapturedAt = s.discordTokenCapturedAt || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDiscordToken(token) {
|
||||||
|
discordToken = token;
|
||||||
|
discordTokenCapturedAt = new Date().toISOString();
|
||||||
|
await browser.storage.local.set({ discordToken: token, discordTokenCapturedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Pixiv PKCE OAuth ----
|
||||||
|
|
||||||
|
async function loadPixivToken() {
|
||||||
|
const s = await browser.storage.local.get(['pixivRefreshToken', 'pixivTokenCapturedAt']);
|
||||||
|
pixivRefreshToken = s.pixivRefreshToken || null;
|
||||||
|
pixivTokenCapturedAt = s.pixivTokenCapturedAt || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePixivToken(token) {
|
||||||
|
pixivRefreshToken = token;
|
||||||
|
pixivTokenCapturedAt = new Date().toISOString();
|
||||||
|
await browser.storage.local.set({ pixivRefreshToken: token, pixivTokenCapturedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCodeVerifier() {
|
||||||
|
const a = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(a);
|
||||||
|
return base64UrlEncode(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateCodeChallenge(verifier) {
|
||||||
|
const data = new TextEncoder().encode(verifier);
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
return base64UrlEncode(new Uint8Array(hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(buf) {
|
||||||
|
return btoa(String.fromCharCode(...buf)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initiatePixivOAuth() {
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
client: 'pixiv-android',
|
||||||
|
});
|
||||||
|
const tab = await browser.tabs.create({ url: `${PIXIV_OAUTH_URL}?${params}` });
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pixivOAuthPending = { codeVerifier, tabId: tab.id, resolve, reject };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pixivOAuthPending && pixivOAuthPending.tabId === tab.id) {
|
||||||
|
pixivOAuthPending = null;
|
||||||
|
reject(new Error('Pixiv OAuth timed out (5 min)'));
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.webRequest.onBeforeRedirect.addListener(
|
||||||
|
async (details) => {
|
||||||
|
if (!pixivOAuthPending) return;
|
||||||
|
if (details.tabId !== pixivOAuthPending.tabId) return;
|
||||||
|
const url = new URL(details.redirectUrl);
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
if (!code) return;
|
||||||
|
const verifier = pixivOAuthPending.codeVerifier;
|
||||||
|
const resolve = pixivOAuthPending.resolve;
|
||||||
|
const reject = pixivOAuthPending.reject;
|
||||||
|
pixivOAuthPending = null;
|
||||||
|
try {
|
||||||
|
const tokenResp = await fetch(PIXIV_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: PIXIV_CLIENT_ID,
|
||||||
|
client_secret: PIXIV_CLIENT_SECRET,
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
include_policy: 'true',
|
||||||
|
redirect_uri: PIXIV_REDIRECT_URI,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await tokenResp.json();
|
||||||
|
if (!body.refresh_token) {
|
||||||
|
reject(new Error(`Pixiv token exchange failed: ${JSON.stringify(body)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await savePixivToken(body.refresh_token);
|
||||||
|
try { await browser.tabs.remove(details.tabId); } catch {}
|
||||||
|
resolve(body.refresh_token);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ urls: ['https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Message router ----
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener(async (msg) => {
|
||||||
|
await ensureInitialized();
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'GET_CONFIG':
|
||||||
|
return { apiUrl: api.baseUrl, apiKey: api.apiKey };
|
||||||
|
|
||||||
|
case 'TEST_CONNECTION':
|
||||||
|
try {
|
||||||
|
await api.testConnection();
|
||||||
|
return { connected: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { connected: false, error: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'GET_PLATFORM_STATUS': {
|
||||||
|
const status = {};
|
||||||
|
for (const key of Object.keys(PLATFORMS)) {
|
||||||
|
if (PLATFORMS[key].authType === 'cookies') {
|
||||||
|
status[key] = { hasCookies: false, cookieCount: 0 };
|
||||||
|
try {
|
||||||
|
const n = await getCookieCount(key);
|
||||||
|
status[key] = { hasCookies: n > 0, cookieCount: n };
|
||||||
|
} catch (e) {
|
||||||
|
status[key] = { error: e.message };
|
||||||
|
}
|
||||||
|
} else if (key === 'discord') {
|
||||||
|
status[key] = { hasToken: !!discordToken, capturedAt: discordTokenCapturedAt };
|
||||||
|
} else if (key === 'pixiv') {
|
||||||
|
status[key] = { hasToken: !!pixivRefreshToken, capturedAt: pixivTokenCapturedAt };
|
||||||
|
} else {
|
||||||
|
status[key] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'EXPORT_COOKIES': {
|
||||||
|
const key = msg.platform;
|
||||||
|
const platform = PLATFORMS[key];
|
||||||
|
if (!platform) return { error: `Unknown platform: ${key}` };
|
||||||
|
try {
|
||||||
|
if (platform.authType === 'cookies') {
|
||||||
|
const cookies = await extractCookiesForPlatform(key);
|
||||||
|
if (cookies.length === 0) return { error: 'No cookies found — log in first.' };
|
||||||
|
const data = toNetscapeFormat(cookies);
|
||||||
|
await api.uploadCredentials(key, 'cookies', data);
|
||||||
|
return { success: true, cookieCount: cookies.length };
|
||||||
|
}
|
||||||
|
if (key === 'discord') {
|
||||||
|
if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
|
||||||
|
await api.uploadCredentials('discord', 'token', discordToken);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (key === 'pixiv') {
|
||||||
|
if (!pixivRefreshToken) {
|
||||||
|
await initiatePixivOAuth();
|
||||||
|
}
|
||||||
|
await api.uploadCredentials('pixiv', 'token', pixivRefreshToken);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { error: 'Unsupported platform.' };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'EXPORT_ALL_COOKIES': {
|
||||||
|
const results = {};
|
||||||
|
for (const key of Object.keys(PLATFORMS)) {
|
||||||
|
if (PLATFORMS[key].authType !== 'cookies') {
|
||||||
|
results[key] = { skipped: true };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cookies = await extractCookiesForPlatform(key);
|
||||||
|
if (cookies.length === 0) {
|
||||||
|
results[key] = { skipped: true, reason: 'no cookies' };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
|
||||||
|
results[key] = { success: true, cookieCount: cookies.length };
|
||||||
|
} catch (e) {
|
||||||
|
results[key] = { error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'LIST_SOURCES':
|
||||||
|
try {
|
||||||
|
return { sources: await api.listSources() };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CHECK_SOURCE':
|
||||||
|
try {
|
||||||
|
return await api.triggerSourceCheck(msg.sourceId);
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ADD_AS_SOURCE':
|
||||||
|
try {
|
||||||
|
return await api.quickAddSource(msg.url);
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { error: `Unknown message type: ${msg.type}` };
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
.fc-add-source-btn {
|
||||||
|
all: revert;
|
||||||
|
position: fixed; bottom: 24px; right: 24px; z-index: 2147483647;
|
||||||
|
padding: 10px 16px; border-radius: 999px; border: none;
|
||||||
|
background: rgb(20, 23, 26); color: rgb(244, 186, 122);
|
||||||
|
font: 500 14px/1.2 system-ui, sans-serif;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer;
|
||||||
|
transition: transform 100ms ease;
|
||||||
|
}
|
||||||
|
.fc-add-source-btn:hover { transform: translateY(-1px); }
|
||||||
|
.fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
|
||||||
|
.fc-toast {
|
||||||
|
all: revert;
|
||||||
|
position: fixed; bottom: 84px; right: 24px; z-index: 2147483647;
|
||||||
|
max-width: 360px; padding: 12px 16px; border-radius: 8px;
|
||||||
|
background: rgb(20, 23, 26); color: rgb(232, 228, 216);
|
||||||
|
font: 14px/1.4 system-ui, sans-serif;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
animation: fc-toast-in 200ms ease;
|
||||||
|
}
|
||||||
|
.fc-toast--success { border-left: 3px solid rgb(244, 186, 122); }
|
||||||
|
.fc-toast--error { border-left: 3px solid rgb(220, 80, 80); }
|
||||||
|
@keyframes fc-toast-in {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
(function () {
|
||||||
|
if (window.__fc_addsource_injected) return;
|
||||||
|
window.__fc_addsource_injected = true;
|
||||||
|
|
||||||
|
evaluate();
|
||||||
|
|
||||||
|
const reEval = () => evaluate();
|
||||||
|
window.addEventListener('popstate', reEval);
|
||||||
|
const origPush = history.pushState;
|
||||||
|
history.pushState = function () { origPush.apply(this, arguments); reEval(); };
|
||||||
|
|
||||||
|
function evaluate() {
|
||||||
|
const platform = getPlatformFromUrl(window.location.href);
|
||||||
|
const onArtist = platform && isArtistPage(window.location.href, platform);
|
||||||
|
let btn = document.getElementById('fc-add-source-btn');
|
||||||
|
if (onArtist && !btn) injectButton();
|
||||||
|
else if (!onArtist && btn) btn.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectButton() {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'fc-add-source-btn';
|
||||||
|
btn.className = 'fc-add-source-btn';
|
||||||
|
btn.textContent = '+ Add to FabledCurator';
|
||||||
|
btn.addEventListener('click', onClick);
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClick() {
|
||||||
|
const btn = document.getElementById('fc-add-source-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
const original = btn.textContent;
|
||||||
|
btn.textContent = 'Adding…';
|
||||||
|
try {
|
||||||
|
const r = await browser.runtime.sendMessage({
|
||||||
|
type: 'ADD_AS_SOURCE',
|
||||||
|
url: window.location.href,
|
||||||
|
});
|
||||||
|
if (r.error) {
|
||||||
|
showToast(`Error: ${r.error}`, 'error');
|
||||||
|
} else {
|
||||||
|
const verb = r.created_source ? 'Added' : 'Already a source for';
|
||||||
|
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Error: ${e.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(text, kind) {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = `fc-toast fc-toast--${kind}`;
|
||||||
|
t.textContent = text;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 4000);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96">
|
||||||
|
<rect width="96" height="96" rx="16" fill="#14171A"/>
|
||||||
|
<text x="48" y="62" font-family="Georgia, serif" font-size="56" font-weight="500"
|
||||||
|
fill="#F4BA7A" text-anchor="middle">F</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 284 B |
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* FC backend client. Talks to /api/credentials (FC-3b), /api/sources
|
||||||
|
* (FC-3a), and the new /api/extension/* endpoints (FC-3g).
|
||||||
|
*/
|
||||||
|
|
||||||
|
class FabledCuratorAPI {
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = null;
|
||||||
|
this.apiKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const cfg = await browser.storage.local.get(['apiUrl', 'apiKey']);
|
||||||
|
this.baseUrl = cfg.apiUrl || null;
|
||||||
|
this.apiKey = cfg.apiKey || null;
|
||||||
|
return this.isConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfigured() {
|
||||||
|
return !!(this.baseUrl && this.apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method, endpoint, data = null) {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
throw new Error('Not configured. Set FC URL + API key in settings.');
|
||||||
|
}
|
||||||
|
const url = `${this.baseUrl.replace(/\/+$/, '')}${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Extension-Key': this.apiKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (data) options.body = JSON.stringify(data);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, options);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Cannot connect to FabledCurator. Check URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
message = body.error || `HTTP ${response.status}`;
|
||||||
|
if (body.detail) message += `: ${body.detail}`;
|
||||||
|
} catch {
|
||||||
|
message = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
}
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = response.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) return { success: true };
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// FC-3b — credentials.
|
||||||
|
uploadCredentials(platform, credentialType, data) {
|
||||||
|
return this.request('POST', '/credentials', {
|
||||||
|
platform,
|
||||||
|
credential_type: credentialType,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getCredentials() {
|
||||||
|
return this.request('GET', '/credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
// FC-3a — sources.
|
||||||
|
listSources() {
|
||||||
|
return this.request('GET', '/sources');
|
||||||
|
}
|
||||||
|
triggerSourceCheck(sourceId) {
|
||||||
|
return this.request('POST', `/sources/${sourceId}/check`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FC-3g — extension-specific.
|
||||||
|
quickAddSource(url) {
|
||||||
|
return this.request('POST', '/extension/quick-add-source', { url });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection test = the cheapest read with auth.
|
||||||
|
testConnection() {
|
||||||
|
return this.request('GET', '/credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = new FabledCuratorAPI();
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Cookie extraction + Netscape format conversion.
|
||||||
|
* Direct port of GS extension/lib/cookies.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function extractCookiesForPlatform(platformKey) {
|
||||||
|
const platform = PLATFORMS[platformKey];
|
||||||
|
if (!platform) throw new Error(`Unknown platform: ${platformKey}`);
|
||||||
|
|
||||||
|
const all = [];
|
||||||
|
for (const domain of platform.domains) {
|
||||||
|
try {
|
||||||
|
const cookies = await browser.cookies.getAll({ domain });
|
||||||
|
all.push(...cookies);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`cookies.getAll failed for ${domain}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deduplicateCookies(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicateCookies(cookies) {
|
||||||
|
const seen = new Map();
|
||||||
|
for (const c of cookies) {
|
||||||
|
const key = `${c.name}|${c.domain}|${c.path}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.set(key, c);
|
||||||
|
} else {
|
||||||
|
const existing = seen.get(key);
|
||||||
|
if (c.expirationDate && (!existing.expirationDate || c.expirationDate > existing.expirationDate)) {
|
||||||
|
seen.set(key, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(seen.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNetscapeFormat(cookies) {
|
||||||
|
const lines = ['# Netscape HTTP Cookie File'];
|
||||||
|
for (const c of cookies) {
|
||||||
|
let domain = c.domain.replace(/^\.?www\./, '.');
|
||||||
|
if (!domain.startsWith('.')) domain = '.' + domain;
|
||||||
|
const secure = c.secure ? 'TRUE' : 'FALSE';
|
||||||
|
const expiration = c.expirationDate ? Math.floor(c.expirationDate) : 0;
|
||||||
|
lines.push([domain, 'TRUE', c.path || '/', secure, String(expiration), c.name, c.value].join('\t'));
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCookieCount(platformKey) {
|
||||||
|
try {
|
||||||
|
return (await extractCookiesForPlatform(platformKey)).length;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Platform definitions — mirrored from backend/app/services/platforms.py.
|
||||||
|
* Two copies of these regexes (here and in backend extension_service.py)
|
||||||
|
* is cheaper than building a shared definition file for two short
|
||||||
|
* tables in different runtimes. Keep in sync by hand; reviewers catch
|
||||||
|
* drift.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PLATFORMS = {
|
||||||
|
patreon: {
|
||||||
|
name: 'Patreon',
|
||||||
|
domains: ['.patreon.com', 'www.patreon.com', 'patreon.com'],
|
||||||
|
authType: 'cookies',
|
||||||
|
color: '#FF424D',
|
||||||
|
urlPattern: /^https?:\/\/(www\.)?patreon\.com/,
|
||||||
|
},
|
||||||
|
subscribestar: {
|
||||||
|
name: 'SubscribeStar',
|
||||||
|
domains: [
|
||||||
|
'.subscribestar.com',
|
||||||
|
'.subscribestar.adult',
|
||||||
|
'www.subscribestar.com',
|
||||||
|
'subscribestar.com',
|
||||||
|
'subscribestar.adult',
|
||||||
|
],
|
||||||
|
authType: 'cookies',
|
||||||
|
color: '#FFD700',
|
||||||
|
urlPattern: /^https?:\/\/(www\.)?subscribestar\.(com|adult)/,
|
||||||
|
},
|
||||||
|
hentaifoundry: {
|
||||||
|
name: 'Hentai Foundry',
|
||||||
|
domains: ['.hentai-foundry.com', 'www.hentai-foundry.com', 'hentai-foundry.com'],
|
||||||
|
authType: 'cookies',
|
||||||
|
color: '#9C27B0',
|
||||||
|
urlPattern: /^https?:\/\/(www\.)?hentai-foundry\.com/,
|
||||||
|
},
|
||||||
|
discord: {
|
||||||
|
name: 'Discord',
|
||||||
|
domains: ['.discord.com', 'discord.com'],
|
||||||
|
authType: 'token',
|
||||||
|
color: '#5865F2',
|
||||||
|
urlPattern: /^https?:\/\/(www\.)?discord\.com/,
|
||||||
|
note: 'Open Discord in browser to capture token',
|
||||||
|
},
|
||||||
|
pixiv: {
|
||||||
|
name: 'Pixiv',
|
||||||
|
domains: ['.pixiv.net', 'www.pixiv.net', 'pixiv.net'],
|
||||||
|
authType: 'token',
|
||||||
|
color: '#0096FA',
|
||||||
|
urlPattern: /^https?:\/\/(www\.)?pixiv\.net/,
|
||||||
|
note: 'Click to authenticate via OAuth',
|
||||||
|
},
|
||||||
|
deviantart: {
|
||||||
|
name: 'DeviantArt',
|
||||||
|
domains: ['.deviantart.com', 'www.deviantart.com', 'deviantart.com'],
|
||||||
|
authType: 'cookies',
|
||||||
|
color: '#05CC47',
|
||||||
|
urlPattern: /^https?:\/\/(www\.)?deviantart\.com/,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-platform artist-page URL patterns — these are STRICTER than
|
||||||
|
* urlPattern (which matches any URL on the domain). Used by the content
|
||||||
|
* script to decide whether to show the floating "Add as source" button.
|
||||||
|
*/
|
||||||
|
const PLATFORM_ARTIST_PATTERNS = {
|
||||||
|
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c\/)[^/?#]+\/?$/i,
|
||||||
|
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
|
||||||
|
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
|
||||||
|
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
|
||||||
|
pixiv: /^https?:\/\/(www\.)?pixiv\.net\/(en\/)?users\/\d+/i,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPlatformFromUrl(url) {
|
||||||
|
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
||||||
|
if (platform.urlPattern.test(url)) return key;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArtistPage(url, platformKey) {
|
||||||
|
const pattern = PLATFORM_ARTIST_PATTERNS[platformKey];
|
||||||
|
return pattern ? pattern.test(url) : false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "FabledCurator",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||||
|
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "fabledcurator@fabledsword.com",
|
||||||
|
"strict_min_version": "115.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"permissions": [
|
||||||
|
"cookies",
|
||||||
|
"storage",
|
||||||
|
"tabs",
|
||||||
|
"activeTab",
|
||||||
|
"webRequest",
|
||||||
|
"webRequestBlocking"
|
||||||
|
],
|
||||||
|
|
||||||
|
"host_permissions": [
|
||||||
|
"*://*.patreon.com/*",
|
||||||
|
"*://*.subscribestar.com/*",
|
||||||
|
"*://*.subscribestar.adult/*",
|
||||||
|
"*://*.hentai-foundry.com/*",
|
||||||
|
"*://*.discord.com/*",
|
||||||
|
"*://*.pixiv.net/*",
|
||||||
|
"*://*.deviantart.com/*",
|
||||||
|
"*://app-api.pixiv.net/*",
|
||||||
|
"*://oauth.secure.pixiv.net/*",
|
||||||
|
"*://*/*"
|
||||||
|
],
|
||||||
|
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup/popup.html",
|
||||||
|
"default_icon": "icons/icon.svg",
|
||||||
|
"default_title": "FabledCurator"
|
||||||
|
},
|
||||||
|
|
||||||
|
"background": {
|
||||||
|
"scripts": ["lib/platforms.js", "lib/cookies.js", "lib/api.js", "background/background.js"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"options_ui": {
|
||||||
|
"page": "options/options.html",
|
||||||
|
"browser_style": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"*://*.patreon.com/*",
|
||||||
|
"*://*.subscribestar.com/*",
|
||||||
|
"*://*.subscribestar.adult/*",
|
||||||
|
"*://*.hentai-foundry.com/*",
|
||||||
|
"*://*.deviantart.com/*",
|
||||||
|
"*://*.pixiv.net/*"
|
||||||
|
],
|
||||||
|
"js": ["lib/platforms.js", "content/content-script.js"],
|
||||||
|
"css": ["content/content-script.css"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"icons": {
|
||||||
|
"48": "icons/icon.svg",
|
||||||
|
"96": "icons/icon.svg"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>FabledCurator — Settings</title>
|
||||||
|
<style>
|
||||||
|
body { font: 14px/1.4 system-ui, sans-serif; max-width: 520px; margin: 32px auto; padding: 0 16px; }
|
||||||
|
h1 { font-family: Georgia, serif; font-size: 20px; }
|
||||||
|
label { display: block; font-weight: 500; margin: 16px 0 6px; }
|
||||||
|
input { width: 100%; padding: 8px; box-sizing: border-box; font: inherit; }
|
||||||
|
.hint { color: #666; font-size: 12px; margin-top: 4px; }
|
||||||
|
.row { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
|
button { padding: 8px 16px; cursor: pointer; font: inherit; }
|
||||||
|
button.primary { background: #F4BA7A; color: #14171A; border: none; border-radius: 4px; }
|
||||||
|
button.test { background: none; border: 1px solid #ccc; border-radius: 4px; }
|
||||||
|
.status { margin-top: 16px; padding: 8px 12px; border-radius: 4px; }
|
||||||
|
.status.ok { background: #DFF5DF; color: #2B6A2B; }
|
||||||
|
.status.err { background: #FBE1E1; color: #862525; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>FabledCurator extension</h1>
|
||||||
|
|
||||||
|
<label for="api-url">FC base URL</label>
|
||||||
|
<input id="api-url" type="url" placeholder="http://curator.example.com/api" />
|
||||||
|
<div class="hint">Find this on FC → Settings → Maintenance → Browser extension.</div>
|
||||||
|
|
||||||
|
<label for="api-key">Extension API key</label>
|
||||||
|
<input id="api-key" type="password" placeholder="paste from FC Settings card" />
|
||||||
|
<div class="hint">Generate or rotate on FC → Settings → Maintenance → Browser extension.</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary" id="save-btn">Save</button>
|
||||||
|
<button class="test" id="test-btn">Test connection</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="status" style="display:none;"></div>
|
||||||
|
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const stored = await browser.storage.local.get(['apiUrl', 'apiKey']);
|
||||||
|
document.getElementById('api-url').value = stored.apiUrl || '';
|
||||||
|
document.getElementById('api-key').value = stored.apiKey || '';
|
||||||
|
|
||||||
|
document.getElementById('save-btn').addEventListener('click', save);
|
||||||
|
document.getElementById('test-btn').addEventListener('click', test);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
|
||||||
|
const apiKey = document.getElementById('api-key').value.trim();
|
||||||
|
if (!apiUrl || !apiKey) {
|
||||||
|
showStatus('Both fields are required.', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await browser.storage.local.set({ apiUrl, apiKey });
|
||||||
|
await browser.storage.local.remove(['lastConnectionTest', 'lastConnectionStatus']);
|
||||||
|
showStatus('Saved.', 'ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
|
||||||
|
const apiKey = document.getElementById('api-key').value.trim();
|
||||||
|
if (!apiUrl || !apiKey) {
|
||||||
|
showStatus('Fill both fields first.', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${apiUrl}/credentials`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'X-Extension-Key': apiKey },
|
||||||
|
});
|
||||||
|
if (r.ok) showStatus(`Connected — HTTP ${r.status}.`, 'ok');
|
||||||
|
else showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(`Cannot reach ${apiUrl}: ${e.message}`, 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(text, kind) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = `status ${kind}`;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "fabledcurator-extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Firefox extension for FabledCurator",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "web-ext lint --source-dir=.",
|
||||||
|
"start": "web-ext run --source-dir=. --firefox=firefox",
|
||||||
|
"build": "web-ext build --source-dir=. --overwrite-dest",
|
||||||
|
"sign": "web-ext sign --source-dir=. --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"web-ext": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #14171A;
|
||||||
|
--surface: #1F2428;
|
||||||
|
--on-surface: #E8E4D8;
|
||||||
|
--on-surface-variant: rgba(232, 228, 216, 0.65);
|
||||||
|
--accent: #F4BA7A;
|
||||||
|
--error: #DC5050;
|
||||||
|
--success: #4CAF50;
|
||||||
|
--warning: #FFB300;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 340px;
|
||||||
|
font: 14px/1.4 system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 14px; background: var(--surface);
|
||||||
|
}
|
||||||
|
.brand { font-family: Georgia, serif; font-size: 16px; font-weight: 500; color: var(--accent); }
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--on-surface-variant); }
|
||||||
|
.dot.connected { background: var(--success); }
|
||||||
|
|
||||||
|
.setup { padding: 20px; text-align: center; }
|
||||||
|
.alert {
|
||||||
|
background: var(--surface); padding: 12px; border-radius: 6px;
|
||||||
|
margin-bottom: 12px; border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
.alert.alert-error { border-left-color: var(--error); }
|
||||||
|
.alert strong { display: block; margin-bottom: 4px; }
|
||||||
|
.alert p { margin: 0; color: var(--on-surface-variant); font-size: 13px; }
|
||||||
|
|
||||||
|
.tabs { display: flex; border-bottom: 1px solid var(--surface); }
|
||||||
|
.tab {
|
||||||
|
flex: 1; padding: 10px 0; background: none; border: none;
|
||||||
|
color: var(--on-surface-variant); cursor: pointer; font: inherit;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
.tab-panel { padding: 10px; }
|
||||||
|
|
||||||
|
.platform-card, .source-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px; margin-bottom: 6px; background: var(--surface);
|
||||||
|
border-radius: 6px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.platform-card.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.platform-card.loading { opacity: 0.6; cursor: wait; }
|
||||||
|
.platform-icon {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
|
display: grid; place-items: center; font-weight: 600; color: white;
|
||||||
|
}
|
||||||
|
.info { flex: 1; min-width: 0; }
|
||||||
|
.info .name { font-weight: 500; }
|
||||||
|
.info .status { font-size: 12px; color: var(--on-surface-variant); }
|
||||||
|
.info .status.ready { color: var(--success); }
|
||||||
|
.info .status.error { color: var(--error); }
|
||||||
|
.info .status.no-cookies { color: var(--warning); }
|
||||||
|
.action-icon { color: var(--on-surface-variant); }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 14px; border: none; border-radius: 6px; cursor: pointer;
|
||||||
|
font: 500 14px/1 system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.btn.primary { background: var(--accent); color: var(--bg); }
|
||||||
|
.btn.primary:hover { filter: brightness(1.05); }
|
||||||
|
.btn.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn.block { display: block; width: 100%; margin-top: 8px; }
|
||||||
|
.btn.link { background: none; color: var(--on-surface-variant); padding: 4px; }
|
||||||
|
.btn.link:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.source-row .play {
|
||||||
|
background: none; border: none; color: var(--on-surface-variant);
|
||||||
|
cursor: pointer; padding: 4px;
|
||||||
|
}
|
||||||
|
.source-row .play:hover { color: var(--accent); }
|
||||||
|
.source-row .url { font-size: 11px; color: var(--on-surface-variant); word-break: break-all; }
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
padding: 10px 14px; margin: 8px 10px; border-radius: 6px;
|
||||||
|
background: var(--surface); border-left: 3px solid;
|
||||||
|
}
|
||||||
|
.status-message.success { border-left-color: var(--success); }
|
||||||
|
.status-message.error { border-left-color: var(--error); }
|
||||||
|
.status-message.warning { border-left-color: var(--warning); }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--surface);
|
||||||
|
padding: 6px 10px; text-align: right;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="stylesheet" href="popup.css" />
|
||||||
|
<title>FabledCurator</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<span class="brand">FabledCurator</span>
|
||||||
|
<span id="connection-status" class="dot" title="checking…"></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="setup-required" class="setup hidden">
|
||||||
|
<div class="alert">
|
||||||
|
<strong>Setup required</strong>
|
||||||
|
<p>Configure the FC URL and extension API key.</p>
|
||||||
|
</div>
|
||||||
|
<button id="open-settings-btn" class="btn primary">Open settings</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="main-content" class="main hidden">
|
||||||
|
<nav class="tabs">
|
||||||
|
<button class="tab active" data-tab="platforms">Platforms</button>
|
||||||
|
<button class="tab" data-tab="sources">Sources</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="tab-platforms" class="tab-panel">
|
||||||
|
<div id="platforms-list"></div>
|
||||||
|
<button id="export-all-btn" class="btn primary block">Export all platforms</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-sources" class="tab-panel hidden">
|
||||||
|
<div id="sources-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status-message" class="status-message hidden"></div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<button id="settings-btn" class="btn link">Settings</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="../lib/platforms.js"></script>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
|
const CONNECTION_TEST_INTERVAL = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' });
|
||||||
|
if (!cfg || !cfg.apiUrl || !cfg.apiKey) {
|
||||||
|
showSetupRequired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('setup-required').classList.add('hidden');
|
||||||
|
document.getElementById('main-content').classList.remove('hidden');
|
||||||
|
setupEventListeners();
|
||||||
|
showPlatformsLoading();
|
||||||
|
testConnectionIfNeeded();
|
||||||
|
loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`));
|
||||||
|
} catch (e) {
|
||||||
|
showSetupRequired();
|
||||||
|
const alert = document.querySelector('#setup-required .alert');
|
||||||
|
alert.textContent = '';
|
||||||
|
const s = document.createElement('strong'); s.textContent = 'Error';
|
||||||
|
const p = document.createElement('p'); p.textContent = e.message;
|
||||||
|
alert.appendChild(s); alert.appendChild(p);
|
||||||
|
alert.classList.add('alert-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSetupRequired() {
|
||||||
|
document.getElementById('setup-required').classList.remove('hidden');
|
||||||
|
document.getElementById('main-content').classList.add('hidden');
|
||||||
|
document.getElementById('open-settings-btn').addEventListener('click', () => {
|
||||||
|
browser.runtime.openOptionsPage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPlatformsLoading() {
|
||||||
|
const c = document.getElementById('platforms-list');
|
||||||
|
c.textContent = '';
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
||||||
|
d.textContent = 'Loading platforms…';
|
||||||
|
c.appendChild(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnectionIfNeeded() {
|
||||||
|
const stored = await browser.storage.local.get(['lastConnectionTest', 'lastConnectionStatus']);
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - (stored.lastConnectionTest || 0) < CONNECTION_TEST_INTERVAL && stored.lastConnectionStatus !== undefined) {
|
||||||
|
updateConnectionDot(stored.lastConnectionStatus);
|
||||||
|
if (!stored.lastConnectionStatus) showError('Cannot connect to backend (cached).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await browser.runtime.sendMessage({ type: 'TEST_CONNECTION' });
|
||||||
|
await browser.storage.local.set({ lastConnectionTest: now, lastConnectionStatus: r.connected });
|
||||||
|
updateConnectionDot(r.connected);
|
||||||
|
if (!r.connected) showError(`Cannot connect to backend: ${r.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConnectionDot(connected) {
|
||||||
|
const d = document.getElementById('connection-status');
|
||||||
|
d.classList.toggle('connected', connected);
|
||||||
|
d.title = connected ? 'Connected to FabledCurator' : 'Disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlatformStatus() {
|
||||||
|
const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
|
||||||
|
const c = document.getElementById('platforms-list');
|
||||||
|
c.textContent = '';
|
||||||
|
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
||||||
|
c.appendChild(createPlatformCard(key, platform, status[key] || {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlatformCard(key, platform, status) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'platform-card';
|
||||||
|
card.dataset.platform = key;
|
||||||
|
|
||||||
|
const isTokenOnly = platform.authType === 'token' && !['discord', 'pixiv'].includes(key);
|
||||||
|
const discordNeedsToken = key === 'discord' && !status.hasToken;
|
||||||
|
if (isTokenOnly || discordNeedsToken) card.classList.add('disabled');
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'platform-icon';
|
||||||
|
icon.style.background = platform.color;
|
||||||
|
icon.textContent = platform.name[0];
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'info';
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = platform.name;
|
||||||
|
const st = document.createElement('div');
|
||||||
|
st.className = `status ${statusClass(status, platform, key)}`;
|
||||||
|
st.textContent = statusText(status, platform, key);
|
||||||
|
info.appendChild(name); info.appendChild(st);
|
||||||
|
|
||||||
|
const act = document.createElement('span');
|
||||||
|
act.className = 'action-icon';
|
||||||
|
act.textContent = '↥';
|
||||||
|
|
||||||
|
card.appendChild(icon); card.appendChild(info); card.appendChild(act);
|
||||||
|
|
||||||
|
if (!isTokenOnly && !discordNeedsToken) {
|
||||||
|
card.addEventListener('click', () => exportPlatformCookies(key, card));
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(s, platform, key) {
|
||||||
|
if (key === 'discord') return s.hasToken ? 'Token captured — ready' : 'Open Discord to capture token';
|
||||||
|
if (key === 'pixiv') return s.hasToken ? 'Token captured — ready' : 'Click to authenticate via OAuth';
|
||||||
|
if (platform.authType === 'token') return 'Manual token entry required';
|
||||||
|
if (s.error) return 'Error checking cookies';
|
||||||
|
if (!s.hasCookies || !s.cookieCount) return 'No cookies — log in first';
|
||||||
|
return `${s.cookieCount} cookies ready`;
|
||||||
|
}
|
||||||
|
function statusClass(s, platform, key) {
|
||||||
|
if (key === 'discord') return s.hasToken ? 'ready' : 'no-cookies';
|
||||||
|
if (key === 'pixiv') return s.hasToken ? 'ready' : 'no-cookies';
|
||||||
|
if (platform.authType === 'token') return 'no-cookies';
|
||||||
|
if (s.error) return 'error';
|
||||||
|
if (!s.hasCookies || !s.cookieCount) return 'no-cookies';
|
||||||
|
return 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPlatformCookies(key, card) {
|
||||||
|
card.classList.add('loading'); hideStatusMessage();
|
||||||
|
try {
|
||||||
|
const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key });
|
||||||
|
if (r.error) showError(r.error);
|
||||||
|
else {
|
||||||
|
const n = r.cookieCount ?? null;
|
||||||
|
const msg = n !== null
|
||||||
|
? `${PLATFORMS[key].name}: ${n} cookies exported`
|
||||||
|
: `${PLATFORMS[key].name}: token exported`;
|
||||||
|
showSuccess(msg);
|
||||||
|
await loadPlatformStatus();
|
||||||
|
}
|
||||||
|
} catch (e) { showError(e.message); }
|
||||||
|
finally { card.classList.remove('loading'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportAllCookies() {
|
||||||
|
const btn = document.getElementById('export-all-btn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Exporting…'; hideStatusMessage();
|
||||||
|
try {
|
||||||
|
const r = await browser.runtime.sendMessage({ type: 'EXPORT_ALL_COOKIES' });
|
||||||
|
const wins = Object.values(r).filter(x => x.success).length;
|
||||||
|
const fails = Object.values(r).filter(x => !x.success && !x.skipped).length;
|
||||||
|
if (wins && !fails) showSuccess(`Exported ${wins} platforms`);
|
||||||
|
else if (wins) showWarning(`${wins} succeeded, ${fails} failed`);
|
||||||
|
else if (fails) showError('All exports failed. Are you logged in?');
|
||||||
|
else showWarning('Nothing to export');
|
||||||
|
await loadPlatformStatus();
|
||||||
|
} catch (e) { showError(e.message); }
|
||||||
|
finally { btn.disabled = false; btn.textContent = 'Export all platforms'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSources() {
|
||||||
|
const c = document.getElementById('sources-list');
|
||||||
|
c.textContent = '';
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
||||||
|
d.textContent = 'Loading sources…';
|
||||||
|
c.appendChild(d);
|
||||||
|
const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' });
|
||||||
|
c.textContent = '';
|
||||||
|
if (r.error) {
|
||||||
|
const e = document.createElement('div');
|
||||||
|
e.style.cssText = 'padding:12px;color:var(--error);';
|
||||||
|
e.textContent = r.error;
|
||||||
|
c.appendChild(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!r.sources || r.sources.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
||||||
|
empty.textContent = 'No sources yet.';
|
||||||
|
c.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const src of r.sources) c.appendChild(createSourceRow(src));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSourceRow(src) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'source-row';
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'info';
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = `${src.platform} · #${src.id}`;
|
||||||
|
const url = document.createElement('div');
|
||||||
|
url.className = 'url';
|
||||||
|
url.textContent = src.url;
|
||||||
|
info.appendChild(name); info.appendChild(url);
|
||||||
|
const play = document.createElement('button');
|
||||||
|
play.className = 'play';
|
||||||
|
play.textContent = '▶';
|
||||||
|
play.title = 'Check now';
|
||||||
|
play.addEventListener('click', async () => {
|
||||||
|
play.disabled = true;
|
||||||
|
const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id });
|
||||||
|
play.disabled = false;
|
||||||
|
if (r.error) showError(r.error);
|
||||||
|
else showSuccess(`Triggered check for source #${src.id}`);
|
||||||
|
});
|
||||||
|
row.appendChild(info); row.appendChild(play);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEventListeners() {
|
||||||
|
document.getElementById('export-all-btn').addEventListener('click', exportAllCookies);
|
||||||
|
document.getElementById('settings-btn').addEventListener('click', () => browser.runtime.openOptionsPage());
|
||||||
|
for (const tab of document.querySelectorAll('.tab')) {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
for (const t of document.querySelectorAll('.tab')) t.classList.remove('active');
|
||||||
|
tab.classList.add('active');
|
||||||
|
for (const p of document.querySelectorAll('.tab-panel')) p.classList.add('hidden');
|
||||||
|
document.getElementById(`tab-${tab.dataset.tab}`).classList.remove('hidden');
|
||||||
|
if (tab.dataset.tab === 'sources') loadSources();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(m) { showStatusMessage(m, 'success'); }
|
||||||
|
function showError(m) { showStatusMessage(m, 'error'); }
|
||||||
|
function showWarning(m) { showStatusMessage(m, 'warning'); }
|
||||||
|
function showStatusMessage(text, kind) {
|
||||||
|
const el = document.getElementById('status-message');
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = `status-message ${kind}`;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function hideStatusMessage() {
|
||||||
|
document.getElementById('status-message').classList.add('hidden');
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module.exports = {
|
||||||
|
sourceDir: '.',
|
||||||
|
artifactsDir: './web-ext-artifacts',
|
||||||
|
ignoreFiles: [
|
||||||
|
'package.json',
|
||||||
|
'package-lock.json',
|
||||||
|
'web-ext-config.cjs',
|
||||||
|
'web-ext-artifacts',
|
||||||
|
'node_modules',
|
||||||
|
'README.md',
|
||||||
|
'.gitignore',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -18,5 +18,11 @@ const route = useRoute()
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-content {
|
.fc-content {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
/* Push initial viewport content below the sticky TopNav. Without
|
||||||
|
this, some views' first rows / form fields / table headers can
|
||||||
|
end up obscured by the navbar (depending on parent overflow
|
||||||
|
context interacting with position: sticky). Scrolled-down content
|
||||||
|
still slides under the nav — the gradient-fade design is intact. */
|
||||||
|
padding-top: 64px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ const health = computed(() => {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
/* Both side cells use `flex: 1 1 0` — equal flex weight, basis 0 —
|
||||||
|
so they grow/shrink at the same rate regardless of which one has
|
||||||
|
content (brand vs. teleport-slot action button). The middle cell
|
||||||
|
is `flex: 0 0 auto` (content width), and because the side cells
|
||||||
|
are symmetric, the middle stays geometrically centered. Earlier
|
||||||
|
attempts: `flex: 1` defaults to basis 0%, which made the centered
|
||||||
|
links shift when actions appeared; `grid-template-columns: 1fr
|
||||||
|
auto 1fr` actually means `minmax(auto, 1fr)` so a wide brand
|
||||||
|
pushed the link block off-center on narrow viewports. */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -87,7 +96,7 @@ const health = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fc-links {
|
.fc-links {
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -114,10 +123,12 @@ const health = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fc-nav-left {
|
.fc-nav-left {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.fc-health {
|
.fc-health {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -125,9 +136,11 @@ const health = computed(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.fc-nav-actions {
|
.fc-nav-actions {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
ref="canvasEl"
|
||||||
class="fc-canvas"
|
class="fc-canvas"
|
||||||
:class="{ 'fc-canvas--zoomed': panZoom.state.scale > 1 }"
|
:class="{ 'fc-canvas--zoomed': panZoom.state.scale > 1 }"
|
||||||
@wheel="panZoom.handlers.onWheel"
|
@wheel="panZoom.handlers.onWheel"
|
||||||
@pointerdown="panZoom.handlers.onPointerDown"
|
@pointerdown="panZoom.handlers.onPointerDown"
|
||||||
@pointermove="panZoom.handlers.onPointerMove"
|
@pointermove="panZoom.handlers.onPointerMove"
|
||||||
@pointerup="panZoom.handlers.onPointerUp"
|
@pointerup="panZoom.handlers.onPointerUp"
|
||||||
@click="panZoom.handlers.onClick"
|
@click="onCanvasClick"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
ref="imgEl"
|
||||||
:src="src" :alt="alt"
|
:src="src" :alt="alt"
|
||||||
:style="{
|
:style="{
|
||||||
transform: `translate(${panZoom.state.x}px, ${panZoom.state.y}px) scale(${panZoom.state.scale})`
|
transform: `translate(${panZoom.state.x}px, ${panZoom.state.y}px) scale(${panZoom.state.scale})`
|
||||||
@@ -19,21 +21,51 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { usePanZoom } from '../../composables/usePanZoom.js'
|
import { usePanZoom } from '../../composables/usePanZoom.js'
|
||||||
|
|
||||||
const props = defineProps({ src: String, alt: String })
|
const props = defineProps({ src: String, alt: String })
|
||||||
|
const emit = defineEmits(['close-request'])
|
||||||
const panZoom = usePanZoom()
|
const panZoom = usePanZoom()
|
||||||
|
|
||||||
|
const canvasEl = ref(null)
|
||||||
|
const imgEl = ref(null)
|
||||||
|
|
||||||
// Reset zoom when the src changes (prev/next nav).
|
// Reset zoom when the src changes (prev/next nav).
|
||||||
watch(() => props.src, () => panZoom.reset())
|
watch(() => props.src, () => panZoom.reset())
|
||||||
|
|
||||||
|
// Click on the image → toggle zoom (IR/expected behavior). Click on
|
||||||
|
// the haze area around the image → request close. We use the image's
|
||||||
|
// own bounding rect rather than @click.self because the <img> sets
|
||||||
|
// pointer-events: none for the pan-drag pathway, so the click always
|
||||||
|
// targets the canvas div regardless of where the cursor was.
|
||||||
|
function onCanvasClick(ev) {
|
||||||
|
if (panZoom.state.scale > 1) {
|
||||||
|
// When zoomed, any click resets zoom — preserve old behavior.
|
||||||
|
panZoom.handlers.onClick(ev)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const img = imgEl.value
|
||||||
|
if (!img) return
|
||||||
|
const r = img.getBoundingClientRect()
|
||||||
|
const inside =
|
||||||
|
ev.clientX >= r.left && ev.clientX <= r.right &&
|
||||||
|
ev.clientY >= r.top && ev.clientY <= r.bottom
|
||||||
|
if (inside) {
|
||||||
|
panZoom.handlers.onClick(ev)
|
||||||
|
} else {
|
||||||
|
emit('close-request')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-canvas {
|
.fc-canvas {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: rgb(var(--v-theme-background));
|
/* Transparent so the viewer's haze shows through to the image area
|
||||||
|
instead of a solid panel sitting on top of it. */
|
||||||
|
background: transparent;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|||||||
@@ -39,9 +39,11 @@
|
|||||||
<template v-else-if="modal.current">
|
<template v-else-if="modal.current">
|
||||||
<ImageCanvas
|
<ImageCanvas
|
||||||
v-if="!isVideo" :src="modal.current.image_url" :alt="`Image ${modal.current.id}`"
|
v-if="!isVideo" :src="modal.current.image_url" :alt="`Image ${modal.current.id}`"
|
||||||
|
@close-request="$emit('close')"
|
||||||
/>
|
/>
|
||||||
<VideoCanvas
|
<VideoCanvas
|
||||||
v-else :src="modal.current.image_url" :mime="modal.current.mime"
|
v-else :src="modal.current.image_url" :mime="modal.current.mime"
|
||||||
|
@close-request="$emit('close')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<v-alert v-else-if="modal.error" type="error" variant="tonal">
|
<v-alert v-else-if="modal.error" type="error" variant="tonal">
|
||||||
@@ -123,7 +125,11 @@ function isTextEntry(el) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-viewer {
|
.fc-viewer {
|
||||||
position: fixed; inset: 0; z-index: 2000;
|
position: fixed; inset: 0; z-index: 2000;
|
||||||
background: rgba(20, 23, 26, 0.96);
|
/* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav,
|
||||||
|
mid-opacity + blur so the page behind shows through faintly. */
|
||||||
|
background: rgba(20, 23, 26, 0.65);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
outline: none;
|
outline: none;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -152,8 +158,12 @@ function isTextEntry(el) {
|
|||||||
flex: 1; display: flex; min-height: 0;
|
flex: 1; display: flex; min-height: 0;
|
||||||
}
|
}
|
||||||
.fc-viewer__media {
|
.fc-viewer__media {
|
||||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
/* Let the canvas fill us; the canvas does its own centering. Both
|
||||||
min-width: 0;
|
min-* are needed so this child can shrink inside its flex parents
|
||||||
|
(without them, max-height/max-width on the <img> have nothing to
|
||||||
|
bound against and the image overflows the viewport). */
|
||||||
|
flex: 1; display: flex;
|
||||||
|
min-width: 0; min-height: 0;
|
||||||
}
|
}
|
||||||
.fc-viewer__side {
|
.fc-viewer__side {
|
||||||
width: 320px; flex-shrink: 0;
|
width: 320px; flex-shrink: 0;
|
||||||
|
|||||||
@@ -43,6 +43,21 @@
|
|||||||
class="fc-prov__desc" v-html="e.post.description_html"
|
class="fc-prov__desc" v-html="e.post.description_html"
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- Fallback: image has artist_id set (folder-derived from
|
||||||
|
filesystem import or set by tag_apply) but no ImageProvenance
|
||||||
|
row exists. Show a minimal "by <artist>" line so the modal
|
||||||
|
isn't blank for IR-imported images that aren't in any post. -->
|
||||||
|
<article
|
||||||
|
v-if="showArtistFallback"
|
||||||
|
class="fc-prov__card"
|
||||||
|
>
|
||||||
|
<div class="fc-prov__meta">
|
||||||
|
<RouterLink :to="`/artist/${fallbackArtist.slug}`">
|
||||||
|
by {{ fallbackArtist.name }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="attachments.length" class="fc-prov__attach">
|
<div v-if="attachments.length" class="fc-prov__attach">
|
||||||
@@ -79,13 +94,27 @@ const state = computed(() =>
|
|||||||
modal.currentImageId == null ? null : prov.imageProv(modal.currentImageId)
|
modal.currentImageId == null ? null : prov.imageProv(modal.currentImageId)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show the panel while loading or on error, or when there is >=1 entry.
|
const fallbackArtist = computed(() => modal.current?.artist || null)
|
||||||
// Hide entirely when the image has zero provenance.
|
|
||||||
|
const showArtistFallback = computed(() => {
|
||||||
|
const st = state.value
|
||||||
|
// Only show the fallback when provenance is genuinely empty (no
|
||||||
|
// entries, not loading, no error) AND the image has a direct
|
||||||
|
// artist_id we can surface.
|
||||||
|
if (!st || st.loading || st.error) return false
|
||||||
|
if (Array.isArray(st.entries) && st.entries.length > 0) return false
|
||||||
|
return fallbackArtist.value != null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show the panel while loading or on error, when there is >=1 entry,
|
||||||
|
// or when the artist fallback applies (image_record.artist_id set but
|
||||||
|
// no ImageProvenance row, e.g. filesystem-imported IR images).
|
||||||
const show = computed(() => {
|
const show = computed(() => {
|
||||||
const st = state.value
|
const st = state.value
|
||||||
if (!st) return false
|
if (!st) return false
|
||||||
if (st.loading || st.error) return true
|
if (st.loading || st.error) return true
|
||||||
return Array.isArray(st.entries) && st.entries.length > 0
|
if (Array.isArray(st.entries) && st.entries.length > 0) return true
|
||||||
|
return showArtistFallback.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const attachments = computed(() => state.value?.attachments || [])
|
const attachments = computed(() => state.value?.attachments || [])
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-canvas fc-canvas--video">
|
<div class="fc-canvas fc-canvas--video" @click.self="$emit('close-request')">
|
||||||
<video
|
<video
|
||||||
v-if="playable" :src="src" controls playsinline preload="metadata"
|
v-if="playable" :src="src" controls playsinline preload="metadata"
|
||||||
class="fc-canvas__video"
|
class="fc-canvas__video"
|
||||||
|
@click.stop
|
||||||
/>
|
/>
|
||||||
<div v-else class="fc-canvas__unsupported">
|
<div v-else class="fc-canvas__unsupported" @click.stop>
|
||||||
<v-icon size="56" icon="mdi-video-off-outline" />
|
<v-icon size="56" icon="mdi-video-off-outline" />
|
||||||
<h3 class="fc-canvas__unsupported-title">Format not browser-playable</h3>
|
<h3 class="fc-canvas__unsupported-title">Format not browser-playable</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
defineEmits(['close-request'])
|
||||||
const props = defineProps({ src: String, mime: String })
|
const props = defineProps({ src: String, mime: String })
|
||||||
|
|
||||||
const BROWSER_PLAYABLE = new Set([
|
const BROWSER_PLAYABLE = new Set([
|
||||||
@@ -34,7 +36,9 @@ const playable = computed(() => BROWSER_PLAYABLE.has(props.mime))
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-canvas {
|
.fc-canvas {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: rgb(var(--v-theme-background));
|
/* Transparent so the viewer's haze shows through (parallels
|
||||||
|
ImageCanvas — both sit inside ImageViewer's blurred backdrop). */
|
||||||
|
background: transparent;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
.fc-canvas__video { max-width: 100%; max-height: 100%; }
|
.fc-canvas__video { max-width: 100%; max-height: 100%; }
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="fc-ext-card">
|
||||||
|
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||||||
|
<v-icon icon="mdi-puzzle" size="small" />
|
||||||
|
<span>Browser extension</span>
|
||||||
|
<span v-if="manifest?.installed" class="text-caption fc-muted">
|
||||||
|
· Firefox · v{{ manifest.version }}
|
||||||
|
</span>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text>
|
||||||
|
<p class="fc-muted text-body-2">
|
||||||
|
Pushes session cookies from supported platforms
|
||||||
|
(patreon, subscribestar, hentaifoundry, discord, pixiv, deviantart)
|
||||||
|
into FabledCurator, and lets you add a creator as a source from
|
||||||
|
their page in one click.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="manifestError"
|
||||||
|
type="warning" variant="tonal" density="compact" class="mt-3"
|
||||||
|
>
|
||||||
|
Could not load extension manifest: {{ manifestError }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-else-if="manifest && !manifest.installed"
|
||||||
|
type="warning" variant="tonal" density="compact" class="mt-3"
|
||||||
|
>
|
||||||
|
No bundled extension found in this image. Push a release that
|
||||||
|
runs the extension CI workflow, or grab the XPI from the
|
||||||
|
FabledCurator Forgejo releases page.
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<template v-else-if="manifest?.installed">
|
||||||
|
<div class="fc-ext-install mt-3">
|
||||||
|
<v-btn
|
||||||
|
v-if="isFirefox"
|
||||||
|
color="accent" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-firefox"
|
||||||
|
@click="installXpi"
|
||||||
|
>Install Firefox extension</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
variant="outlined" rounded="pill"
|
||||||
|
:href="manifest.latest_url" download
|
||||||
|
prepend-icon="mdi-download"
|
||||||
|
>Download XPI</v-btn>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="!isFirefox"
|
||||||
|
type="info" variant="tonal" density="compact" class="mt-3"
|
||||||
|
>
|
||||||
|
Open this page in Firefox to install in one click, or use
|
||||||
|
"Download XPI" to install manually.
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
|
||||||
|
<div class="fc-muted text-body-2 mb-3">
|
||||||
|
After installing, open the extension's options page
|
||||||
|
(about:addons → FabledCurator → Preferences) and paste these:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
label="FC base URL" :model-value="apiUrl"
|
||||||
|
readonly density="compact" hide-details
|
||||||
|
append-inner-icon="mdi-content-copy"
|
||||||
|
@click:append-inner="copy(apiUrl, 'URL')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
label="Extension API key"
|
||||||
|
:model-value="apiKey"
|
||||||
|
:type="keyShown ? 'text' : 'password'"
|
||||||
|
readonly density="compact" hide-details class="mt-3"
|
||||||
|
>
|
||||||
|
<template #append-inner>
|
||||||
|
<v-btn
|
||||||
|
variant="text" density="compact" size="small"
|
||||||
|
:icon="keyShown ? 'mdi-eye-off' : 'mdi-eye'"
|
||||||
|
@click="keyShown = !keyShown"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
variant="text" density="compact" size="small"
|
||||||
|
icon="mdi-content-copy"
|
||||||
|
@click="copy(apiKey, 'API key')"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
variant="text" density="compact" size="small"
|
||||||
|
icon="mdi-refresh" color="warning"
|
||||||
|
:loading="rotating"
|
||||||
|
@click="rotateKey"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
</template>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useApi } from '../../composables/useApi.js'
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const manifest = ref(null)
|
||||||
|
const manifestError = ref(null)
|
||||||
|
const apiKey = ref('')
|
||||||
|
const keyShown = ref(false)
|
||||||
|
const rotating = ref(false)
|
||||||
|
|
||||||
|
const apiUrl = computed(() => `${window.location.origin}/api`)
|
||||||
|
const isFirefox = computed(() => navigator.userAgent.includes('Firefox'))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadManifest(), loadKey()])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadManifest() {
|
||||||
|
try {
|
||||||
|
manifest.value = await api.get('/api/extension/manifest')
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404) {
|
||||||
|
// Backend says no XPI is bundled — surface the not-installed
|
||||||
|
// state, not an error.
|
||||||
|
manifest.value = { installed: false }
|
||||||
|
} else {
|
||||||
|
manifestError.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKey() {
|
||||||
|
try {
|
||||||
|
const { key } = await api.get('/api/settings/extension_api_key')
|
||||||
|
apiKey.value = key
|
||||||
|
} catch (e) {
|
||||||
|
apiKey.value = ''
|
||||||
|
window.__fcToast?.({
|
||||||
|
text: `Failed to load extension API key: ${e.message}`,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installXpi() {
|
||||||
|
if (!manifest.value?.latest_url) return
|
||||||
|
window.location.assign(manifest.value.latest_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateKey() {
|
||||||
|
rotating.value = true
|
||||||
|
try {
|
||||||
|
const { key } = await api.post('/api/settings/extension_api_key/rotate')
|
||||||
|
apiKey.value = key
|
||||||
|
keyShown.value = true
|
||||||
|
window.__fcToast?.({ text: 'Extension API key rotated.', type: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({
|
||||||
|
text: `Rotate failed: ${e.message}`,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
rotating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(text, label) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
window.__fcToast?.({ text: `${label} copied.`, type: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
window.__fcToast?.({ text: `Copy failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-ext-card { border-radius: 8px; }
|
||||||
|
.fc-ext-install {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px;
|
||||||
|
}
|
||||||
|
.fc-muted {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<MLThresholdSliders class="mt-4" />
|
<MLThresholdSliders class="mt-4" />
|
||||||
<AllowlistTable class="mt-4" />
|
<AllowlistTable class="mt-4" />
|
||||||
<AliasTable class="mt-4" />
|
<AliasTable class="mt-4" />
|
||||||
|
<BrowserExtensionCard class="mt-6" />
|
||||||
<LegacyMigrationCard class="mt-6" />
|
<LegacyMigrationCard class="mt-6" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,6 +23,7 @@ import CentroidRecomputeCard from './CentroidRecomputeCard.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 BrowserExtensionCard from './BrowserExtensionCard.vue'
|
||||||
import LegacyMigrationCard from './LegacyMigrationCard.vue'
|
import LegacyMigrationCard from './LegacyMigrationCard.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,16 @@ async function request(method, url, { body, params, signal } = {}) {
|
|||||||
const headers = {}
|
const headers = {}
|
||||||
const init = { method, headers, signal }
|
const init = { method, headers, signal }
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
headers['Content-Type'] = 'application/json'
|
if (body instanceof FormData) {
|
||||||
init.body = JSON.stringify(body)
|
// Let the browser set Content-Type with the multipart boundary.
|
||||||
|
// JSON-stringifying FormData produces "{}" and the backend sees
|
||||||
|
// an empty multipart payload (this broke FC-5 IR/GS ingest from
|
||||||
|
// the UI for several releases).
|
||||||
|
init.body = body
|
||||||
|
} else {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
init.body = JSON.stringify(body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = await fetch(fullUrl, init)
|
const r = await fetch(fullUrl, init)
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ function startPolling() {
|
|||||||
pollId = setInterval(() => {
|
pollId = setInterval(() => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
system.refreshStats()
|
system.refreshStats()
|
||||||
if (tab.value === 'import') importStore.refreshStatus()
|
if (tab.value === 'import') {
|
||||||
|
importStore.refreshStatus()
|
||||||
|
// Refresh the task list while a batch is in flight so the UI
|
||||||
|
// doesn't sit stale next to a ticking imported/skipped counter.
|
||||||
|
if (importStore.activeBatch) importStore.loadTasks(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""FC-3g: /api/extension and /extension/<filename> integration tests."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from backend.app import create_app
|
||||||
|
from backend.app import frontend as frontend_module
|
||||||
|
from backend.app.api import extension as extension_module
|
||||||
|
from backend.app.models import AppSetting, Artist, Source
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def app():
|
||||||
|
return create_app()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(app):
|
||||||
|
async with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def ext_key(db):
|
||||||
|
db.add(AppSetting(key="extension_api_key", value="test-ext-key"))
|
||||||
|
await db.commit()
|
||||||
|
return "test-ext-key"
|
||||||
|
|
||||||
|
|
||||||
|
# --- /api/extension/quick-add-source ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quick_add_source_creates_artist_and_source(client, ext_key, db_sync):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
json={"url": "https://www.patreon.com/maewix"},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["created_artist"] is True
|
||||||
|
assert body["created_source"] is True
|
||||||
|
assert body["artist"]["slug"] == "maewix"
|
||||||
|
assert body["source"]["platform"] == "patreon"
|
||||||
|
assert body["source"]["enabled"] is True
|
||||||
|
|
||||||
|
# Async Core-DML assertion: column select, not ORM attribute access.
|
||||||
|
artist_count = db_sync.execute(
|
||||||
|
select(func.count(Artist.id)).where(Artist.slug == "maewix")
|
||||||
|
).scalar_one()
|
||||||
|
assert artist_count == 1
|
||||||
|
source_count = db_sync.execute(
|
||||||
|
select(func.count(Source.id)).where(
|
||||||
|
Source.platform == "patreon",
|
||||||
|
Source.url == "https://www.patreon.com/maewix",
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert source_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quick_add_source_idempotent(client, ext_key):
|
||||||
|
body = {"url": "https://www.subscribestar.com/some-creator"}
|
||||||
|
headers = {"X-Extension-Key": ext_key}
|
||||||
|
r1 = await client.post("/api/extension/quick-add-source", json=body, headers=headers)
|
||||||
|
r2 = await client.post("/api/extension/quick-add-source", json=body, headers=headers)
|
||||||
|
assert r1.status_code == 201
|
||||||
|
assert r2.status_code == 200
|
||||||
|
body2 = await r2.get_json()
|
||||||
|
assert body2["created_source"] is False
|
||||||
|
assert body2["created_artist"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url,platform,slug", [
|
||||||
|
("https://www.patreon.com/maewix", "patreon", "maewix"),
|
||||||
|
("https://patreon.com/maewix", "patreon", "maewix"),
|
||||||
|
("https://www.subscribestar.com/foobar", "subscribestar", "foobar"),
|
||||||
|
("https://subscribestar.adult/foobar", "subscribestar", "foobar"),
|
||||||
|
("https://www.hentai-foundry.com/user/Foo/profile", "hentaifoundry", "Foo"),
|
||||||
|
("https://www.deviantart.com/baz", "deviantart", "baz"),
|
||||||
|
("https://www.pixiv.net/users/12345", "pixiv", "12345"),
|
||||||
|
("https://www.pixiv.net/en/users/12345", "pixiv", "12345"),
|
||||||
|
])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quick_add_source_url_patterns(client, ext_key, url, platform, slug):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
json={"url": url},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, await resp.get_json()
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["source"]["platform"] == platform
|
||||||
|
# slugify lowercases — the Artist slug should reflect that.
|
||||||
|
assert body["artist"]["slug"] == slug.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quick_add_source_unknown_url_400(client, ext_key):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
json={"url": "https://example.com/foo"},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["error"] == "unknown_platform"
|
||||||
|
assert "known" in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quick_add_source_invalid_url_400(client, ext_key):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
json={"url": "not-a-url"},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["error"] == "invalid_url"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quick_add_source_missing_key_401(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
json={"url": "https://www.patreon.com/maewix"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["error"] == "unauthorized"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quick_add_source_wrong_key_401(client, ext_key):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
json={"url": "https://www.patreon.com/maewix"},
|
||||||
|
headers={"X-Extension-Key": "wrong-key"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quick_add_source_missing_body_400(client, ext_key):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["error"] == "invalid_body"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quick_add_source_attaches_to_existing_artist(client, ext_key, db, db_sync):
|
||||||
|
db.add(Artist(name="Maewix Original", slug="maewix", is_subscription=False))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/extension/quick-add-source",
|
||||||
|
json={"url": "https://www.patreon.com/maewix"},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["created_artist"] is False
|
||||||
|
assert body["created_source"] is True
|
||||||
|
|
||||||
|
artist_count = db_sync.execute(
|
||||||
|
select(func.count(Artist.id)).where(Artist.slug == "maewix")
|
||||||
|
).scalar_one()
|
||||||
|
assert artist_count == 1 # no duplicate created
|
||||||
|
|
||||||
|
|
||||||
|
# --- /api/extension/manifest ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extension_manifest_returns_404_when_dir_missing(client, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path / "does-not-exist")
|
||||||
|
resp = await client.get("/api/extension/manifest")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body == {"installed": False}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extension_manifest_returns_404_when_no_xpi_files(client, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path)
|
||||||
|
resp = await client.get("/api/extension/manifest")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extension_manifest_returns_metadata_when_xpi_present(client, monkeypatch, tmp_path):
|
||||||
|
xpi = tmp_path / "fabledcurator-1.2.3.xpi"
|
||||||
|
xpi.write_bytes(b"fake-xpi-content")
|
||||||
|
monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path)
|
||||||
|
resp = await client.get("/api/extension/manifest")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["installed"] is True
|
||||||
|
assert body["version"] == "1.2.3"
|
||||||
|
assert body["xpi_url"] == "/extension/fabledcurator-1.2.3.xpi"
|
||||||
|
assert body["latest_url"] == "/extension/fabledcurator-latest.xpi"
|
||||||
|
assert body["sha256"] == hashlib.sha256(b"fake-xpi-content").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# --- /extension/<filename> -----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_serve_extension_rejects_non_xpi_filename(client, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
||||||
|
resp = await client.get("/extension/passwd")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_serve_extension_rejects_path_traversal(client, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
||||||
|
# Path-traversal attempt — the route regex alone catches this since
|
||||||
|
# `..` characters aren't in [\w.-]+, but cover the case anyway.
|
||||||
|
resp = await client.get("/extension/fabledcurator-..%2Fetc%2Fpasswd.xpi")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_serve_extension_404_when_xpi_missing(client, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
||||||
|
resp = await client.get("/extension/fabledcurator-1.0.0.xpi")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_serve_extension_serves_specific_xpi_with_correct_mime(
|
||||||
|
client, monkeypatch, tmp_path,
|
||||||
|
):
|
||||||
|
xpi = tmp_path / "fabledcurator-1.0.0.xpi"
|
||||||
|
xpi.write_bytes(b"xpi-bytes")
|
||||||
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
||||||
|
resp = await client.get("/extension/fabledcurator-1.0.0.xpi")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["Content-Type"].startswith("application/x-xpinstall")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_serve_extension_latest_returns_most_recent_xpi(
|
||||||
|
client, monkeypatch, tmp_path,
|
||||||
|
):
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
older = tmp_path / "fabledcurator-1.0.0.xpi"
|
||||||
|
newer = tmp_path / "fabledcurator-1.0.1.xpi"
|
||||||
|
older.write_bytes(b"old")
|
||||||
|
newer.write_bytes(b"new")
|
||||||
|
os.utime(older, (time.time() - 10, time.time() - 10))
|
||||||
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
||||||
|
resp = await client.get("/extension/fabledcurator-latest.xpi")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = await resp.get_data()
|
||||||
|
assert data == b"new"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_serve_extension_latest_404_when_dir_empty(client, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
||||||
|
resp = await client.get("/extension/fabledcurator-latest.xpi")
|
||||||
|
assert resp.status_code == 404
|
||||||
@@ -30,9 +30,14 @@ def test_recover_interrupted_only_old(db_sync, monkeypatch):
|
|||||||
batch_id = _make_batch(db_sync)
|
batch_id = _make_batch(db_sync)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
# "Fresh" must sit comfortably under whatever STUCK_THRESHOLD_MINUTES
|
||||||
|
# currently is (5 min as of 2026-05-24, tightened from 30); 30
|
||||||
|
# seconds is well below any reasonable threshold. "Stale" stays at
|
||||||
|
# 2 hours so the test remains valid if the threshold ever moves
|
||||||
|
# back up.
|
||||||
fresh = ImportTask(
|
fresh = ImportTask(
|
||||||
batch_id=batch_id, source_path="/import/a.jpg", task_type="media",
|
batch_id=batch_id, source_path="/import/a.jpg", task_type="media",
|
||||||
status="processing", started_at=now - timedelta(minutes=5),
|
status="processing", started_at=now - timedelta(seconds=30),
|
||||||
)
|
)
|
||||||
stale = ImportTask(
|
stale = ImportTask(
|
||||||
batch_id=batch_id, source_path="/import/b.jpg", task_type="media",
|
batch_id=batch_id, source_path="/import/b.jpg", task_type="media",
|
||||||
|
|||||||
+146
-4
@@ -2,9 +2,19 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from backend.app.models import Artist, ImageRecord, SeriesPage, Tag, TagKind, image_tag
|
from backend.app.models import (
|
||||||
|
Artist,
|
||||||
|
ImageProvenance,
|
||||||
|
ImageRecord,
|
||||||
|
Post,
|
||||||
|
SeriesPage,
|
||||||
|
Source,
|
||||||
|
Tag,
|
||||||
|
TagKind,
|
||||||
|
image_tag,
|
||||||
|
)
|
||||||
from backend.app.services.migrators import tag_apply
|
from backend.app.services.migrators import tag_apply
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
@@ -21,14 +31,19 @@ async def _seed_image(db, sha: str, *, suffix="x"):
|
|||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
def _write_manifest(tmp_path, *, artist_assignments=None, tag_associations=None, series_pages=None):
|
def _write_manifest(
|
||||||
|
tmp_path, *,
|
||||||
|
artist_assignments=None, tag_associations=None,
|
||||||
|
series_pages=None, image_posts=None, schema_version=2,
|
||||||
|
):
|
||||||
d = tmp_path / "_migration_state"
|
d = tmp_path / "_migration_state"
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
(d / "ir_tag_manifest.json").write_text(json.dumps({
|
(d / "ir_tag_manifest.json").write_text(json.dumps({
|
||||||
"schema_version": 1,
|
"schema_version": schema_version,
|
||||||
"image_artist_assignments": artist_assignments or [],
|
"image_artist_assignments": artist_assignments or [],
|
||||||
"image_tag_associations": tag_associations or [],
|
"image_tag_associations": tag_associations or [],
|
||||||
"series_pages": series_pages or [],
|
"series_pages": series_pages or [],
|
||||||
|
"image_posts": image_posts or [],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
@@ -154,3 +169,130 @@ async def test_dry_run_makes_no_changes(db, tmp_path):
|
|||||||
select(image_tag.c.tag_id).where(image_tag.c.image_record_id == img_id)
|
select(image_tag.c.tag_id).where(image_tag.c.image_record_id == img_id)
|
||||||
)).all()
|
)).all()
|
||||||
assert len(rows) == 0
|
assert len(rows) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Phase 4: image_posts (schema v2) -----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_POST_ENTRY = {
|
||||||
|
"platform": "patreon",
|
||||||
|
"post_id": "10001",
|
||||||
|
"artist": "Maewix",
|
||||||
|
"title": "Test Post Title",
|
||||||
|
"description": "<p>HTML description</p>",
|
||||||
|
"source_url": "https://www.patreon.com/posts/test-10001",
|
||||||
|
"attachment_count": 1,
|
||||||
|
"published_at": "2026-01-15T12:00:00+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_posts_creates_source_post_provenance(db, tmp_path):
|
||||||
|
sha = "d" * 64
|
||||||
|
await _seed_image(db, sha, suffix="d")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
_write_manifest(tmp_path, image_posts=[
|
||||||
|
{**_POST_ENTRY, "image_sha256s": [sha]},
|
||||||
|
])
|
||||||
|
|
||||||
|
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
|
||||||
|
assert result["counts"]["rows_inserted"] >= 1
|
||||||
|
|
||||||
|
# Async Core-DML assertions — column selects, not ORM attribute access.
|
||||||
|
artist_count = (await db.execute(
|
||||||
|
select(func.count(Artist.id)).where(Artist.slug == "maewix")
|
||||||
|
)).scalar_one()
|
||||||
|
assert artist_count == 1
|
||||||
|
|
||||||
|
source_count = (await db.execute(
|
||||||
|
select(func.count(Source.id)).where(
|
||||||
|
Source.platform == "patreon",
|
||||||
|
Source.url == "https://www.patreon.com/maewix",
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
assert source_count == 1
|
||||||
|
|
||||||
|
post_count = (await db.execute(
|
||||||
|
select(func.count(Post.id)).where(Post.external_post_id == "10001")
|
||||||
|
)).scalar_one()
|
||||||
|
assert post_count == 1
|
||||||
|
|
||||||
|
img_id = (await db.execute(
|
||||||
|
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
|
||||||
|
)).scalar_one()
|
||||||
|
prov_count = (await db.execute(
|
||||||
|
select(func.count(ImageProvenance.id))
|
||||||
|
.where(ImageProvenance.image_record_id == img_id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert prov_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_posts_idempotent_on_rerun(db, tmp_path):
|
||||||
|
sha = "e" * 64
|
||||||
|
await _seed_image(db, sha, suffix="e")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
_write_manifest(tmp_path, image_posts=[
|
||||||
|
{**_POST_ENTRY, "image_sha256s": [sha]},
|
||||||
|
])
|
||||||
|
|
||||||
|
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
|
||||||
|
result2 = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
|
||||||
|
|
||||||
|
# Re-run: nothing should be newly inserted; all skipped.
|
||||||
|
assert result2["counts"]["rows_inserted"] == 0
|
||||||
|
assert result2["counts"]["rows_skipped"] >= 1
|
||||||
|
|
||||||
|
prov_count = (await db.execute(
|
||||||
|
select(func.count(ImageProvenance.id))
|
||||||
|
)).scalar_one()
|
||||||
|
assert prov_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_posts_unknown_sha_unmatched(db, tmp_path):
|
||||||
|
# No image seeded; sha doesn't exist.
|
||||||
|
_write_manifest(tmp_path, image_posts=[
|
||||||
|
{**_POST_ENTRY, "image_sha256s": ["f" * 64]},
|
||||||
|
])
|
||||||
|
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
|
||||||
|
unmatched_kinds = [u["kind"] for u in result["unmatched"]]
|
||||||
|
assert "post" in unmatched_kinds
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_posts_unknown_platform_skipped(db, tmp_path):
|
||||||
|
sha = "1" * 64
|
||||||
|
await _seed_image(db, sha, suffix="1")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
_write_manifest(tmp_path, image_posts=[
|
||||||
|
{**_POST_ENTRY, "platform": "tumblr", "image_sha256s": [sha]},
|
||||||
|
])
|
||||||
|
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
|
||||||
|
assert result["counts"]["rows_skipped"] >= 1
|
||||||
|
|
||||||
|
# No Post / Source / ImageProvenance should have been created.
|
||||||
|
src_count = (await db.execute(
|
||||||
|
select(func.count(Source.id)).where(Source.platform == "tumblr")
|
||||||
|
)).scalar_one()
|
||||||
|
assert src_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_posts_dry_run_makes_no_writes(db, tmp_path):
|
||||||
|
sha = "2" * 64
|
||||||
|
await _seed_image(db, sha, suffix="2")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
_write_manifest(tmp_path, image_posts=[
|
||||||
|
{**_POST_ENTRY, "image_sha256s": [sha]},
|
||||||
|
])
|
||||||
|
|
||||||
|
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=True)
|
||||||
|
prov_count = (await db.execute(
|
||||||
|
select(func.count(ImageProvenance.id))
|
||||||
|
)).scalar_one()
|
||||||
|
assert prov_count == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user