Merge pull request 'Merge dev → main: #830 rich post capture + external-host downloads (+ #768/#789/#739)' (#102) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 48s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m47s
CI / integration (push) Successful in 3m20s

This commit was merged in pull request #102.
This commit is contained in:
2026-06-14 19:16:09 -04:00
40 changed files with 2677 additions and 75 deletions
+4 -1
View File
@@ -18,13 +18,16 @@ ENV PYTHONUNBUFFERED=1 \
# System deps: ffmpeg (transcode + thumbnails, FC-2), unar (archives, FC-2),
# libpq for psycopg, postgresql-client + zstd for FC-5 backup/restore
# (pg_dump + tar --zstd), image libs.
# (pg_dump + tar --zstd), image libs, megatools (mega.nz public-link downloads
# for off-platform file-host links, #830 — `megatools dl`; Debian-native, no
# external MEGA apt repo needed).
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
unar \
libpq5 \
postgresql-client \
zstd \
megatools \
libjpeg62-turbo \
libwebp7 \
libpng16-16 \
@@ -0,0 +1,90 @@
"""external_link table — off-platform file-host links found in post bodies
Creators host the real files on mega.nz / Google Drive / MediaFire / Dropbox /
Pixeldrain and link them in the post text. This table records each such link
(so nothing is silently dropped), and doubles as the dedup + dead-letter ledger
the download worker (a later slice) walks. `url` keeps the FULL link including
the `#fragment` — mega.nz's decryption key lives there; truncating it makes the
file undownloadable.
CHECK whitelists for host + status include the full enum up front (incl. the
download-worker statuses) so the worker slice needs no constraint migration.
Revision ID: 0049
Revises: 0048
Create Date: 2026-06-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0049"
down_revision: Union[str, None] = "0048"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"external_link",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"post_id", sa.Integer(),
sa.ForeignKey("post.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"artist_id", sa.Integer(),
sa.ForeignKey("artist.id", ondelete="SET NULL"), nullable=True,
),
sa.Column("host", sa.String(length=16), nullable=False),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("label", sa.Text(), nullable=True),
sa.Column(
"status", sa.String(length=16), nullable=False,
server_default="pending",
),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column(
"attachment_id", sa.Integer(),
sa.ForeignKey("post_attachment.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("duration_seconds", sa.Float(), nullable=True),
sa.CheckConstraint(
"host IN ('mega','gdrive','mediafire','dropbox','pixeldrain')",
name="ck_external_link_host",
),
sa.CheckConstraint(
"status IN ('pending','downloading','downloaded','failed',"
"'skipped','dead')",
name="ck_external_link_status",
),
)
op.create_index(
"ix_external_link_post_id", "external_link", ["post_id"],
)
op.create_index(
"ix_external_link_artist_id", "external_link", ["artist_id"],
)
op.create_index(
"ix_external_link_status", "external_link", ["status"],
)
op.create_index(
"uq_external_link_post_url", "external_link", ["post_id", "url"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_external_link_post_url", table_name="external_link")
op.drop_index("ix_external_link_status", table_name="external_link")
op.drop_index("ix_external_link_artist_id", table_name="external_link")
op.drop_index("ix_external_link_post_id", table_name="external_link")
op.drop_table("external_link")
@@ -0,0 +1,38 @@
"""import_settings: per-host enable toggles for external file-host downloads
Operator levers (#830): disable a single host (e.g. mega.nz when it's
rate-limiting/banning) without touching the others. The worker reads these via
getattr and defaults to enabled, so the toggles default TRUE (works out of the
box, rule #26).
Revision ID: 0050
Revises: 0049
Create Date: 2026-06-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0050"
down_revision: Union[str, None] = "0049"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_HOSTS = ("mega", "gdrive", "mediafire", "dropbox", "pixeldrain")
def upgrade() -> None:
for host in _HOSTS:
op.add_column(
"import_settings",
sa.Column(
f"extdl_{host}_enabled", sa.Boolean(), nullable=False,
server_default=sa.true(),
),
)
def downgrade() -> None:
for host in _HOSTS:
op.drop_column("import_settings", f"extdl_{host}_enabled")
@@ -0,0 +1,38 @@
"""image_record: source_url + source_filehash (inline-image localization)
#830 Phase 2. To render a post body faithfully we serve LOCAL copies of inline
images instead of hotlinking the public CDN. The join key between a body
`<img src=CDN>` and the local file is the CDN's 32-hex filehash (the same
identity extract_media dedups by). Persist it (indexed) plus the full source
URL for provenance/debugging. Both NULL for filesystem-imported / pre-existing
rows — those fall back to hotlinking until re-downloaded.
Revision ID: 0051
Revises: 0050
Create Date: 2026-06-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0051"
down_revision: Union[str, None] = "0050"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("image_record", sa.Column("source_url", sa.Text(), nullable=True))
op.add_column(
"image_record", sa.Column("source_filehash", sa.String(length=32), nullable=True)
)
op.create_index(
"ix_image_record_source_filehash", "image_record", ["source_filehash"]
)
def downgrade() -> None:
op.drop_index("ix_image_record_source_filehash", table_name="image_record")
op.drop_column("image_record", "source_filehash")
op.drop_column("image_record", "source_url")
+22
View File
@@ -27,6 +27,20 @@ _EDITABLE_FIELDS = (
"download_failure_warning_threshold",
"series_suggest_enabled",
"series_suggest_threshold",
"extdl_mega_enabled",
"extdl_gdrive_enabled",
"extdl_mediafire_enabled",
"extdl_dropbox_enabled",
"extdl_pixeldrain_enabled",
)
# Per-host external-download toggles — all plain booleans, validated uniformly.
_EXTDL_TOGGLE_FIELDS = (
"extdl_mega_enabled",
"extdl_gdrive_enabled",
"extdl_mediafire_enabled",
"extdl_dropbox_enabled",
"extdl_pixeldrain_enabled",
)
@@ -50,6 +64,11 @@ async def get_import_settings():
"download_failure_warning_threshold": row.download_failure_warning_threshold,
"series_suggest_enabled": row.series_suggest_enabled,
"series_suggest_threshold": row.series_suggest_threshold,
"extdl_mega_enabled": row.extdl_mega_enabled,
"extdl_gdrive_enabled": row.extdl_gdrive_enabled,
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled,
})
@@ -106,6 +125,9 @@ async def update_import_settings():
return jsonify(
{"error": "series_suggest_enabled must be a boolean"}
), 400
for tog in _EXTDL_TOGGLE_FIELDS:
if tog in body and not isinstance(body[tog], bool):
return jsonify({"error": f"{tog} must be a boolean"}), 400
if "series_suggest_threshold" in body:
v = body["series_suggest_threshold"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
+19
View File
@@ -30,6 +30,7 @@ def make_celery() -> Celery:
"backend.app.tasks.maintenance",
"backend.app.tasks.ml",
"backend.app.tasks.download",
"backend.app.tasks.external",
"backend.app.tasks.backup",
"backend.app.tasks.admin",
"backend.app.tasks.library_audit",
@@ -42,6 +43,9 @@ def make_celery() -> Celery:
"backend.app.tasks.ml.*": {"queue": "ml"},
"backend.app.tasks.thumbnail.*": {"queue": "thumbnail"},
"backend.app.tasks.download.*": {"queue": "download"},
# External file-host fetches are downloads — same lane (they can run
# long, but the download worker already tolerates long backfills).
"backend.app.tasks.external.*": {"queue": "download"},
"backend.app.tasks.scan.*": {"queue": "scan"},
# `maintenance` is the QUICK lane — recovery sweeps, vacuum, cleanup
# (concurrency-1 on the scheduler). The long one-shots (DB backups,
@@ -150,6 +154,21 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.thumbnail.backfill_thumbnails",
"schedule": 86400.0,
},
# External file-host downloads (#830): a steady sweep catches links
# the post-download hook missed (worker down, etc.); recovery re-tries
# dead links daily; retention prunes long-dead rows.
"extdl-sweep": {
"task": "backend.app.tasks.external.sweep_external_links",
"schedule": 600.0, # every 10 min
},
"extdl-recover-daily": {
"task": "backend.app.tasks.external.recover_external_links",
"schedule": 86400.0,
},
"extdl-prune-daily": {
"task": "backend.app.tasks.external.prune_external_links",
"schedule": 86400.0,
},
},
timezone="UTC",
)
+2
View File
@@ -7,6 +7,7 @@ from .backup_run import BackupRun
from .base import Base
from .credential import Credential
from .download_event import DownloadEvent
from .external_link import ExternalLink
from .image_prediction import ImagePrediction
from .image_provenance import ImageProvenance
from .image_record import ImageRecord
@@ -52,6 +53,7 @@ __all__ = [
"TagKind",
"image_tag",
"DownloadEvent",
"ExternalLink",
"ImportBatch",
"ImportTask",
"ImportSettings",
+73
View File
@@ -0,0 +1,73 @@
"""ExternalLink — an off-platform file-host link found in a post body.
Creators host the actual files (films, packs) on mega.nz / Google Drive /
MediaFire / Dropbox / Pixeldrain and drop the link in the post text. This row
is the record that the link existed (so nothing is silently dropped), the
dedup + dead-letter ledger for fetching it, and the driver the download worker
walks. `url` keeps the FULL link including the `#fragment` (mega's decryption
key) — truncating it makes the file undownloadable.
status lifecycle: pending → downloading → downloaded | failed | dead
(too many attempts) | skipped (host disabled). `attachment_id` links the
captured file once a download lands (SET NULL so deleting the attachment
doesn't delete the link record).
"""
from datetime import datetime
from sqlalchemy import (
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
# Kept in sync with link_extract.SUPPORTED_HOSTS and the CHECK in migration 0049.
HOSTS = ("mega", "gdrive", "mediafire", "dropbox", "pixeldrain")
STATUSES = ("pending", "downloading", "downloaded", "failed", "skipped", "dead")
class ExternalLink(Base):
__tablename__ = "external_link"
__table_args__ = (
# One row per (post, url). The full url (incl. #fragment) is the identity
# — the same file linked twice in a post collapses to one row.
Index("uq_external_link_post_url", "post_id", "url", unique=True),
Index("ix_external_link_status", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
post_id: Mapped[int] = mapped_column(
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
)
artist_id: Mapped[int | None] = mapped_column(
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
)
host: Mapped[str] = mapped_column(String(16), nullable=False)
url: Mapped[str] = mapped_column(Text, nullable=False)
label: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(16), nullable=False, server_default="pending"
)
attempts: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("0")
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
attachment_id: Mapped[int | None] = mapped_column(
ForeignKey("post_attachment.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
+12
View File
@@ -49,6 +49,18 @@ class ImageRecord(Base):
# Thumbnail (populated by FC-2)
thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True)
# Source provenance for downloaded media (#830 Phase 2). `source_url` is the
# CDN/origin URL the file was fetched from (debugging + future re-fetch).
# `source_filehash` is the URL's 32-hex CDN identity segment
# (utils.paths.filehash_from_url) — the JOIN KEY that maps a post body's
# inline `<img src=CDN>` back to this local copy so the rendered body serves
# our stored image instead of hotlinking the public source. Indexed for the
# render-time lookup. NULL for filesystem-imported / pre-Phase-2 rows.
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
source_filehash: Mapped[str | None] = mapped_column(
String(32), nullable=True, index=True
)
# Origin / provenance pointers
origin: Mapped[str] = mapped_column(Enum(*ORIGIN_CHOICES, name="origin_enum"), nullable=False)
primary_post_id: Mapped[int | None] = mapped_column(
+19
View File
@@ -73,6 +73,25 @@ class ImportSettings(Base):
Float, nullable=False, default=0.5,
)
# #830 off-platform file-host downloads — per-host enable lever (default on,
# rule #26). Column names are extdl_<host>_enabled so the worker reads them
# via getattr(settings, f"extdl_{host}_enabled", True).
extdl_mega_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_gdrive_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_mediafire_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_dropbox_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_pixeldrain_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
@classmethod
async def load(cls, session) -> ImportSettings:
"""The singleton settings row (id=1), via an async session."""
+23
View File
@@ -380,6 +380,29 @@ class DownloadService:
else:
import_summary["errors"] += 1
# Post-only records: media-less posts (pure-text) the native ingester
# captured so the artist archive is complete. Upsert each (keyed on
# external_post_id → updates the same Post a media import would create,
# never doubles). No file to clean up; the sidecar stays on disk.
for rec_str in getattr(dl_result, "post_record_paths", None) or []:
rec_path = Path(rec_str)
if not rec_path.exists(): # noqa: ASYNC240
continue
def _upsert(p=rec_path):
return self.importer.upsert_post_record(
p, artist=artist, source=source_row,
)
await loop.run_in_executor(None, _upsert)
# Kick the off-platform file-host downloader for any links this run
# recorded (mega/gdrive/…). Global + idempotent (only claims pending/
# retryable rows); the beat sweep is the backstop. Lazy import dodges a
# task-module import cycle.
from ..tasks.external import sweep_external_links
sweep_external_links.delay()
ev = (await self.async_session.execute(
select(DownloadEvent).where(DownloadEvent.id == event_id)
)).scalar_one()
+248
View File
@@ -0,0 +1,248 @@
"""Fetchers for off-platform file hosts (mega / gdrive / mediafire / dropbox /
pixeldrain).
A shared, reusable subsystem: given an external_link URL, fetch the file(s) into
a destination directory and report the outcome. The download worker (separate
slice) drives these off the external_link ledger; any in-house downloader can
call `fetch_external()` directly.
No single tool covers all five hosts, so a small registry maps host → fetch
function behind one signature:
fetch_external(host, url, dest_dir, *, timeout, should_stop) -> FetchResult
Backends:
- dropbox : force the direct-download variant (dl=1) + stream GET.
- pixeldrain : GET the /api/file/{id} endpoint.
- mediafire : scrape the download page for the direct link + stream GET.
- gdrive : gdown (handles the confirm-token + virus-scan interstitial).
- mega : `megatools dl` subprocess (public link incl. #key); needs the
`megatools` binary in the runtime image (Debian apt package).
Public links work credential-free (rule 26); per-host creds are a later Settings
concern. Plain-HTTP homelab — no secure-context API. The HTTP / gdown /
subprocess calls go through module-level seams so unit tests run without
network, gdown, or MEGAcmd.
"""
from __future__ import annotations
import logging
import os
import re
import subprocess
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import requests
log = logging.getLogger(__name__)
_CHUNK = 1 << 16
_DEFAULT_TIMEOUT = 600.0
_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
)
# MediaFire's download page embeds the real file URL in a download-button href.
_MEDIAFIRE_RE = re.compile(
r'href="(https://download[^"]+?\.mediafire\.com/[^"]+)"', re.IGNORECASE
)
_CD_FILENAME_RE = re.compile(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', re.IGNORECASE)
@dataclass
class FetchResult:
files: list[Path] = field(default_factory=list)
bytes: int = 0
error: str | None = None
@property
def ok(self) -> bool:
return self.error is None and bool(self.files)
class ExternalFetchError(Exception):
"""A fetch failed in a way worth recording on the link's last_error."""
# -- seams (monkeypatched in tests) ----------------------------------------
def _http_get(url: str, *, timeout: float, headers: dict | None = None,
stream: bool = True) -> requests.Response:
hdrs = {"User-Agent": _USER_AGENT}
if headers:
hdrs.update(headers)
return requests.get(url, timeout=timeout, headers=hdrs, stream=stream)
def _gdown_download(url: str, out_dir: str) -> str | None:
"""Download a Google Drive url into out_dir via gdown; return the written
path. Imported lazily so the dep is optional at module-import time."""
# Lazy import: keeps gdown an optional dep that isn't needed to import this
# module (e.g. the no-DB unit lane that never exercises a real fetch).
import gdown
# A trailing sep tells gdown to keep the server-side filename inside out_dir.
return gdown.download(url, output=out_dir + os.sep, quiet=True, fuzzy=True)
def _run_mega_get(url: str, out_dir: str, *, timeout: float) -> None:
"""Download a mega.nz public link (key in the #fragment) into out_dir via
`megatools dl` (the Debian `megatools` package). Raises ExternalFetchError
on non-zero exit."""
# Fixed argv (not shell): only `url` is external input, passed positionally,
# so there's no shell-injection surface.
proc = subprocess.run(
["megatools", "dl", "--path", out_dir, url],
capture_output=True, text=True, timeout=timeout, check=False,
)
if proc.returncode != 0:
raise ExternalFetchError(
f"megatools dl exit {proc.returncode}: {(proc.stderr or '').strip()[:300]}"
)
# -- helpers ---------------------------------------------------------------
def _safe_name(name: str, fallback: str) -> str:
name = os.path.basename((name or "").strip().strip('"'))
# Strip path separators / control chars; never empty.
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip(". ")
return name or fallback
def _filename_from(resp: requests.Response, url: str, fallback: str) -> str:
cd = resp.headers.get("Content-Disposition", "")
m = _CD_FILENAME_RE.search(cd)
if m:
return _safe_name(m.group(1), fallback)
path_name = os.path.basename(urlsplit(url).path)
return _safe_name(path_name, fallback)
def _stream_to_file(resp: requests.Response, dest: Path,
should_stop: Callable[[], bool]) -> int:
"""Stream a response body to `dest` (atomic via .part). Returns byte count.
Honors should_stop between chunks (partial file removed)."""
part = dest.with_name(dest.name + ".part")
total = 0
try:
with part.open("wb") as fh:
for chunk in resp.iter_content(chunk_size=_CHUNK):
if should_stop():
raise ExternalFetchError("stopped")
if chunk:
fh.write(chunk)
total += len(chunk)
except BaseException:
part.unlink(missing_ok=True)
raise
os.replace(part, dest)
return total
def _get_to_dir(url: str, dest_dir: Path, *, timeout: float,
should_stop: Callable[[], bool], fallback: str,
headers: dict | None = None) -> FetchResult:
resp = _http_get(url, timeout=timeout, headers=headers, stream=True)
if resp.status_code != 200:
return FetchResult(error=f"HTTP {resp.status_code} for {url}")
name = _filename_from(resp, url, fallback)
dest = dest_dir / name
written = _stream_to_file(resp, dest, should_stop)
return FetchResult(files=[dest], bytes=written)
# -- per-host fetchers -----------------------------------------------------
def _fetch_dropbox(url: str, dest_dir: Path, *, timeout: float,
should_stop: Callable[[], bool]) -> FetchResult:
# Force the direct-download variant: dl=1 (Dropbox serves an HTML preview
# for dl=0). Rewrite/insert the param rather than string-replace so ?dl=0,
# &dl=0, and a missing param all resolve.
parts = urlsplit(url)
q = dict(parse_qsl(parts.query))
q["dl"] = "1"
direct = urlunsplit(parts._replace(query=urlencode(q)))
return _get_to_dir(direct, dest_dir, timeout=timeout,
should_stop=should_stop, fallback="dropbox-file")
def _fetch_pixeldrain(url: str, dest_dir: Path, *, timeout: float,
should_stop: Callable[[], bool]) -> FetchResult:
# /u/{id} (and /l/{id}) → the API file endpoint.
file_id = urlsplit(url).path.rstrip("/").split("/")[-1]
if not file_id:
return FetchResult(error=f"no pixeldrain id in {url}")
api = f"https://pixeldrain.com/api/file/{file_id}"
return _get_to_dir(api, dest_dir, timeout=timeout,
should_stop=should_stop, fallback=f"{file_id}.bin")
def _fetch_mediafire(url: str, dest_dir: Path, *, timeout: float,
should_stop: Callable[[], bool]) -> FetchResult:
page = _http_get(url, timeout=timeout, stream=False)
if page.status_code != 200:
return FetchResult(error=f"HTTP {page.status_code} for mediafire page")
m = _MEDIAFIRE_RE.search(page.text or "")
if not m:
return FetchResult(error="mediafire direct link not found on page")
return _get_to_dir(m.group(1), dest_dir, timeout=timeout,
should_stop=should_stop, fallback="mediafire-file")
def _fetch_gdrive(url: str, dest_dir: Path, *, timeout: float,
should_stop: Callable[[], bool]) -> FetchResult:
out = _gdown_download(url, str(dest_dir))
if not out:
return FetchResult(error="gdown returned no file (quota / private?)")
p = Path(out)
if not p.exists():
return FetchResult(error=f"gdown reported {out} but it is missing")
return FetchResult(files=[p], bytes=p.stat().st_size)
def _fetch_mega(url: str, dest_dir: Path, *, timeout: float,
should_stop: Callable[[], bool]) -> FetchResult:
before = set(dest_dir.iterdir()) if dest_dir.exists() else set()
_run_mega_get(url, str(dest_dir), timeout=timeout)
new = [p for p in dest_dir.iterdir() if p not in before and p.is_file()]
if not new:
return FetchResult(error="mega-get wrote no new file")
return FetchResult(files=new, bytes=sum(p.stat().st_size for p in new))
_REGISTRY: dict[str, Callable[..., FetchResult]] = {
"dropbox": _fetch_dropbox,
"pixeldrain": _fetch_pixeldrain,
"mediafire": _fetch_mediafire,
"gdrive": _fetch_gdrive,
"mega": _fetch_mega,
}
SUPPORTED_HOSTS = tuple(_REGISTRY)
def fetch_external(host: str, url: str, dest_dir: Path, *,
timeout: float = _DEFAULT_TIMEOUT,
should_stop: Callable[[], bool] = lambda: False) -> FetchResult:
"""Fetch `url` (a `host` link) into `dest_dir`. Returns a FetchResult; never
raises — any backend error (transport, non-200, scrape miss, subprocess
failure, stop) is captured on `.error` so the worker can record it and move
on."""
fetcher = _REGISTRY.get(host)
if fetcher is None:
return FetchResult(error=f"unsupported host {host!r}")
dest_dir.mkdir(parents=True, exist_ok=True)
try:
return fetcher(url, dest_dir, timeout=timeout, should_stop=should_stop)
except requests.RequestException as exc:
return FetchResult(error=f"transport error: {exc}")
except subprocess.TimeoutExpired:
return FetchResult(error="timed out")
except ExternalFetchError as exc:
return FetchResult(error=str(exc))
except Exception as exc: # never let a backend quirk kill the worker
log.warning("external fetch (%s) failed for %s: %s", host, url, exc)
return FetchResult(error=f"{type(exc).__name__}: {exc}")
+6
View File
@@ -147,6 +147,12 @@ class DownloadResult:
files_quarantined: int = 0
quarantined_paths: list[str] = field(default_factory=list)
written_paths: list[str] = field(default_factory=list)
# Native ingester only: post-only sidecar paths for posts that have NO
# downloadable media (pure-text posts), so the importer can still upsert the
# Post + its body. Empty on the gallery-dl path. Phase 3 imports these via
# Importer.upsert_post_record (keyed on external_post_id → updates, never
# doubles, the same Post a media import would create).
post_record_paths: list[str] = field(default_factory=list)
stdout: str = ""
stderr: str = ""
return_code: int = 0
+99
View File
@@ -23,6 +23,7 @@ from sqlalchemy.orm import Session
from ..models import (
Artist,
ExternalLink,
ImageProvenance,
ImageRecord,
ImportSettings,
@@ -34,6 +35,7 @@ from ..utils import safe_probe
from ..utils.paths import (
derive_subdir,
derive_top_level_artist,
filehash_from_url,
hash_suffixed_name,
safe_ext,
)
@@ -42,6 +44,7 @@ from ..utils.sidecar import find_sidecar, parse_sidecar
from ..utils.slug import slugify
from .archive_extractor import extract_archive, is_archive
from .attachment_store import AttachmentStore
from .link_extract import extract_external_links
from .thumbnailer import Thumbnailer
log = logging.getLogger(__name__)
@@ -305,6 +308,32 @@ class Importer:
),
)
def _sync_external_links(self, post: Post) -> None:
"""Record off-platform file-host links (mega/gdrive/mediafire/dropbox/
pixeldrain) found in the post body, so they're never silently dropped
and the download worker can fetch them later. Shared by every platform's
import (runs off Post.description). INSERT-MISSING only — an existing row
keeps its status/attempts (a re-import must not reset a link already
downloaded or dead-lettered). Identity is (post_id, url); the full url
incl. #fragment is preserved by the extractor."""
links = extract_external_links(post.description)
if not links:
return
self.session.flush() # ensure post.id is assigned before we reference it
existing = set(self.session.execute(
select(ExternalLink.url).where(ExternalLink.post_id == post.id)
).scalars().all())
for link in links:
if link.url in existing:
continue
self.session.add(ExternalLink(
post_id=post.id,
artist_id=post.artist_id,
host=link.host,
url=link.url,
label=link.label,
))
def import_one(self, source: Path) -> ImportResult:
"""Dispatch by kind. Media → normal pipeline. Archive → extract
media members (one Post via the archive-adjacent sidecar) and
@@ -710,6 +739,67 @@ class Importer:
self.session.commit()
return ImportResult(status="refreshed", image_id=existing.id)
def upsert_post_record(
self, sidecar: Path, *, artist: Artist | None = None,
source: Source | None = None,
) -> bool:
"""Upsert the Post for a post-ONLY sidecar (a media-less post), so the
artist archive includes text posts (their body + external links).
Reuses `_find_or_create_post` (keyed on external_post_id) so it UPDATES
the SAME Post a media import would create — never doubles. Fields are
FILLED, never clobbered with empty: parse_sidecar yields None for empty
values, and a None field is left untouched (an empty feed body never
wipes a populated one). Returns True if a Post was upserted, False if the
sidecar was unusable (parse failure / no artist)."""
try:
data = json.loads(sidecar.read_text("utf-8"))
if not isinstance(data, dict):
raise ValueError("sidecar JSON is not an object")
except Exception as exc:
log.warning("post-record sidecar parse failed for %s: %s", sidecar, exc)
return False
sd = parse_sidecar(data)
if artist is None:
name = self._sidecar_artist_name(data)
artist = self._upsert_artist(name) if name else None
if artist is None:
log.warning("post-record sidecar %s has no artist; skipping", sidecar)
return False
if source is not None:
src = source
else:
platform = sd.platform or "unknown"
src = self._lookup_source_for_sidecar(
artist_id=artist.id, platform=platform,
)
epid = sd.external_post_id or sidecar.stem
post = self._find_or_create_post(
source_id=src.id if src else None,
external_post_id=epid,
artist_id=artist.id,
)
if post.artist_id is None:
post.artist_id = artist.id
if sd.post_url is not None:
post.post_url = sd.post_url
if sd.post_title is not None:
post.post_title = sd.post_title
if sd.post_date is not None:
post.post_date = sd.post_date
if sd.description is not None:
post.description = sd.description
if sd.attachment_count is not None:
post.attachment_count = sd.attachment_count
post.raw_metadata = sd.raw
self._sync_external_links(post)
self.session.commit()
return True
def attach_in_place(
self,
path: Path,
@@ -957,6 +1047,14 @@ class Importer:
if record.artist_id is None:
record.artist_id = artist.id
# #830 Phase 2: persist the file's CDN source identity (NULL-only, so a
# re-import / enrich-on-duplicate never clobbers it) — the filehash is
# the join key that lets post_feed_service remap the body's inline
# `<img src=CDN>` to this local copy at render time.
if sd.source_url and record.source_filehash is None:
record.source_url = sd.source_url
record.source_filehash = filehash_from_url(sd.source_url)
if explicit_source is not None:
src = explicit_source
else:
@@ -982,6 +1080,7 @@ class Importer:
if sd.attachment_count is not None:
post.attachment_count = sd.attachment_count
post.raw_metadata = sd.raw
self._sync_external_links(post)
# Race-safe (image_record_id, post_id) upsert — mirrors the
# _find_or_create_source/post savepoint pattern. The plain
+30
View File
@@ -118,10 +118,16 @@ class Ingester:
# tick has no resumable backfill state.
checkpoint = mode in ("backfill", "recovery")
ledger_key = self._ledger_key
# Optional seams (Patreon native ingester): capture pure-text posts that
# have NO downloadable media so the artist archive is complete. Absent on
# stub clients/downloaders (unit tests) → media-less posts skipped as before.
post_record_key = getattr(self.client, "post_record_key", None)
write_post_record = getattr(self.downloader, "write_post_record", None)
start = time.monotonic()
last_live = start # plan #709: last live-progress write timestamp
log_lines: list[str] = []
written: list[str] = []
post_records: list[str] = []
quarantined_paths: list[str] = []
downloaded = 0
errors = 0
@@ -158,6 +164,7 @@ class Ingester:
files_quarantined=quarantined,
quarantined_paths=list(quarantined_paths),
written_paths=written,
post_record_paths=list(post_records),
stdout="\n".join(log_lines),
stderr="",
return_code=return_code,
@@ -220,6 +227,29 @@ class Ingester:
# resume_cursor None, so everything counts.
if not (resume_cursor and page_cursor == resume_cursor):
chunk_new_posts += 1
# Capture the post body + external links ONCE per post (gated by
# the synthetic post key in the seen-ledger), for EVERY post —
# whether or not it has downloadable media. This is what makes a
# backfill/recovery re-walk RECAPTURE bodies + links for posts
# whose media is already on disk: re-downloading existing media
# never fills links the system never had, so the body recapture
# has to ride the walk itself. Detail-fetch (for an empty feed
# body) happens at most once per post — the gate then spares it on
# later walks. bypass_seen (recovery) re-captures unconditionally.
if post_record_key and write_post_record:
rk = post_record_key(post)
if rk is not None:
pkey, ppid = rk
already = (
set() if bypass_seen
else self._seen_keys(source_id, [pkey])
)
if pkey not in already:
rec_path = write_post_record(post, artist_slug)
if rec_path is not None:
post_records.append(str(rec_path))
self._mark_seen(source_id, [(pkey, ppid)])
media = self.client.extract_media(post, included)
if not media:
continue
+105
View File
@@ -0,0 +1,105 @@
"""Extract off-platform file-host links from a post body.
Pure (no I/O) so it's unit-testable and reusable by EVERY in-house downloader's
import path — every platform stores its body in `Post.description`, so running
this there covers them all. Finds links to the supported external file hosts in
a post's HTML body (both `<a href="...">` anchors and bare URLs in text),
unwraps a Patreon outbound-redirect wrapper, and preserves the FULL url
including the `#fragment` (mega.nz puts the decryption key there) and the query
string — without those a mega download is impossible.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from html import unescape
from urllib.parse import parse_qs, urlsplit
# Supported file-host enum values (kept in sync with the external_link CHECK).
SUPPORTED_HOSTS = ("mega", "gdrive", "mediafire", "dropbox", "pixeldrain")
# Bare-domain suffix → canonical host label. Matched against the URL netloc
# (exact or as a dotted suffix, so www./dl. subdomains resolve too).
_HOST_MAP = {
"mega.nz": "mega",
"mega.co.nz": "mega",
"drive.google.com": "gdrive",
"mediafire.com": "mediafire",
"dropbox.com": "dropbox",
"pixeldrain.com": "pixeldrain",
}
# `<a href="...">label</a>` — DOTALL so a label spanning tags/newlines is caught.
_HREF_RE = re.compile(
r"""<a\b[^>]*?\bhref=["']([^"']+)["'][^>]*>(.*?)</a>""",
re.IGNORECASE | re.DOTALL,
)
_TAG_RE = re.compile(r"<[^>]+>")
# Bare http(s) URL in text. Stops at whitespace, quotes, angle brackets, and
# closing brackets — but KEEPS `#`, `&`, `?`, `=` so fragments/queries survive.
_URL_RE = re.compile(r"""https?://[^\s"'<>)\]}]+""", re.IGNORECASE)
@dataclass(frozen=True)
class ExtractedLink:
host: str # one of SUPPORTED_HOSTS
url: str # full url incl. query + #fragment
label: str | None # visible anchor text, when present
def _netloc(url: str) -> str:
# Lowercase host without credentials or port.
return urlsplit(url).netloc.lower().split("@")[-1].split(":")[0]
def host_for(url: str) -> str | None:
"""Canonical host label for a url, or None if it's not a supported host."""
netloc = _netloc(url)
for suffix, host in _HOST_MAP.items():
if netloc == suffix or netloc.endswith("." + suffix):
return host
return None
def _unwrap(url: str) -> str:
"""Unwrap a Patreon outbound-redirect wrapper to its inner target, so a
wrapped mega/gdrive link resolves to the real host. Patreon has used both
`www.patreon.com/...?url=<encoded>` and the `l.patreon.com` shim; check the
common target-param names. Non-Patreon urls pass through untouched."""
netloc = _netloc(url)
if not (netloc == "patreon.com" or netloc.endswith(".patreon.com")):
return url
qs = parse_qs(urlsplit(url).query)
for key in ("url", "u", "ext_url", "redirect", "target"):
vals = qs.get(key)
if vals and vals[0]:
return unescape(vals[0]).strip()
return url
def extract_external_links(html: str | None) -> list[ExtractedLink]:
"""All supported-host links in `html`, de-duplicated by url (first wins, so
an anchor's label is kept over a later bare sighting of the same url)."""
if not html:
return []
found: dict[str, ExtractedLink] = {}
# 1) Anchors first — they carry a human label ("Mega - Streamable").
for raw_href, inner in _HREF_RE.findall(html):
url = _unwrap(unescape(raw_href).strip())
host = host_for(url)
if host is None:
continue
label = unescape(_TAG_RE.sub("", inner)).strip() or None
found.setdefault(url, ExtractedLink(host=host, url=url, label=label))
# 2) Bare URLs pasted in text (no label). Trailing prose punctuation is
# trimmed; the href values already captured above de-dup away here.
for raw in _URL_RE.findall(html):
url = _unwrap(unescape(raw).strip().rstrip(".,;"))
host = host_for(url)
if host is None:
continue
found.setdefault(url, ExtractedLink(host=host, url=url, label=None))
return list(found.values())
+60 -11
View File
@@ -39,7 +39,7 @@ from urllib.parse import parse_qs, urlsplit
import requests
from ..utils.paths import safe_ext
from ..utils.paths import filehash_from_url, safe_ext
log = logging.getLogger(__name__)
@@ -90,12 +90,6 @@ _FIELDS_POST = (
_FIELDS_MEDIA = "id,image_urls,download_url,metadata,file_name"
_FIELDS_CAMPAIGN = "name,url"
# A CDN download URL embeds a 32-char hex (MD5) path segment; that segment is
# Patreon's stable per-file identity and is what we dedup + ledger against.
# Same role gallery-dl's _filehash plays. Match the FIRST 32-hex run anywhere
# in the URL (path or query); real Patreon CDN URLs carry exactly one.
_FILEHASH_RE = re.compile(r"([0-9a-fA-F]{32})")
# Inline post `content` is HTML; images are emitted as <img ... src="...">.
# Pull every src; downstream dedup collapses any that duplicate a gallery item
# by filehash. Tolerant of attribute ordering and single/double quotes.
@@ -188,10 +182,9 @@ def _load_session(cookies_path: str | Path | None) -> requests.Session:
def _filehash(url: str) -> str | None:
if not url:
return None
match = _FILEHASH_RE.search(url)
return match.group(1).lower() if match else None
# Delegate to the shared extractor (utils.paths) so capture-time persistence
# and render-time inline-image matching use the EXACT same identity.
return filehash_from_url(url)
def _basename_from_url(url: str) -> str:
@@ -526,6 +519,19 @@ class PatreonClient:
"date": published if isinstance(published, str) else None,
}
@staticmethod
def post_record_key(post: dict) -> tuple[str, str] | None:
"""`(ledger_key, post_id)` for a media-less post's seen-ledger entry, or
None when the post has no id. The synthetic `post:<id>` key lets the
generic core gate text-post capture through the SAME seen-ledger as media
— so a text post's body is detail-fetched + recorded ONCE, not re-fetched
every tick. Part of the client contract the core uses via getattr."""
pid = post.get("id")
pid = str(pid) if pid is not None else ""
if not pid:
return None
return (f"post:{pid}", pid)
# -- iteration ---------------------------------------------------------
def iter_posts(
@@ -562,6 +568,49 @@ class PatreonClient:
return
current_cursor = next_cursor
# -- detail (full body enrichment) -------------------------------------
def fetch_post_detail_content(self, post_id: str) -> str | None:
"""Best-effort fetch of a post's full HTML `content` from the per-post
DETAIL endpoint (`/api/posts/{id}`).
The feed/list endpoint (`/api/posts`) frequently returns `content` as
null even though we request it — the full body (its formatting, inline
`<img>`, and external `<a href>` links) only comes back from the
single-post detail resource. The downloader calls this to enrich a post
whose feed body was empty before writing the importer sidecar.
Best-effort BY DESIGN: the media download is the primary job, so a body
we can't fetch must never fail the walk. Every failure path (no id,
transport error, non-200, non-JSON, missing/empty content) returns None
rather than raising — distinct from the loud drift/auth raises on the
feed path, which gate real downloads.
"""
if not post_id:
return None
if self._request_sleep > 0:
time.sleep(self._request_sleep) # pace the API endpoint (plan #703)
url = f"{_POSTS_URL}/{post_id}"
params = {"fields[post]": "content", "json-api-version": "1.0"}
try:
resp = self._session.get(url, params=params, timeout=_TIMEOUT_SECONDS)
except requests.RequestException as exc:
log.warning("Patreon post-detail fetch failed (post %s): %s", post_id, exc)
return None
if resp.status_code != 200:
log.warning(
"Patreon post-detail fetch HTTP %s (post %s)", resp.status_code, post_id
)
return None
try:
payload = resp.json()
except ValueError:
return None
data = payload.get("data") if isinstance(payload, dict) else None
attrs = data.get("attributes") if isinstance(data, dict) else None
content = attrs.get("content") if isinstance(attrs, dict) else None
return content if isinstance(content, str) and content.strip() else None
# -- verify ------------------------------------------------------------
def verify_auth(self, campaign_id: str) -> tuple[bool | None, str]:
+53 -3
View File
@@ -168,10 +168,17 @@ class PatreonDownloader:
validate: bool = True,
rate_limit: float = 0.0,
session: requests.Session | None = None,
content_fetcher: Callable[[str], str | None] | None = None,
):
self.images_root = Path(images_root)
self.cookies_path = str(cookies_path) if cookies_path else None
self._validate = validate
# Best-effort enrichment seam: (post_id) -> full HTML body, or None. The
# feed endpoint often omits `content`; the adapter wires this to
# PatreonClient.fetch_post_detail_content so the sidecar captures the
# real body (formatting + inline <img> + external <a href> links).
# None in unit tests / when enrichment isn't wanted.
self._content_fetcher = content_fetcher
# Politeness: seconds to sleep before each actual media download (paces
# the CDN; honors ImportSettings.download_rate_limit_seconds, the same
# value gallery-dl used as its between-downloads `sleep`). 0 = no pacing.
@@ -287,7 +294,7 @@ class PatreonDownloader:
path=quarantine_dest, error=reason,
)
self._write_sidecar(post, out_path)
self._write_sidecar(post, out_path, source_url=media.url)
return MediaOutcome(media=media, status="downloaded", path=out_path, error=None)
# -- download seams ----------------------------------------------------
@@ -483,7 +490,9 @@ class PatreonDownloader:
# -- sidecar -----------------------------------------------------------
def _write_sidecar(self, post: dict, media_path: Path) -> Path:
def _write_sidecar(
self, post: dict, media_path: Path, *, source_url: str | None = None
) -> Path:
"""Write the importer-consumed sidecar next to `media_path`.
Patreon uses base-default sidecar keys (parse_sidecar maps
@@ -491,10 +500,37 @@ class PatreonDownloader:
content->description, published_at->post_date, url->post_url). Patreon
registers no derive_post_url, so `url` is trusted as the permalink — we
pass the post's attributes.url.
`source_url` is THIS file's CDN URL (#830 Phase 2). The importer persists
its filehash on the ImageRecord so the post body's inline `<img src>` can
be remapped to the local copy at render time.
"""
return self._write_sidecar_data(
post, media_path.with_suffix(".json"), source_url=source_url
)
def _write_sidecar_data(
self, post: dict, sidecar_path: Path, *, source_url: str | None = None
) -> Path:
"""Serialize the post's metadata to `sidecar_path`. Shared by the
per-media sidecar (next to each file) and the post-only sidecar
(`write_post_record`, for media-less posts). `source_url` is set only for
the per-media sidecar — a media-less post has no source file."""
attrs = post.get("attributes") or {}
title = attrs.get("title")
content = attrs.get("content")
# The feed/list endpoint frequently returns an empty `content`; the full
# HTML body (formatting + inline <img> + external <a href> links) only
# comes from the per-post detail endpoint. Enrich on first write for this
# post and MEMOIZE by mutating the shared `post` dict — so a multi-image
# post fetches detail exactly once, and a fully-seen post (no fresh
# download → no sidecar write) never pays the extra GET.
if (not isinstance(content, str) or not content.strip()) and self._content_fetcher:
fetched = self._content_fetcher(str(post.get("id") or ""))
if fetched:
attrs["content"] = fetched
post["attributes"] = attrs
content = fetched
published = attrs.get("published_at")
url = attrs.get("url")
data = {
@@ -505,6 +541,20 @@ class PatreonDownloader:
"published_at": published if isinstance(published, str) else None,
"url": url if isinstance(url, str) else None,
}
sidecar_path = media_path.with_suffix(".json")
if source_url:
data["source_url"] = source_url
sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path
def write_post_record(self, post: dict, artist_slug: str) -> Path | None:
"""Write a post-ONLY sidecar (no media file) for a media-less post, so
the importer can still upsert the Post + its body — text posts often hold
the only copy of an external <a href> link. Named `_post.json`: the
leading underscore keeps it from colliding with a media sidecar
(`<NN>_<stem>.json`) and from being resolved as some media file's sidecar
by find_sidecar. Returns the path, or None when the post has no id."""
if not str(post.get("id") or ""):
return None
post_dir = self.images_root / artist_slug / "patreon" / _post_dir_name(post)
post_dir.mkdir(parents=True, exist_ok=True)
return self._write_sidecar_data(post, post_dir / "_post.json")
+4 -1
View File
@@ -112,7 +112,10 @@ class PatreonIngester(Ingester):
downloader
if downloader is not None
else PatreonDownloader(
self.images_root, cookies_path, validate=validate, rate_limit=rate_limit
self.images_root, cookies_path, validate=validate, rate_limit=rate_limit,
# Enrich empty feed bodies from the per-post detail endpoint, via
# the SAME client (shares its cookie session + request pacing).
content_fetcher=resolved_client.fetch_post_detail_content,
)
)
super().__init__(
+73 -1
View File
@@ -11,19 +11,28 @@ attachments from PostAttachment) so the API layer can jsonify directly.
"""
from __future__ import annotations
from html import unescape
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import (
Artist,
ExternalLink,
ImageProvenance,
ImageRecord,
Post,
PostAttachment,
Source,
)
from ..utils.html_sanitize import (
extract_img_srcs,
rewrite_img_srcs,
sanitize_post_html,
)
from ..utils.paths import filehash_from_url
from ..utils.text import html_to_plain, truncate_at_word
from .gallery_service import thumbnail_url
from .gallery_service import image_url, thumbnail_url
from .pagination import decode_cursor, encode_cursor
DESCRIPTION_LIMIT = 280
@@ -191,8 +200,71 @@ class PostFeedService:
atts_map = await self._attachments_for([post.id])
item = self._to_dict(post, artist, source, thumbs_map, atts_map)
item["description_full"] = html_to_plain(post.description)
# Sanitized HTML body for faithful (semantic) rendering in the post view;
# detail-only (the feed list stays lightweight plain text). None when the
# post has no body. Inline `<img>` sources are remapped to locally-served
# copies (#830 Phase 2) so the body never hotlinks the public CDN.
item["description_html"] = await self._localize_inline_images(
sanitize_post_html(post.description), post.artist_id,
)
item["external_links"] = await self._external_links_for(post.id)
return item
async def _localize_inline_images(
self, html: str | None, artist_id: int | None,
) -> str | None:
"""Rewrite a post body's inline `<img src=CDN>` to locally-served copies.
The join key is the CDN filehash the downloader persisted on each
ImageRecord (source_filehash): for every body image whose filehash maps
to a stored image of THIS artist, swap the src to /images/<path>. Images
we never captured (or pre-Phase-2 rows with no filehash) are left as-is —
they keep hotlinking, which is the prior behavior. Scoped to the post's
artist so one creator's body never resolves to another's file."""
if not html or artist_id is None:
return html
srcs = extract_img_srcs(html)
if not srcs:
return html
# filehash -> the raw (as-in-HTML) src strings carrying it. A body can
# repeat the same image; keep every raw form so each is substituted.
by_hash: dict[str, list[str]] = {}
for raw in srcs:
fh = filehash_from_url(unescape(raw))
if fh:
by_hash.setdefault(fh, []).append(raw)
if not by_hash:
return html
rows = (await self.session.execute(
select(ImageRecord.source_filehash, ImageRecord.path)
.where(
ImageRecord.artist_id == artist_id,
ImageRecord.source_filehash.in_(list(by_hash)),
)
)).all()
replace: dict[str, str] = {}
for fh, path in rows:
for raw in by_hash.get(fh, ()):
replace[raw] = image_url(path)
return rewrite_img_srcs(html, replace)
async def _external_links_for(self, post_id: int) -> list[dict]:
"""Off-platform file-host links recorded for a post (detail-only). Each
carries its host, full url, label, and download status so the post view
can surface them (and, later, a retry/download affordance)."""
rows = (await self.session.execute(
select(ExternalLink)
.where(ExternalLink.post_id == post_id)
.order_by(ExternalLink.id.asc())
)).scalars().all()
return [
{
"id": e.id, "host": e.host, "url": e.url,
"label": e.label, "status": e.status,
}
for e in rows
]
# --- composition helpers ---------------------------------------------
async def _thumbnails_for(
+356
View File
@@ -0,0 +1,356 @@
"""Celery worker for off-platform file-host downloads (external_link ledger).
Walks the external_link table and fetches each pending/retryable link via
external_fetch, then routes the downloaded file(s) through the existing importer
(attach_in_place) so an archive becomes ImageRecords and any other file a
PostAttachment — linked to the SAME post the link came from (via a synthesized
sidecar). Long-running-task hygiene (rule #89): per-fetch wall-clock timeout,
attempt tracking + dead-letter, a recovery sweep, and retention of dead rows.
Concurrency: a per-host Redis lock serializes fetches to one-per-host (mega /
gdrive ban-avoidance), and an atomic status claim (pending/failed →
downloading) stops two workers grabbing the same link.
Files land in the artist's library tree (so an attached art image stays in
place, mirroring the gallery-dl download path); a captured archive/attachment is
copied into its store and the on-disk original removed.
"""
from __future__ import annotations
import json
import logging
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
import redis
from sqlalchemy import delete, select, update
from ..celery_app import celery
from ..config import get_config
from ..models import Artist, ExternalLink, ImportSettings, Post, Source
from ..services.external_fetch import fetch_external
from ..services.importer import Importer
from ..services.thumbnailer import Thumbnailer
from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images")
# After this many failed attempts a link is dead-lettered (skipped by routine
# sweeps; an operator recovery still re-attempts). Mirrors the ingester ledger.
DEAD_LETTER_THRESHOLD = 3
# Per-fetch wall-clock budget — films/packs are large; the celery time_limit is
# the backstop above this.
_FETCH_TIMEOUT = 3000.0
# Links enqueued per sweep — bounds the burst when a big backfill records many.
_SWEEP_BATCH = 50
# Dead rows older than this are pruned (retention).
_RETENTION_DAYS = 30
# Per-host serialize lock.
_LOCK_PREFIX = "fc:extdl_lock:"
_LOCK_TTL = 3600
_SERIALIZE_COUNTDOWN = 120
_MAX_SERIALIZE_WAITS = 30
_redis_client: redis.Redis | None = None
def _redis() -> redis.Redis:
global _redis_client
if _redis_client is None:
_redis_client = redis.from_url(get_config().celery_broker_url)
return _redis_client
def _host_enabled(settings: ImportSettings, host: str) -> bool:
"""Per-host enable flag — defaults True (rule #26: works out of the box).
The Settings UI slice adds real columns; getattr keeps this forward-
compatible without a migration in this slice."""
return bool(getattr(settings, f"extdl_{host}_enabled", True))
def _write_link_sidecar(file: Path, post: Post, platform: str, artist: Artist) -> None:
"""Sidecar next to a fetched file so the importer links it to `post`
(category+id resolve the source+post; matches gallery-dl's sidecar shape)."""
data = {
"category": platform,
"id": post.external_post_id,
"url": post.post_url,
"title": post.post_title,
"artist": artist.name,
}
Path(str(file) + ".json").write_text(json.dumps(data))
@celery.task(
name="backend.app.tasks.external.fetch_external_link",
bind=True,
soft_time_limit=3300,
time_limit=3600,
)
def fetch_external_link(self, link_id: int, _serialize_waits: int = 0) -> dict:
"""Fetch one external_link and route its file(s) through the importer."""
SessionLocal = _sync_session_factory()
# Atomic claim: only an actionable (pending/failed) row transitions to
# downloading, so a duplicate enqueue (sweep + post-download hook) no-ops.
with SessionLocal() as session:
claimed = session.execute(
update(ExternalLink)
.where(
ExternalLink.id == link_id,
ExternalLink.status.in_(("pending", "failed")),
)
.values(status="downloading")
.returning(ExternalLink.id)
).first()
session.commit()
if claimed is None:
return {"link_id": link_id, "skipped": "not claimable"}
link = session.get(ExternalLink, link_id)
post = session.get(Post, link.post_id) if link.post_id else None
if post is None:
link.status = "dead"
link.last_error = "post missing"
link.completed_at = datetime.now(UTC)
session.commit()
return {"link_id": link_id, "error": "post missing"}
artist = session.get(Artist, post.artist_id)
source = session.get(Source, post.source_id) if post.source_id else None
platform = source.platform if source is not None else "patreon"
settings = ImportSettings.load_sync(session)
if not _host_enabled(settings, link.host):
log.info("extdl skip (host disabled): link=%s host=%s", link_id, link.host)
link.status = "skipped"
link.last_error = f"{link.host} disabled"
link.completed_at = datetime.now(UTC)
session.commit()
return {"link_id": link_id, "skipped": "host disabled"}
host, url, attempts = link.host, link.url, link.attempts
log.info(
"extdl start: link=%s host=%s post=%s artist=%s attempt=%d url=%s",
link_id, host, post.id, artist.slug, attempts + 1, url,
)
# Per-host serialize: one fetch per host at a time. If busy, requeue.
lock = None
try:
lock = _redis().lock(f"{_LOCK_PREFIX}{host}", timeout=_LOCK_TTL, blocking=False)
got = bool(lock.acquire(blocking=False))
except redis.RedisError as exc:
log.warning("extdl lock unavailable for %s: %s — running uncapped", host, exc)
lock, got = None, True
if not got:
# Release the claim back to pending so a later run can pick it up, then
# requeue with a countdown (bounded, like download_source).
with SessionLocal() as session:
session.execute(
update(ExternalLink).where(ExternalLink.id == link_id)
.values(status="pending")
)
session.commit()
log.info(
"extdl requeue (host %s busy): link=%s wait=%d", host, link_id, _serialize_waits,
)
if _serialize_waits < _MAX_SERIALIZE_WAITS:
fetch_external_link.apply_async(
(link_id,), {"_serialize_waits": _serialize_waits + 1},
countdown=_SERIALIZE_COUNTDOWN,
)
else:
log.warning("extdl giving up requeue (host %s busy): link=%s", host, link_id)
return {"link_id": link_id, "requeued": "host busy"}
started = time.monotonic()
post_dir = IMAGES_ROOT / artist.slug / platform / "external" / str(post.external_post_id)
try:
result = fetch_external(host, url, post_dir, timeout=_FETCH_TIMEOUT)
with SessionLocal() as session:
link = session.get(ExternalLink, link_id)
if not result.ok:
log.warning(
"extdl fetch failed: link=%s host=%s error=%s",
link_id, host, result.error,
)
_record_failure(link, attempts, result.error or "fetch failed")
session.commit()
return {"link_id": link_id, "error": result.error}
log.info(
"extdl fetched: link=%s host=%s files=%d bytes=%d",
link_id, host, len(result.files), result.bytes,
)
# Re-load post/artist/source attached to THIS session before handing
# them to the importer (the claim block's instances are detached).
post = session.get(Post, link.post_id)
artist = session.get(Artist, post.artist_id)
source = session.get(Source, post.source_id) if post.source_id else None
image_ids = _route_files(session, result.files, post, platform, artist, source)
link.status = "downloaded"
link.last_error = None
link.completed_at = datetime.now(UTC)
link.duration_seconds = time.monotonic() - started
session.commit()
log.info(
"extdl done: link=%s host=%s post=%s files=%d image(s)=%d dur=%.1fs",
link_id, host, post.id, len(result.files), len(image_ids),
link.duration_seconds,
)
# Thumbnails + ML for any newly-attached images (mirrors the download
# path). Lazy import to dodge a task-module import cycle.
if image_ids:
from .ml import tag_and_embed
from .thumbnail import generate_thumbnail
for img_id in image_ids:
generate_thumbnail.delay(img_id)
tag_and_embed.delay(img_id)
return {"link_id": link_id, "files": len(result.files), "images": len(image_ids)}
except Exception as exc: # never leave a link stuck in 'downloading'
log.exception("external fetch task failed for link %s", link_id)
with SessionLocal() as session:
link = session.get(ExternalLink, link_id)
if link is not None and link.status == "downloading":
_record_failure(link, attempts, f"{type(exc).__name__}: {exc}")
session.commit()
raise
finally:
if lock is not None:
try:
lock.release()
except Exception: # noqa: BLE001 — TTL may have already freed it
pass
def _record_failure(link: ExternalLink, attempts: int, error: str) -> None:
nxt = attempts + 1
link.attempts = nxt
link.last_error = error[:1000]
link.status = "dead" if nxt >= DEAD_LETTER_THRESHOLD else "failed"
link.completed_at = datetime.now(UTC)
log.warning(
"extdl link %s -> %s (attempt %d/%d): %s",
link.id, link.status, nxt, DEAD_LETTER_THRESHOLD, error[:200],
)
def _route_files(session, files, post, platform, artist, source) -> list[int]:
"""Import each fetched file through the SAME pipeline extracted-zip members
use — importer.attach_in_place — so a downloaded archive is extracted to
ImageRecords and provenance-linked to the post (via the synthesized sidecar),
a non-art file is captured as a PostAttachment, and an art image is attached
in place. Returns new image ids so the caller enqueues thumbnail + ML
(tagging) for them, exactly as download_service does for downloaded media.
Result handling mirrors download_service._phase3_persist branch-for-branch
(duplicate cleanup, the unextracted-archive warning of #718) and logs every
decision — this path is new and the operator expects to iterate on it."""
settings = ImportSettings.load_sync(session)
importer = Importer(
session=session,
images_root=IMAGES_ROOT,
import_root=IMAGES_ROOT,
thumbnailer=Thumbnailer(images_root=IMAGES_ROOT),
settings=settings,
)
image_ids: list[int] = []
for f in files:
_write_link_sidecar(f, post, platform, artist)
result = importer.attach_in_place(f, artist=artist, source=source)
session.commit()
if result.status in ("imported", "superseded"):
ids = list(getattr(result, "member_image_ids", []) or [])
if result.image_id is not None and result.image_id not in ids:
ids.append(result.image_id)
image_ids.extend(ids)
log.info(
"extdl import: post=%s file=%s status=%s -> %d image(s) "
"(provenance-linked, queued for tagging)",
post.id, f.name, result.status, len(ids),
)
elif result.status == "attached":
# Non-art / archive captured as a PostAttachment (copied into the
# store) — drop the on-disk original. An archive captured WITHOUT
# extracting any image carries the reason (the recurring "zip but no
# images" symptom, #718) — surface it loudly.
if result.error:
log.warning(
"extdl archive captured UNEXTRACTED: post=%s file=%s reason=%s",
post.id, f.name, result.error,
)
else:
log.info("extdl attachment captured: post=%s file=%s", post.id, f.name)
f.unlink(missing_ok=True)
elif result.status == "skipped":
reason = result.skip_reason.value if result.skip_reason else None
log.info(
"extdl skipped: post=%s file=%s reason=%s", post.id, f.name, reason,
)
if reason in ("duplicate_hash", "duplicate_phash"):
f.unlink(missing_ok=True) # canonical copy already in the library
elif result.status == "failed":
log.warning(
"extdl import FAILED: post=%s file=%s error=%s",
post.id, f.name, result.error,
)
f.unlink(missing_ok=True)
else: # 'refreshed' or any future status — work happened, no new images
log.info(
"extdl import: post=%s file=%s status=%s", post.id, f.name, result.status,
)
# The synthesized sidecar has done its job — don't litter the tree.
Path(str(f) + ".json").unlink(missing_ok=True)
return image_ids
@celery.task(name="backend.app.tasks.external.sweep_external_links")
def sweep_external_links() -> dict:
"""Enqueue a bounded batch of actionable links (pending, or failed below the
dead-letter threshold). Driven by the post-download hook and a beat tick."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
ids = session.execute(
select(ExternalLink.id).where(
ExternalLink.status.in_(("pending", "failed")),
ExternalLink.attempts < DEAD_LETTER_THRESHOLD,
).order_by(ExternalLink.id.asc()).limit(_SWEEP_BATCH)
).scalars().all()
for link_id in ids:
fetch_external_link.delay(link_id)
return {"enqueued": len(ids)}
@celery.task(name="backend.app.tasks.external.recover_external_links")
def recover_external_links() -> dict:
"""Recovery sweep (rule #89): reset dead links back to retryable so a stuck
host outage or a since-fixed bug gets another pass, then enqueue. Bounded."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
session.execute(
update(ExternalLink).where(ExternalLink.status == "dead")
.values(status="failed", attempts=0, last_error=None)
)
session.commit()
return sweep_external_links()
@celery.task(name="backend.app.tasks.external.prune_external_links")
def prune_external_links() -> dict:
"""Retention (rule #89): delete long-dead links so the ledger doesn't grow
unbounded. Downloaded links are kept (they're the record of what we have)."""
cutoff = datetime.now(UTC) - timedelta(days=_RETENTION_DAYS)
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
res = session.execute(
delete(ExternalLink).where(
ExternalLink.status == "dead",
ExternalLink.created_at < cutoff,
)
)
session.commit()
return {"pruned": res.rowcount or 0}
+53 -2
View File
@@ -6,12 +6,27 @@ server-side to a tight allowlist. nh3 (Rust/ammonia bindings) is used;
bleach is deprecated upstream and intentionally not used.
"""
import re
import nh3
# A faithful-but-safe set: enough to reproduce the SEMANTIC look of a scraped
# post body (headings, lists, quotes, code, inline images, links, line breaks +
# inline emphasis) without layout-injection wrappers. `div`/`span` are
# deliberately NOT allowed — their text is preserved by nh3 and they only carry
# source-site layout/styling we don't want to import (a test pins this).
ALLOWED_TAGS = {
"p", "a", "br", "em", "strong", "b", "i", "ul", "ol", "li", "blockquote",
"p", "a", "br", "em", "strong", "b", "i", "u", "s",
"ul", "ol", "li", "blockquote",
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
"pre", "code", "img", "figure", "figcaption",
}
ALLOWED_ATTRIBUTES = {"a": {"href", "title"}}
ALLOWED_ATTRIBUTES = {
"a": {"href", "title", "target"},
"img": {"src", "alt", "title", "width", "height"},
}
# http/https for hotlinked source images today; relative URLs (e.g. a local
# `/api/...` src once inline images are captured) pass through nh3 untouched.
ALLOWED_URL_SCHEMES = {"http", "https"}
@@ -37,3 +52,39 @@ def sanitize_post_html(raw: str | None) -> str | None:
link_rel="noopener noreferrer",
clean_content_tags={"script", "style"},
)
# nh3 emits attributes double-quoted, so a single `src="..."` capture is enough
# to read/rewrite inline-image sources on already-sanitized markup (#830 Phase 2).
_IMG_SRC_RE = re.compile(r'(<img\b[^>]*?\bsrc=")([^"]*)(")', re.IGNORECASE)
def extract_img_srcs(html: str | None) -> list[str]:
"""The raw `src` values of every <img> in `html`, in document order, deduped
(first kept). Returned EXACTLY as they appear (still entity-escaped) so a
caller can match them back for verbatim substitution."""
if not html:
return []
out: list[str] = []
seen: set[str] = set()
for _pre, src, _post in _IMG_SRC_RE.findall(html):
if src and src not in seen:
seen.add(src)
out.append(src)
return out
def rewrite_img_srcs(html: str | None, replace: dict[str, str]) -> str | None:
"""Swap each <img src> whose raw (as-in-HTML) value is a key in `replace`
with the mapped value; others are left untouched. Used to point a post body's
inline images at locally-served copies (#830 Phase 2). Operates only on src
values of already-sanitized markup, so the result stays within the allowlist.
Returns `html` unchanged when it or `replace` is empty."""
if not html or not replace:
return html
def _sub(m: re.Match) -> str:
pre, src, post = m.group(1), m.group(2), m.group(3)
return f"{pre}{replace.get(src, src)}{post}"
return _IMG_SRC_RE.sub(_sub, html)
+19
View File
@@ -1,9 +1,28 @@
"""Filesystem path helpers — destination derivation, hash-suffixed names."""
import re
from pathlib import Path
_MAX_EXT_LEN = 16
# A Patreon/gallery CDN URL embeds a 32-char hex (MD5) path segment that is the
# file's stable per-file identity — the same role gallery-dl's `_filehash`
# plays. It is the join key between a post body `<img src=CDN>` and the local
# copy we downloaded (extract_media dedups content vs gallery images by it), so
# this ONE extractor must be used for both capture-time persistence and
# render-time matching — they cannot be allowed to drift. Match the FIRST 32-hex
# run anywhere in the URL (path or query); real CDN URLs carry exactly one.
_FILEHASH_RE = re.compile(r"([0-9a-fA-F]{32})")
def filehash_from_url(url: str | None) -> str | None:
"""The 32-char hex (MD5) CDN identity segment of `url`, lowercased, or None
when the URL is empty / carries no such segment."""
if not url:
return None
match = _FILEHASH_RE.search(url)
return match.group(1).lower() if match else None
def safe_ext(name: str | Path) -> str:
"""Conservatively extract a short, alphanumeric file extension.
+11
View File
@@ -27,6 +27,11 @@ class SidecarData:
description: str | None
attachment_count: int | None
post_date: datetime | None
# Per-FILE CDN/origin URL the media was downloaded from (#830 Phase 2). Only
# the per-media sidecar carries it; post-only sidecars leave it None. The
# importer persists it on the ImageRecord so the body's inline `<img src>`
# can be matched (by filehash) to the local copy at render time.
source_url: str | None
raw: dict
@@ -172,6 +177,11 @@ def parse_sidecar(data: dict) -> SidecarData:
else:
post_url = _first_str(data, ("url", "post_url"))
# Per-file source URL (#830 Phase 2): the native downloader writes it into
# each media sidecar. `url` is the post permalink for Patreon (handled
# above), so source_url has its own dedicated key and never collides.
source_url = _first_str(data, ("source_url",))
return SidecarData(
platform=platform,
external_post_id=external_post_id,
@@ -180,5 +190,6 @@ def parse_sidecar(data: dict) -> SidecarData:
description=description,
attachment_count=attachment_count,
post_date=post_date,
source_url=source_url,
raw=data,
)
+130 -2
View File
@@ -63,8 +63,16 @@
Post {{ post.external_post_id }}
</h3>
<!-- Faithful (semantic) body render once expanded: backend-sanitized
HTML (headings, lists, links, inline images). Collapsed and the
no-detail fallback stay plain text. -->
<div
v-if="hasDescription && descExpanded && descHtml"
class="fc-post-card__desc fc-post-card__desc--html"
v-html="descHtml"
/>
<p
v-if="hasDescription" ref="descEl"
v-else-if="hasDescription" ref="descEl"
class="fc-post-card__desc"
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
>{{ descText }}</p>
@@ -79,6 +87,25 @@
@click="toggleDesc"
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
<!-- Off-platform file-host links (mega/gdrive/…) found in the body.
Shown once expanded; clickable now, auto-downloaded by the worker
slice. -->
<div v-if="descExpanded && externalLinks.length" class="fc-post-card__links">
<div class="fc-post-card__links-label">External files</div>
<a
v-for="lnk in externalLinks" :key="lnk.id"
:href="lnk.url" target="_blank" rel="noopener noreferrer"
class="fc-post-card__link"
>
<span class="fc-post-card__link-host">{{ lnk.host }}</span>
<span class="fc-post-card__link-text">{{ lnk.label || lnk.url }}</span>
<span
v-if="lnk.status && lnk.status !== 'pending'"
class="fc-post-card__link-status" :data-status="lnk.status"
>{{ lnk.status }}</span>
</a>
</div>
<div v-if="attachments.length" class="fc-post-card__atts">
<a
v-for="att in attachments" :key="att.id"
@@ -190,6 +217,11 @@ const descEl = ref(null)
const hasDescription = computed(() => !!props.post.description_plain)
const fullDescription = computed(() => detail.value?.description_full || null)
// Backend-sanitized HTML body (detail-only). Drives the faithful render once
// expanded; falls back to plain text when detail isn't loaded.
const descHtml = computed(() => detail.value?.description_html || null)
// Off-platform file-host links (detail-only), surfaced once expanded.
const externalLinks = computed(() => detail.value?.external_links || [])
const descText = computed(() =>
descExpanded.value
? (fullDescription.value || props.post.description_plain)
@@ -219,7 +251,9 @@ onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } })
async function toggleDesc () {
if (!descExpanded.value) {
if (props.post.description_truncated && !fullDescription.value && !detail.value) {
// Fetch detail on first expand to get the sanitized HTML body (and the full
// plain text) — render the formatted body, not just the truncated preview.
if (!detail.value) {
try {
detail.value = await postsStore.getPostFull(props.post.id)
} catch { /* render the truncated text rather than nothing */ }
@@ -369,6 +403,58 @@ function formatBytes (n) {
@container (min-width: 800px) {
.fc-post-card__desc--clamped { -webkit-line-clamp: 5; }
}
/* Faithful HTML body (expanded). Drop the plain-text pre-wrap and style the
semantic tags the backend sanitizer allows. :deep() pierces scoped CSS to
reach the v-html subtree. */
.fc-post-card__desc--html { white-space: normal; }
.fc-post-card__desc--html :deep(p) { margin: 0 0 0.6em; }
.fc-post-card__desc--html :deep(p:last-child) { margin-bottom: 0; }
.fc-post-card__desc--html :deep(h1),
.fc-post-card__desc--html :deep(h2),
.fc-post-card__desc--html :deep(h3),
.fc-post-card__desc--html :deep(h4),
.fc-post-card__desc--html :deep(h5),
.fc-post-card__desc--html :deep(h6) {
margin: 0.8em 0 0.4em;
line-height: 1.25;
font-weight: 700;
}
.fc-post-card__desc--html :deep(h1) { font-size: 1.3rem; }
.fc-post-card__desc--html :deep(h2) { font-size: 1.2rem; }
.fc-post-card__desc--html :deep(h3) { font-size: 1.1rem; }
.fc-post-card__desc--html :deep(h4),
.fc-post-card__desc--html :deep(h5),
.fc-post-card__desc--html :deep(h6) { font-size: 1rem; }
.fc-post-card__desc--html :deep(a) {
color: rgb(var(--v-theme-accent));
text-decoration: underline;
word-break: break-word;
}
.fc-post-card__desc--html :deep(img) {
max-width: 100%;
height: auto;
border-radius: 6px;
margin: 0.4em 0;
}
.fc-post-card__desc--html :deep(ul),
.fc-post-card__desc--html :deep(ol) { margin: 0 0 0.6em; padding-left: 1.4em; }
.fc-post-card__desc--html :deep(li) { margin: 0.15em 0; }
.fc-post-card__desc--html :deep(blockquote) {
margin: 0.6em 0;
padding-left: 0.8em;
border-left: 3px solid rgb(var(--v-theme-on-surface-variant));
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__desc--html :deep(pre) {
background: rgba(var(--v-theme-on-surface), 0.06);
padding: 0.6em; border-radius: 6px; overflow-x: auto;
}
.fc-post-card__desc--html :deep(code) { font-family: monospace; font-size: 0.85em; }
.fc-post-card__desc--html :deep(hr) {
border: 0; border-top: 1px solid rgb(var(--v-theme-on-surface-variant));
margin: 0.8em 0; opacity: 0.4;
}
.fc-post-card__desc--html :deep(figure) { margin: 0.4em 0; }
.fc-post-card__desc--missing {
font-style: italic;
color: rgb(var(--v-theme-on-surface-variant));
@@ -404,4 +490,46 @@ function formatBytes (n) {
}
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-post-card__links {
display: flex; flex-direction: column; gap: 6px;
margin-top: 12px;
}
.fc-post-card__links-label {
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__link {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 12px;
border: 1px solid rgb(var(--v-theme-on-surface-variant));
border-radius: 8px;
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-size: 0.85rem;
}
.fc-post-card__link:hover {
color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card__link-host {
flex: 0 0 auto;
font-weight: 700; text-transform: uppercase; font-size: 0.7rem;
letter-spacing: 0.04em;
padding: 2px 6px; border-radius: 4px;
background: rgba(var(--v-theme-accent), 0.16);
color: rgb(var(--v-theme-accent));
}
.fc-post-card__link-text {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-post-card__link-status {
flex: 0 0 auto; margin-left: auto;
font-size: 0.7rem; text-transform: uppercase;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__link-status[data-status="downloaded"] {
color: rgb(var(--v-theme-accent));
}
</style>
@@ -52,6 +52,52 @@
</v-card-text>
</v-card>
<h3 class="text-h6 mb-3 mt-6">External file-host downloads</h3>
<v-card variant="outlined">
<v-card-text v-if="importStore.settings">
<div class="fc-help mb-2">
When a creator links files on these hosts in a post, Curator downloads
them into the post automatically (tagged + provenance-linked like any
other media). Turn a host off if it starts rate-limiting or banning.
</div>
<v-row>
<v-col cols="12" sm="4">
<v-switch
v-model="dl.extdl_mega_enabled" label="mega.nz"
density="compact" hide-details color="accent" @change="saveDownloader"
/>
</v-col>
<v-col cols="12" sm="4">
<v-switch
v-model="dl.extdl_gdrive_enabled" label="Google Drive"
density="compact" hide-details color="accent" @change="saveDownloader"
/>
</v-col>
<v-col cols="12" sm="4">
<v-switch
v-model="dl.extdl_pixeldrain_enabled" label="Pixeldrain"
density="compact" hide-details color="accent" @change="saveDownloader"
/>
</v-col>
<v-col cols="12" sm="4">
<v-switch
v-model="dl.extdl_mediafire_enabled" label="MediaFire"
density="compact" hide-details color="accent" @change="saveDownloader"
/>
</v-col>
<v-col cols="12" sm="4">
<v-switch
v-model="dl.extdl_dropbox_enabled" label="Dropbox"
density="compact" hide-details color="accent" @change="saveDownloader"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-else>
<v-skeleton-loader type="paragraph" />
</v-card-text>
</v-card>
<h3 class="text-h6 mb-3 mt-6">Schedule defaults</h3>
<v-card variant="outlined">
<v-card-text v-if="importStore.settings">
@@ -151,6 +197,11 @@ const dl = reactive({
download_schedule_default_seconds: 28800,
download_event_retention_days: 90,
download_failure_warning_threshold: 5,
extdl_mega_enabled: true,
extdl_gdrive_enabled: true,
extdl_mediafire_enabled: true,
extdl_dropbox_enabled: true,
extdl_pixeldrain_enabled: true,
})
watch(() => importStore.settings, (s) => { if (s) Object.assign(dl, s) }, { immediate: true })
+61 -46
View File
@@ -1,35 +1,60 @@
<template>
<v-container fluid class="pt-2 pb-6">
<v-tabs v-model="tab" density="compact" class="mb-3">
<v-tab value="browse">Browse</v-tab>
<v-tab value="suggestions">
Suggestions
<v-badge
v-if="sug.suggestions.length" :content="sug.suggestions.length"
inline color="accent"
<!-- Tabs + the active tab's controls live in one sticky block pinned under
the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and
search/sort stay reachable on a long grid. The controls can't sit
inside v-window (it clips sticky children), so they're hoisted here. -->
<div class="fc-series__head">
<v-tabs v-model="tab" density="compact">
<v-tab value="browse">Browse</v-tab>
<v-tab value="suggestions">
Suggestions
<v-badge
v-if="sug.suggestions.length" :content="sug.suggestions.length"
inline color="accent"
/>
</v-tab>
</v-tabs>
<div v-if="tab === 'browse'" class="fc-series-browse__controls">
<v-text-field
v-model="query"
density="compact" variant="outlined" hide-details clearable
label="Search series" prepend-inner-icon="mdi-magnify"
style="max-width: 320px;"
/>
</v-tab>
</v-tabs>
<v-select
:model-value="store.sort"
:items="sortItems"
density="compact" variant="outlined" hide-details
label="Sort" style="max-width: 220px;"
@update:model-value="store.setSort($event)"
/>
</div>
<div v-else class="fc-sug__controls">
<v-switch
:model-value="sug.enabled" color="accent" density="compact"
hide-details label="Matching on"
@update:model-value="sug.setEnabled($event)"
/>
<v-text-field
:model-value="sug.threshold"
type="number" min="0" max="1" step="0.05"
density="compact" variant="outlined" hide-details
label="Threshold" style="max-width: 130px;"
@change="sug.setThreshold(Number($event.target.value))"
/>
<v-spacer />
<v-btn
variant="tonal" prepend-icon="mdi-radar" @click="sug.rescan()"
>Rescan</v-btn>
</div>
</div>
<v-window v-model="tab">
<!-- ---- Browse ---- -->
<v-window-item value="browse">
<div class="fc-series-browse__controls">
<v-text-field
v-model="query"
density="compact" variant="outlined" hide-details clearable
label="Search series" prepend-inner-icon="mdi-magnify"
style="max-width: 320px;"
/>
<v-select
:model-value="store.sort"
:items="sortItems"
density="compact" variant="outlined" hide-details
label="Sort" style="max-width: 220px;"
@update:model-value="store.setSort($event)"
/>
</div>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
Failed to load: {{ store.error }}
</v-alert>
@@ -138,25 +163,6 @@
<!-- ---- Suggestions ---- -->
<v-window-item value="suggestions">
<div class="fc-sug__controls">
<v-switch
:model-value="sug.enabled" color="accent" density="compact"
hide-details label="Matching on"
@update:model-value="sug.setEnabled($event)"
/>
<v-text-field
:model-value="sug.threshold"
type="number" min="0" max="1" step="0.05"
density="compact" variant="outlined" hide-details
label="Threshold" style="max-width: 130px;"
@change="sug.setThreshold(Number($event.target.value))"
/>
<v-spacer />
<v-btn
variant="tonal" prepend-icon="mdi-radar" @click="sug.rescan()"
>Rescan</v-btn>
</div>
<v-alert v-if="sug.error" type="error" variant="tonal" closable class="my-4">
{{ sug.error }}
</v-alert>
@@ -287,8 +293,17 @@ onMounted(() => {
</script>
<style scoped>
/* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so
content scrolls cleanly beneath it. Surface bg matches SettingsView. */
.fc-series__head {
position: sticky;
top: 64px;
z-index: 4;
background: rgb(var(--v-theme-surface));
padding-bottom: 12px;
}
.fc-series-browse__controls {
display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px;
display: flex; flex-wrap: wrap; gap: 12px; padding-top: 12px;
}
.fc-series-browse__empty {
padding: 48px; text-align: center;
@@ -351,7 +366,7 @@ onMounted(() => {
/* Suggestions tab */
.fc-sug__controls {
display: flex; align-items: center; gap: 16px; margin-bottom: 12px;
display: flex; align-items: center; gap: 16px; padding-top: 12px;
}
.fc-sug__list { display: flex; flex-direction: column; gap: 8px; }
.fc-sug__row {
+5
View File
@@ -38,3 +38,8 @@ nh3>=0.2,<0.3
# Archive extraction (FC-2d-iii filesystem-import aid)
rarfile>=4.2,<5
py7zr>=1,<2
# Google Drive fetcher for off-platform file-host links (external downloads,
# #830). Handles Drive's confirm-token + virus-scan interstitial. mega.nz uses
# the `megatools` binary instead (Debian apt pkg in the runtime image, not pip).
gdown>=5,<6
+21
View File
@@ -44,3 +44,24 @@ async def test_patch_rejects_non_bool_validate(client):
"/api/settings/import", json={"download_validate_files": "yes"}
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_extdl_host_toggles_default_on_and_patch(client):
body = await (await client.get("/api/settings/import")).get_json()
assert body["extdl_mega_enabled"] is True
assert body["extdl_pixeldrain_enabled"] is True
resp = await client.patch(
"/api/settings/import", json={"extdl_mega_enabled": False}
)
assert resp.status_code == 200
assert (await resp.get_json())["extdl_mega_enabled"] is False
@pytest.mark.asyncio
async def test_extdl_toggle_rejects_non_bool(client):
resp = await client.patch(
"/api/settings/import", json={"extdl_gdrive_enabled": "nope"}
)
assert resp.status_code == 400
+141
View File
@@ -0,0 +1,141 @@
"""Unit tests for external_fetch — no network/gdown/MEGAcmd (seams stubbed)."""
from pathlib import Path
import pytest
import requests
import backend.app.services.external_fetch as ef
from backend.app.services.external_fetch import fetch_external
def _resp(*, status=200, headers=None, chunks=(b"hello",), text=""):
class _R:
def __init__(self):
self.status_code = status
self.headers = headers or {}
self.text = text
def iter_content(self, chunk_size=65536):
yield from chunks
return _R()
def test_dropbox_forces_dl1_and_downloads(tmp_path, monkeypatch):
seen = {}
def fake_get(url, *, timeout, headers=None, stream=True):
seen["url"] = url
return _resp(headers={"Content-Disposition": 'attachment; filename="film.zip"'})
monkeypatch.setattr(ef, "_http_get", fake_get)
res = fetch_external(
"dropbox", "https://www.dropbox.com/s/abc/film.zip?dl=0", tmp_path
)
assert res.ok
assert "dl=1" in seen["url"]
assert res.files[0].name == "film.zip"
assert res.files[0].read_bytes() == b"hello"
def test_pixeldrain_hits_api_endpoint(tmp_path, monkeypatch):
seen = {}
def fake_get(url, *, timeout, headers=None, stream=True):
seen["url"] = url
return _resp()
monkeypatch.setattr(ef, "_http_get", fake_get)
res = fetch_external("pixeldrain", "https://pixeldrain.com/u/ems4UomN", tmp_path)
assert res.ok
assert seen["url"] == "https://pixeldrain.com/api/file/ems4UomN"
def test_mediafire_scrapes_then_downloads(tmp_path, monkeypatch):
calls = []
def fake_get(url, *, timeout, headers=None, stream=True):
calls.append(url)
if "mediafire.com/file" in url:
return _resp(
text='<a id="downloadButton" '
'href="https://download1234.mediafire.com/abc/film.zip">dl</a>'
)
return _resp(headers={"Content-Disposition": 'filename="film.zip"'})
monkeypatch.setattr(ef, "_http_get", fake_get)
res = fetch_external("mediafire", "https://www.mediafire.com/file/x/film.zip", tmp_path)
assert res.ok
assert res.files[0].name == "film.zip"
assert calls[1].startswith("https://download1234.mediafire.com/")
def test_mediafire_missing_link_errors(tmp_path, monkeypatch):
monkeypatch.setattr(ef, "_http_get", lambda *a, **k: _resp(text="<html>none</html>"))
res = fetch_external("mediafire", "https://www.mediafire.com/file/x", tmp_path)
assert not res.ok
assert "not found" in res.error
def test_gdrive_uses_gdown(tmp_path, monkeypatch):
def fake_gdown(url, out_dir):
p = Path(out_dir) / "drive.bin"
p.write_bytes(b"xyz")
return str(p)
monkeypatch.setattr(ef, "_gdown_download", fake_gdown)
res = fetch_external("gdrive", "https://drive.google.com/file/d/ID/view", tmp_path)
assert res.ok
assert res.files[0].name == "drive.bin"
assert res.bytes == 3
def test_gdrive_no_file_errors(tmp_path, monkeypatch):
monkeypatch.setattr(ef, "_gdown_download", lambda url, out_dir: None)
res = fetch_external("gdrive", "https://drive.google.com/file/d/ID/view", tmp_path)
assert not res.ok
assert "gdown" in res.error
def test_mega_collects_new_files(tmp_path, monkeypatch):
def fake_mega(url, out_dir, *, timeout):
(Path(out_dir) / "film.mp4").write_bytes(b"vid")
monkeypatch.setattr(ef, "_run_mega_get", fake_mega)
res = fetch_external("mega", "https://mega.nz/file/x#k", tmp_path)
assert res.ok
assert res.files[0].name == "film.mp4"
def test_unsupported_host(tmp_path):
res = fetch_external("nope", "https://x", tmp_path)
assert not res.ok
assert "unsupported" in res.error
def test_non_200_errors(tmp_path, monkeypatch):
monkeypatch.setattr(ef, "_http_get", lambda *a, **k: _resp(status=404))
res = fetch_external("pixeldrain", "https://pixeldrain.com/u/x", tmp_path)
assert not res.ok
assert "404" in res.error
def test_transport_error_captured(tmp_path, monkeypatch):
def boom(*a, **k):
raise requests.ConnectionError("down")
monkeypatch.setattr(ef, "_http_get", boom)
res = fetch_external("dropbox", "https://www.dropbox.com/s/x?dl=0", tmp_path)
assert not res.ok
assert "transport" in res.error
def test_should_stop_aborts_and_cleans_part(tmp_path, monkeypatch):
monkeypatch.setattr(ef, "_http_get", lambda *a, **k: _resp(chunks=(b"a", b"b")))
res = fetch_external(
"pixeldrain", "https://pixeldrain.com/u/x", tmp_path,
should_stop=lambda: True,
)
assert not res.ok
assert list(tmp_path.glob("*.part")) == []
+203
View File
@@ -0,0 +1,203 @@
"""Integration tests for the external-link download worker (tasks/external)."""
import io
import zipfile
import pytest
from PIL import Image
from sqlalchemy import func, select
import backend.app.tasks.external as ext
from backend.app.models import (
Artist,
ExternalLink,
ImageProvenance,
Post,
PostAttachment,
Source,
)
from backend.app.services.external_fetch import FetchResult
pytestmark = pytest.mark.integration
def _structured_jpeg_bytes():
# A non-uniform image so it survives the import filters (a solid color would
# phash-collapse / fail thresholds).
im = Image.new("RGB", (256, 256))
px = im.load()
for y in range(256):
for x in range(256):
px[x, y] = (x, y, (x + y) % 256)
buf = io.BytesIO()
im.save(buf, "JPEG")
return buf.getvalue()
class _FakeLock:
def acquire(self, blocking=False):
return True
def release(self):
pass
class _FakeRedis:
def lock(self, name, timeout=None, blocking=False):
return _FakeLock()
def _seed(db_sync, *, host="pixeldrain", status="pending"):
# Key per host so a single test can seed several (artist name is UNIQUE).
artist = Artist(name=f"Ext {host}", slug=f"ext-{host}")
db_sync.add(artist)
db_sync.flush()
source = Source(
artist_id=artist.id, platform="patreon",
url=f"https://patreon.com/{host}", enabled=True, config_overrides={},
)
db_sync.add(source)
db_sync.flush()
post = Post(
source_id=source.id, artist_id=artist.id, external_post_id=f"EXT-{host}",
post_url=f"https://patreon.com/posts/{host}",
)
db_sync.add(post)
db_sync.flush()
link = ExternalLink(
post_id=post.id, artist_id=artist.id, host=host,
url=f"https://{host}.test/file", status=status,
)
db_sync.add(link)
db_sync.commit()
return post, link
def test_fetch_external_link_downloads_and_attaches(db_sync, tmp_path, monkeypatch):
post, link = _seed(db_sync)
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
def fake_fetch(host, url, dest_dir, *, timeout, should_stop=lambda: False):
dest_dir.mkdir(parents=True, exist_ok=True)
f = dest_dir / "film.bin" # non-art → PostAttachment (no thumb/ML enqueue)
f.write_bytes(b"a film pack")
return FetchResult(files=[f], bytes=f.stat().st_size)
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
out = ext.fetch_external_link(link_id)
assert out.get("files") == 1
db_sync.expire_all()
refreshed = db_sync.get(ExternalLink, link_id)
assert refreshed.status == "downloaded"
assert refreshed.completed_at is not None
# The file was captured as a PostAttachment linked to the SAME post.
atts = db_sync.execute(
select(PostAttachment).where(PostAttachment.post_id == post.id)
).scalars().all()
assert len(atts) == 1
def test_fetch_external_link_records_failure(db_sync, tmp_path, monkeypatch):
_, link = _seed(db_sync)
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
monkeypatch.setattr(
ext, "fetch_external",
lambda *a, **k: FetchResult(error="host 503"),
)
ext.fetch_external_link(link_id)
db_sync.expire_all()
refreshed = db_sync.get(ExternalLink, link_id)
assert refreshed.status == "failed"
assert refreshed.attempts == 1
assert "503" in refreshed.last_error
def test_fetch_external_link_dead_letters_at_threshold(db_sync, tmp_path, monkeypatch):
_, link = _seed(db_sync)
link.attempts = ext.DEAD_LETTER_THRESHOLD - 1
db_sync.commit()
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
monkeypatch.setattr(ext, "fetch_external", lambda *a, **k: FetchResult(error="nope"))
ext.fetch_external_link(link_id)
db_sync.expire_all()
assert db_sync.get(ExternalLink, link_id).status == "dead"
def test_fetch_external_link_skips_non_claimable(db_sync, tmp_path, monkeypatch):
_, link = _seed(db_sync, status="downloaded")
link_id = link.id
called = []
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
monkeypatch.setattr(
ext, "fetch_external",
lambda *a, **k: called.append(1) or FetchResult(),
)
out = ext.fetch_external_link(link_id)
assert out.get("skipped") == "not claimable"
assert called == [] # never fetched
def test_sweep_enqueues_pending_and_retryable(db_sync, monkeypatch):
_, l1 = _seed(db_sync, host="pixeldrain")
_, l2 = _seed(db_sync, host="mega", status="failed")
# A dead one must NOT be swept.
_, l3 = _seed(db_sync, host="dropbox", status="dead")
enqueued = []
monkeypatch.setattr(ext.fetch_external_link, "delay", lambda lid: enqueued.append(lid))
out = ext.sweep_external_links()
assert out["enqueued"] == 2
assert set(enqueued) == {l1.id, l2.id}
def test_downloaded_archive_gets_provenance_and_tagging(db_sync, tmp_path, monkeypatch):
"""An archive downloaded by the worker is extracted, provenance-linked to the
post, AND queued for tagging + thumbnails — exactly like an extracted zip on
the normal download path."""
post, link = _seed(db_sync, host="mega")
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
def fake_fetch(host, url, dest_dir, *, timeout, should_stop=lambda: False):
dest_dir.mkdir(parents=True, exist_ok=True)
zpath = dest_dir / "pack.zip"
with zipfile.ZipFile(zpath, "w") as z:
z.writestr("art.jpg", _structured_jpeg_bytes())
return FetchResult(files=[zpath], bytes=zpath.stat().st_size)
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
tagged, thumbed = [], []
import backend.app.tasks.ml as ml_mod
import backend.app.tasks.thumbnail as thumb_mod
monkeypatch.setattr(ml_mod.tag_and_embed, "delay", lambda i: tagged.append(i))
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: thumbed.append(i))
out = ext.fetch_external_link(link_id)
assert out["images"] >= 1
db_sync.expire_all()
assert db_sync.get(ExternalLink, link_id).status == "downloaded"
# Extracted member is provenance-linked to the SAME post (as extracted zips).
prov = db_sync.execute(
select(ImageProvenance).where(ImageProvenance.post_id == post.id)
).scalars().all()
assert len(prov) >= 1
member_ids = {p.image_record_id for p in prov}
# Tagging + thumbnails queued for exactly those member images.
assert set(tagged) == member_ids
assert set(thumbed) == member_ids
+58 -1
View File
@@ -1,4 +1,8 @@
from backend.app.utils.html_sanitize import sanitize_post_html
from backend.app.utils.html_sanitize import (
extract_img_srcs,
rewrite_img_srcs,
sanitize_post_html,
)
def test_none_and_blank_return_none():
@@ -36,3 +40,56 @@ def test_javascript_href_dropped():
out = sanitize_post_html('<a href="javascript:alert(1)">x</a>')
assert "javascript:" not in out
assert "x" in out # text preserved, dangerous href removed
def test_headings_lists_and_images_survive():
out = sanitize_post_html(
'<h2>Title</h2><p>x</p>'
'<img src="https://cdn.test/a.jpg" alt="art" width="200">'
'<ul><li>one</li></ul><blockquote>q</blockquote>'
)
assert "<h2>" in out
assert '<img' in out and 'src="https://cdn.test/a.jpg"' in out and 'alt="art"' in out
assert "<li>" in out and "<blockquote>" in out
def test_image_javascript_src_dropped():
out = sanitize_post_html('<img src="javascript:alert(1)" alt="x">')
assert "javascript:" not in out
# --- inline-image localization helpers (#830 Phase 2) ----------------------
def test_extract_img_srcs_in_order_deduped():
html = (
'<p>a</p><img src="https://cdn.test/1.jpg">'
'<img alt="x" src="https://cdn.test/2.jpg" width="10">'
'<img src="https://cdn.test/1.jpg">'
)
assert extract_img_srcs(html) == [
"https://cdn.test/1.jpg",
"https://cdn.test/2.jpg",
]
def test_extract_img_srcs_empty():
assert extract_img_srcs(None) == []
assert extract_img_srcs("<p>no images</p>") == []
def test_rewrite_img_srcs_swaps_only_mapped():
html = (
'<img src="https://cdn.test/1.jpg" alt="a">'
'<img src="https://cdn.test/2.jpg">'
)
out = rewrite_img_srcs(html, {"https://cdn.test/1.jpg": "/images/local/1.jpg"})
assert 'src="/images/local/1.jpg"' in out
assert 'alt="a"' in out # other attributes preserved
assert 'src="https://cdn.test/2.jpg"' in out # unmapped left alone
def test_rewrite_img_srcs_noop_on_empty():
html = '<img src="https://cdn.test/1.jpg">'
assert rewrite_img_srcs(html, {}) == html
assert rewrite_img_srcs(None, {"a": "b"}) is None
+76
View File
@@ -0,0 +1,76 @@
"""Unit tests for link_extract — pure, no DB/network."""
from backend.app.services.link_extract import extract_external_links, host_for
def test_host_classification_covers_all_and_rejects_others():
assert host_for("https://mega.nz/file/x#k") == "mega"
assert host_for("https://www.dropbox.com/s/x?dl=0") == "dropbox"
assert host_for("https://drive.google.com/file/d/ID/view") == "gdrive"
assert host_for("https://pixeldrain.com/u/abc") == "pixeldrain"
assert host_for("https://www.mediafire.com/file/x") == "mediafire"
assert host_for("https://example.com/x") is None
def test_anchor_preserves_fragment_and_label():
html = ('<p>Watch: '
'<a href="https://mega.nz/file/Bux0FRrZ#b2Pn9841">Mega - Streamable</a></p>')
links = extract_external_links(html)
assert len(links) == 1
assert links[0].host == "mega"
# The #fragment (mega's decryption key) MUST survive.
assert links[0].url == "https://mega.nz/file/Bux0FRrZ#b2Pn9841"
assert links[0].label == "Mega - Streamable"
def test_extracts_all_five_hosts():
html = (
'<a href="https://mega.nz/file/a#k">m</a>'
'<a href="https://drive.google.com/file/d/ID/view?usp=sharing">g</a>'
'<a href="https://www.mediafire.com/file/x/y">mf</a>'
'<a href="https://www.dropbox.com/s/x/y?dl=0">db</a>'
'<a href="https://pixeldrain.com/u/ems4UomN">px</a>'
)
hosts = {lnk.host for lnk in extract_external_links(html)}
assert hosts == {"mega", "gdrive", "mediafire", "dropbox", "pixeldrain"}
def test_bare_url_in_text():
html = "Full film: https://pixeldrain.com/u/ems4UomN and more prose."
links = extract_external_links(html)
assert len(links) == 1
assert links[0].host == "pixeldrain"
assert links[0].url == "https://pixeldrain.com/u/ems4UomN" # trailing '.' trimmed
assert links[0].label is None
def test_unwraps_patreon_redirect():
html = ('<a href="https://www.patreon.com/redirect?'
'url=https%3A%2F%2Fmega.nz%2Ffile%2Fz%23key">x</a>')
links = extract_external_links(html)
assert len(links) == 1
assert links[0].host == "mega"
assert links[0].url == "https://mega.nz/file/z#key"
def test_ignores_unsupported_and_dedups_anchor_label_wins():
html = (
'<a href="https://example.com/x">no</a>'
'<a href="https://mega.nz/file/dup#k">a</a>'
' bare https://mega.nz/file/dup#k'
)
links = extract_external_links(html)
assert len(links) == 1 # example.com dropped; mega dup collapsed
assert links[0].label == "a" # anchor (with label) wins over bare
def test_amp_escaped_query_is_unescaped():
html = '<a href="https://drive.google.com/uc?id=ID&amp;export=download">g</a>'
links = extract_external_links(html)
assert links[0].url == "https://drive.google.com/uc?id=ID&export=download"
def test_empty_inputs():
assert extract_external_links(None) == []
assert extract_external_links("") == []
assert extract_external_links("<p>no links here</p>") == []
+19
View File
@@ -3,12 +3,31 @@ from pathlib import Path
from backend.app.utils.paths import (
derive_subdir,
derive_top_level_artist,
filehash_from_url,
hash_suffixed_name,
)
IMPORT = Path("/import")
def test_filehash_from_url_extracts_lowercased_md5():
url = "https://c10.patreonusercontent.com/4/patreon-media/p/post/1/AbC123DeF456789012345678901234EF/1/img.png?token=x"
assert filehash_from_url(url) == "abc123def456789012345678901234ef"
def test_filehash_from_url_none_and_no_hash():
assert filehash_from_url(None) is None
assert filehash_from_url("") is None
assert filehash_from_url("https://example.test/a.png") is None
def test_filehash_from_url_query_unescaping_irrelevant():
# The hash sits in the path before the query, so entity-escaped ampersands
# in the query never affect the match (render-time matches escaped srcs).
base = "https://cdn.test/p/0123456789abcdef0123456789ABCDEF/x.jpg"
assert filehash_from_url(base + "?a=1&amp;b=2") == "0123456789abcdef0123456789abcdef"
def test_derive_subdir_nested():
assert derive_subdir(Path("/import/Alice/sub/x.png"), IMPORT) == "Alice/sub"
+67
View File
@@ -9,6 +9,7 @@ import json
from pathlib import Path
import pytest
import requests
from backend.app.services import patreon_client as pc_mod
from backend.app.services.patreon_client import (
@@ -352,3 +353,69 @@ def test_request_sleep_paces_before_fetch(monkeypatch):
monkeypatch.setattr(pc_mod.time, "sleep", lambda s: slept.append(s))
client._fetch("5555", None)
assert slept == [1.5]
# -- fetch_post_detail_content (best-effort full-body enrichment) ----------
def test_fetch_post_detail_content_returns_body(monkeypatch):
payload = {"data": {"id": "42", "attributes": {"content": "<p>full body</p>"}}}
captured: dict = {}
client = PatreonClient(cookies_path=None)
def _get(url, params=None, timeout=None):
captured["url"] = url
captured["params"] = params
return _FakeResp(200, json_data=payload)
monkeypatch.setattr(client._session, "get", _get)
assert client.fetch_post_detail_content("42") == "<p>full body</p>"
assert captured["url"].endswith("/api/posts/42")
assert captured["params"]["fields[post]"] == "content"
def test_fetch_post_detail_content_empty_body_is_none(monkeypatch):
payload = {"data": {"attributes": {"content": " "}}}
client = _client_returning(monkeypatch, _FakeResp(200, json_data=payload))
assert client.fetch_post_detail_content("42") is None
def test_fetch_post_detail_content_blank_id_skips_request(monkeypatch):
calls: list = []
client = PatreonClient(cookies_path=None)
monkeypatch.setattr(
client._session, "get",
lambda *a, **k: calls.append(1) or _FakeResp(200, json_data={}),
)
assert client.fetch_post_detail_content("") is None
assert calls == []
def test_fetch_post_detail_content_swallows_http_and_transport_errors(monkeypatch):
# Non-200 → None (best-effort; a missing body must never fail the walk).
client = _client_returning(monkeypatch, _FakeResp(500))
assert client.fetch_post_detail_content("42") is None
# Non-JSON body (HTML challenge) → None, not a raise.
client = _client_returning(monkeypatch, _FakeResp(200, raise_json=True))
assert client.fetch_post_detail_content("42") is None
# Transport error → None.
client = PatreonClient(cookies_path=None)
def _boom(*a, **k):
raise requests.ConnectionError("boom")
monkeypatch.setattr(client._session, "get", _boom)
assert client.fetch_post_detail_content("42") is None
# -- post_record_key (media-less post seen-ledger gate) --------------------
def test_post_record_key_returns_synthetic_key_and_id():
assert PatreonClient.post_record_key({"id": "987"}) == ("post:987", "987")
assert PatreonClient.post_record_key({"id": 987}) == ("post:987", "987")
def test_post_record_key_none_without_id():
assert PatreonClient.post_record_key({}) is None
assert PatreonClient.post_record_key({"id": None}) is None
+78
View File
@@ -7,6 +7,7 @@ via monkeypatching `_run_ytdlp`. PURE module → no DB, so unmarked (fast lane).
from __future__ import annotations
import json
from pathlib import Path
import pytest
@@ -174,6 +175,9 @@ def test_sidecar_written_and_findable(tmp_path):
assert data["id"] == "1001"
assert data["title"] == "My Post Title"
assert data["url"] == "https://www.patreon.com/posts/1001"
# #830 Phase 2: the per-media sidecar records THIS file's CDN URL so the
# importer can persist its filehash for inline-image localization.
assert data["source_url"] == "https://cdn.patreon.com/media1.png"
def test_skip_seen(tmp_path):
@@ -536,3 +540,77 @@ def test_range_ignored_by_server_refetches_clean(tmp_path, monkeypatch):
outcomes = dl.download_post(_post(), [_img("a.png")], "artist-x")
assert outcomes[0].status == "downloaded"
assert outcomes[0].path.read_bytes() == full # clean, not full[:400] + full
# -- content enrichment (adaptive detail-fetch of an empty feed body) -------
def test_enriches_empty_content_via_fetcher(tmp_path):
"""An empty feed body is backfilled from the detail-fetch seam — once per
post (memoized on the shared dict) — and lands in the importer sidecar."""
calls: list[str] = []
def _fetcher(post_id: str) -> str:
calls.append(post_id)
return "<p>full body</p>"
dl = PatreonDownloader(
images_root=tmp_path, cookies_path=None, validate=False,
session=_FakeSession(), content_fetcher=_fetcher,
)
post = _post()
post["attributes"]["content"] = "" # feed returned an empty body
dl.download_post(post, [_img("a.png"), _img("b.png")], "artist-x")
post_dir = tmp_path / "artist-x" / "patreon" / pd_mod._post_dir_name(post)
sc = find_sidecar(post_dir / "01_a.png")
assert sc is not None
assert json.loads(sc.read_text())["content"] == "<p>full body</p>"
# Fetched ONCE for the post, not once per media item.
assert calls == ["1001"]
def test_no_enrichment_when_feed_body_present(tmp_path):
"""A non-empty feed body is trusted as-is; the detail endpoint isn't hit."""
calls: list[str] = []
dl = PatreonDownloader(
images_root=tmp_path, cookies_path=None, validate=False,
session=_FakeSession(),
content_fetcher=lambda pid: calls.append(pid) or "x",
)
dl.download_post(_post(), [_img("a.png")], "artist-x") # _post body is non-empty
assert calls == []
# -- write_post_record (media-less / text-only post capture) ----------------
def test_write_post_record_writes_enriched_post_only_sidecar(tmp_path):
calls: list[str] = []
def _fetcher(post_id: str) -> str:
calls.append(post_id)
return "<p>text post body</p>"
dl = PatreonDownloader(
images_root=tmp_path, cookies_path=None, validate=False,
session=_FakeSession(), content_fetcher=_fetcher,
)
post = _post()
post["attributes"]["content"] = "" # media-less post, empty feed body
sc = dl.write_post_record(post, "artist-x")
assert sc is not None
assert sc.name == "_post.json" # can't collide with a media `NN_*.json`
data = json.loads(sc.read_text())
assert data["id"] == "1001"
assert data["content"] == "<p>text post body</p>"
assert calls == ["1001"]
def test_write_post_record_none_without_post_id(tmp_path):
dl = PatreonDownloader(
images_root=tmp_path, cookies_path=None, validate=False,
session=_FakeSession(),
)
assert dl.write_post_record({"id": "", "attributes": {}}, "artist-x") is None
+96 -5
View File
@@ -68,6 +68,11 @@ class _FakeClient:
def post_meta(self, post):
return {"title": post.get("id"), "date": None}
@staticmethod
def post_record_key(post):
pid = str(post.get("id") or "")
return (f"post:{pid}", pid) if pid else None
class _FakeDownloader:
"""Stub PatreonDownloader. Honors the injected is_seen (tier-1); media in
@@ -80,6 +85,7 @@ class _FakeDownloader:
self.quarantine = set(quarantine or ())
self.error = set(error or ())
self.download_calls = 0
self.post_records = 0
def download_post(self, post, media_items, artist_slug, *, is_seen,
should_stop=lambda: False):
@@ -106,6 +112,12 @@ class _FakeDownloader:
outcomes.append(MediaOutcome(media=m, status="downloaded", path=p, error=None))
return outcomes
def write_post_record(self, post, artist_slug):
self.post_records += 1
p = self.tmp_path / f"{post.get('id')}__post.json"
p.write_text("{}")
return p
@pytest.fixture
async def source_id(db):
@@ -161,7 +173,10 @@ async def test_tick_downloads_unseen_and_marks_seen(source_id, sync_engine, tmp_
# plan #704: structured run_stats carry the real counts.
assert result.run_stats["downloaded_count"] == 2
assert result.posts_processed == 1
assert _count_ledger(sync_engine, source_id) == 2
# 2 media keys + 1 synthetic post key (body/links recaptured per post).
assert _count_ledger(sync_engine, source_id) == 3
# The post body + links are captured for media posts too (rides the walk).
assert len(result.post_record_paths) == 1
@pytest.mark.asyncio
@@ -184,8 +199,9 @@ async def test_quarantined_media_surfaced_in_result(source_id, sync_engine, tmp_
assert result.run_stats["quarantined_count"] == 1
assert result.run_stats["downloaded_count"] == 1
assert len(result.written_paths) == 1 # quarantined NOT written
# Quarantined media is NOT marked seen (a fixed file may be re-fetched).
assert _count_ledger(sync_engine, source_id) == 1
# Quarantined media is NOT marked seen (a fixed file may be re-fetched);
# m1 + the synthetic post key (body/links captured per post) = 2.
assert _count_ledger(sync_engine, source_id) == 2
@pytest.mark.asyncio
@@ -538,8 +554,31 @@ async def test_recovery_tier2_disk_still_skips(source_id, sync_engine, tmp_path)
)
assert result.files_downloaded == 0
assert downloader.download_calls == 0
# Disk-skip still reconciles the ledger so a later tick skips at tier-1.
assert _count_ledger(sync_engine, source_id) == 1
# Disk-skip reconciles the media key + the synthetic post key (recovery
# recaptures the body/links per post) = 2.
assert _count_ledger(sync_engine, source_id) == 2
@pytest.mark.asyncio
async def test_backfill_recaptures_body_for_already_downloaded_post(
source_id, sync_engine, tmp_path,
):
"""Phase 5 guarantee: a post whose media is ALREADY on disk still gets its
body + external links recaptured on a re-walk. Re-downloading existing media
can never fill links the system never had — so the recapture rides the walk
itself, and a normal backfill is the backfill."""
m1 = _media("p1", 1)
client = _FakeClient([(None, [("p1", [m1])])])
downloader = _FakeDownloader(tmp_path, on_disk={_ledger_key(m1)})
ing = _ingester(sync_engine, tmp_path, client, downloader)
result = ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="backfill",
)
assert result.files_downloaded == 0 # media already on disk
assert len(result.post_record_paths) == 1 # body/links recaptured anyway
assert downloader.post_records == 1
# --- dead-letter ledger (plan #705 #7) ------------------------------------
@@ -743,3 +782,55 @@ async def test_verify_patreon_credential_delegates_to_client(monkeypatch):
ok, msg = await mod.verify_patreon_credential("https://patreon.com/x", None, {})
assert ok is True
assert msg == "ok:123"
# --- media-less (text-only) post capture ----------------------------------
@pytest.mark.asyncio
async def test_tick_captures_media_less_post_once(source_id, sync_engine, tmp_path):
"""A post with NO downloadable media still gets a post-only record, gated by
the seen-ledger (synthetic post key) so a second walk doesn't re-record it."""
client = _FakeClient([(None, [("ptext", [])])])
downloader = _FakeDownloader(tmp_path)
ing = _ingester(sync_engine, tmp_path, client, downloader)
result = ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick",
)
assert result.success is True
assert len(result.post_record_paths) == 1
assert downloader.post_records == 1
# The synthetic `post:ptext` key was marked seen (gates re-capture).
assert _count_ledger(sync_engine, source_id) == 1
# Second walk: already recorded → gated, no re-write, no new ledger row.
client2 = _FakeClient([(None, [("ptext", [])])])
downloader2 = _FakeDownloader(tmp_path)
ing2 = _ingester(sync_engine, tmp_path, client2, downloader2)
result2 = ing2.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick",
)
assert result2.post_record_paths == []
assert downloader2.post_records == 0
assert _count_ledger(sync_engine, source_id) == 1
@pytest.mark.asyncio
async def test_recovery_bypasses_gate_and_re_records(source_id, sync_engine, tmp_path):
"""Recovery bypasses the seen-ledger, so a media-less post is re-recorded
(matches recovery semantics for media)."""
client = _FakeClient([(None, [("ptext", [])])])
ing = _ingester(sync_engine, tmp_path, client, _FakeDownloader(tmp_path))
ing.run(source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick")
client2 = _FakeClient([(None, [("ptext", [])])])
dl2 = _FakeDownloader(tmp_path)
ing2 = _ingester(sync_engine, tmp_path, client2, dl2)
result = ing2.run(source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="recovery")
assert len(result.post_record_paths) == 1
assert dl2.post_records == 1
+96
View File
@@ -11,6 +11,7 @@ from sqlalchemy import select
from backend.app.models import (
Artist,
ExternalLink,
ImageProvenance,
ImageRecord,
Post,
@@ -419,6 +420,101 @@ async def test_get_post_returns_full_description(db):
assert len(item["description_full"]) > 280
@pytest.mark.asyncio
async def test_get_post_returns_sanitized_html_body(db):
artist = await _seed_artist(db, "alice-html")
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-html")
p = await _seed_post(
db, src.id, external_id="HTMLBODY", post_date=datetime.now(UTC),
description='<h2>Heading</h2><p>body</p><script>alert(1)</script>',
)
await db.commit()
item = await PostFeedService(db).get_post(p.id)
# Detail carries the sanitized HTML body for faithful rendering; <script> is
# stripped, semantic tags survive.
assert item["description_html"] is not None
assert "<h2>" in item["description_html"]
assert "<script>" not in item["description_html"]
@pytest.mark.asyncio
async def test_get_post_localizes_inline_images(db):
"""#830 Phase 2: a body <img src=CDN> whose filehash matches a stored image
of this artist is rewritten to the local /images path; an unmatched src is
left hotlinking."""
artist = await _seed_artist(db, "alice-inline")
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-inline")
fh = "0123456789abcdef0123456789abcdef"
matched = f"https://cdn.test/p/{fh}/img.png"
unmatched = "https://cdn.test/p/ffffffffffffffffffffffffffffffff/other.png"
p = await _seed_post(
db, src.id, external_id="INLINE", post_date=datetime.now(UTC),
description=f'<p>x</p><img src="{matched}"><img src="{unmatched}">',
)
db.add(ImageRecord(
path="/images/alice-inline/patreon/post/01_img.png",
sha256=f"{p.id:064d}",
size_bytes=10, mime="image/png", width=10, height=10,
origin="downloaded", primary_post_id=p.id, artist_id=artist.id,
source_url=matched, source_filehash=fh,
))
await db.commit()
item = await PostFeedService(db).get_post(p.id)
html = item["description_html"]
assert 'src="/images/alice-inline/patreon/post/01_img.png"' in html
assert matched not in html # CDN src swapped out
assert unmatched in html # no local copy → still hotlinked
@pytest.mark.asyncio
async def test_get_post_inline_image_scoped_to_artist(db):
"""A matching filehash owned by a DIFFERENT artist must not leak into this
post's body — localization is artist-scoped."""
a1 = await _seed_artist(db, "owner-art")
a2 = await _seed_artist(db, "other-art")
src = await _seed_source(db, a1.id, "patreon", "https://p/owner-art")
fh = "abcabcabcabcabcabcabcabcabcabc12"
cdn = f"https://cdn.test/p/{fh}/x.png"
p = await _seed_post(
db, src.id, external_id="SCOPED", post_date=datetime.now(UTC),
description=f'<img src="{cdn}">',
)
# The only image with this filehash belongs to a DIFFERENT artist.
db.add(ImageRecord(
path="/images/other/x.png", sha256=f"{p.id:064d}",
size_bytes=10, mime="image/png", width=10, height=10,
origin="downloaded", artist_id=a2.id,
source_url=cdn, source_filehash=fh,
))
await db.commit()
item = await PostFeedService(db).get_post(p.id)
assert cdn in item["description_html"] # not localized (wrong artist)
@pytest.mark.asyncio
async def test_get_post_returns_external_links(db):
artist = await _seed_artist(db, "alice-extlinks")
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-extlinks")
p = await _seed_post(
db, src.id, external_id="EXTLINKS", post_date=datetime.now(UTC),
)
db.add(ExternalLink(
post_id=p.id, artist_id=artist.id, host="mega",
url="https://mega.nz/file/x#k", label="Mega",
))
await db.commit()
item = await PostFeedService(db).get_post(p.id)
assert len(item["external_links"]) == 1
link = item["external_links"][0]
assert link["host"] == "mega"
assert link["url"] == "https://mega.nz/file/x#k"
assert link["status"] == "pending"
@pytest.mark.asyncio
async def test_get_post_unknown_returns_none(db):
item = await PostFeedService(db).get_post(99999)
+109 -2
View File
@@ -8,6 +8,8 @@ from PIL import Image
from sqlalchemy import func, select
from backend.app.models import (
Artist,
ExternalLink,
ImageProvenance,
ImageRecord,
ImportSettings,
@@ -99,6 +101,25 @@ def test_sidecar_creates_provenance(importer, import_layout):
assert rec.effective_date == post.post_date
def test_sidecar_source_url_persists_filehash(importer, import_layout):
"""#830 Phase 2: a media sidecar's source_url lands on the ImageRecord as
source_url + the lowercased CDN filehash (the inline-image join key)."""
import_root, _ = import_layout
m = import_root / "Alice" / "b.jpg"
_split(m, "v")
_sidecar(m, {
"category": "patreon", "id": 901, "title": "Body post",
"source_url": "https://cdn.test/p/0123456789ABCDEF0123456789abcdef/b.jpg",
})
r = importer.import_one(m)
assert r.status == "imported"
rec = importer.session.get(ImageRecord, r.image_id)
assert rec.source_url == (
"https://cdn.test/p/0123456789ABCDEF0123456789abcdef/b.jpg"
)
assert rec.source_filehash == "0123456789abcdef0123456789abcdef"
def test_reimport_same_post_idempotent(importer, import_layout):
import_root, _ = import_layout
# Threshold 0: only an exact phash match collapses. Orthogonal splits
@@ -164,8 +185,6 @@ def test_no_artist_anywhere_skips_provenance(importer, import_layout):
def test_sidecar_artist_used_when_no_folder_artist(importer, import_layout):
from backend.app.models import Artist
import_root, _ = import_layout
m = import_root / "e.jpg" # root → no folder artist
_split(m, "v")
@@ -183,3 +202,91 @@ def test_sidecar_artist_used_when_no_folder_artist(importer, import_layout):
assert importer.session.execute(
select(func.count()).select_from(Source)
).scalar_one() == 0
def test_upsert_post_record_creates_media_less_post(importer, import_layout):
"""A post-only sidecar (no media) upserts a Post WITH its body — text posts
are captured even when they have nothing to download."""
import_root, _ = import_layout
artist = Artist(name="Alice", slug="alice")
importer.session.add(artist)
importer.session.flush()
sc = import_root / "Alice" / "_post.json"
sc.parent.mkdir(parents=True, exist_ok=True)
sc.write_text(json.dumps({
"category": "patreon", "id": 777,
"url": "https://patreon.com/posts/777", "title": "Text only",
"content": "<p>links below: mega</p>",
"published_at": "2023-08-01T00:00:00Z",
}))
assert importer.upsert_post_record(sc, artist=artist) is True
post = importer.session.execute(select(Post)).scalar_one()
assert post.external_post_id == "777"
assert post.post_title == "Text only"
assert post.description == "<p>links below: mega</p>"
assert post.post_url == "https://patreon.com/posts/777"
assert post.post_date is not None
def test_upsert_post_record_idempotent_no_double(importer, import_layout):
"""Re-running upsert_post_record UPDATES the same Post (keyed on
external_post_id) — never doubles."""
import_root, _ = import_layout
artist = Artist(name="Bob", slug="bob")
importer.session.add(artist)
importer.session.flush()
sc = import_root / "Bob" / "_post.json"
sc.parent.mkdir(parents=True, exist_ok=True)
sc.write_text(json.dumps({
"category": "patreon", "id": 888, "title": "T",
"content": "<p>body</p>", "url": "https://patreon.com/posts/888",
}))
assert importer.upsert_post_record(sc, artist=artist) is True
assert importer.upsert_post_record(sc, artist=artist) is True
assert importer.session.execute(
select(func.count()).select_from(Post)
).scalar_one() == 1
def test_external_links_recorded_from_body(importer, import_layout):
"""A mega/gdrive/etc. link in the body is recorded as an external_link row
(status pending) so it's never silently dropped."""
import_root, _ = import_layout
artist = Artist(name="Linker", slug="linker")
importer.session.add(artist)
importer.session.flush()
sc = import_root / "Linker" / "_post.json"
sc.parent.mkdir(parents=True, exist_ok=True)
sc.write_text(json.dumps({
"category": "patreon", "id": 999, "title": "Film",
"url": "https://patreon.com/posts/999",
"content": '<p>Get it: <a href="https://mega.nz/file/AbC#key123">Mega</a></p>',
}))
assert importer.upsert_post_record(sc, artist=artist) is True
importer.session.expire_all() # re-read so the server_default status loads
links = importer.session.execute(select(ExternalLink)).scalars().all()
assert len(links) == 1
assert links[0].host == "mega"
assert links[0].url == "https://mega.nz/file/AbC#key123"
assert links[0].label == "Mega"
assert links[0].status == "pending"
def test_external_links_not_duplicated_on_reimport(importer, import_layout):
"""Re-importing the same body keeps ONE external_link row (insert-missing)."""
import_root, _ = import_layout
artist = Artist(name="Linker2", slug="linker2")
importer.session.add(artist)
importer.session.flush()
sc = import_root / "Linker2" / "_post.json"
sc.parent.mkdir(parents=True, exist_ok=True)
sc.write_text(json.dumps({
"category": "patreon", "id": 1000, "title": "Film",
"url": "https://patreon.com/posts/1000",
"content": '<a href="https://pixeldrain.com/u/xyz">px</a>',
}))
assert importer.upsert_post_record(sc, artist=artist) is True
assert importer.upsert_post_record(sc, artist=artist) is True
assert importer.session.execute(
select(func.count()).select_from(ExternalLink)
).scalar_one() == 1
+9
View File
@@ -119,6 +119,15 @@ def test_parse_message_used_as_description_fallback():
assert sd.description == "hello channel"
def test_parse_source_url_present_and_absent():
"""The per-media sidecar carries a `source_url` (#830 Phase 2); a post-only
sidecar (no media file) omits it → None."""
sd = parse_sidecar({"category": "patreon", "id": "1",
"source_url": "https://cdn.test/abc.png"})
assert sd.source_url == "https://cdn.test/abc.png"
assert parse_sidecar({"category": "patreon", "id": "1"}).source_url is None
def test_parse_date_epoch_and_unparseable_and_naive():
assert parse_sidecar({"timestamp": 1690857602}).post_date.tzinfo is not None
assert parse_sidecar({"date": "not-a-date"}).post_date is None