Files
FabledCurator/backend/app/services/source_service.py
T
bvandeusen a69bd1baa8
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m36s
feat(artist): move a source into another artist (#130 step 4)
Operator ask: a surface to merge new sources into existing artists (consolidate
the singleton artist a fresh add spins up). Enabled by the #130 slug decoupling —
the storage path is immutable, so re-attribution moves NO files. SourceService
.reassign moves the source, re-points its posts (Post.source_id==S) and the
images it contributed (ImageProvenance via S, scoped to the old artist so shared
images aren't stolen), and deletes the old artist if it's left fully empty (else
clears its subscription flag). POST /api/sources/<id>/reassign. Frontend: a
'Move…' action per source on the artist Management tab → artist-autocomplete
picker → confirm → routes to the target (whose slug is stable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
2026-07-04 22:15:38 -04:00

514 lines
22 KiB
Python

"""FC-3a: CRUD over Source rows, with platform/url/config validation and
is_subscription auto-flip on first add / last delete.
"""
from dataclasses import dataclass
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import (
Artist,
ImageProvenance,
ImageRecord,
ImportSettings,
Post,
Source,
)
from .platforms import known_platform_keys
from .scheduler_service import compute_next_check_at
# Re-exported for back-compat with FC-3a; the registry in
# services/platforms.py is the single source of truth.
KNOWN_PLATFORMS = known_platform_keys()
# --- Errors -----------------------------------------------------------------
class SourceServiceError(Exception):
"""Base."""
class ArtistNotFoundError(SourceServiceError):
pass
class UnknownPlatformError(SourceServiceError):
def __init__(self, platform: str):
super().__init__(f"unknown platform: {platform!r}")
self.platform = platform
class InvalidConfigError(SourceServiceError):
pass
class EmptyUrlError(SourceServiceError):
pass
class DuplicateSourceError(SourceServiceError):
def __init__(self, existing_id: int):
super().__init__(f"duplicate source (existing id={existing_id})")
self.existing_id = existing_id
@dataclass(frozen=True)
class SourceRecord:
id: int
artist_id: int
artist_name: str
artist_slug: str
platform: str
url: str
enabled: bool
config_overrides: dict | None
last_checked_at: str | None
last_error: str | None
error_type: str | None
check_interval_override: int | None
consecutive_failures: int
next_check_at: str | None
backfill_runs_remaining: int
# plan #693: derived from config_overrides for the UI badge.
backfill_state: str | None # "running" | "complete" | "stalled" | None (idle)
backfill_chunks: int
# plan #697: a running deep-walk that bypasses the Patreon seen-ledger
# (recovery) vs. a normal backfill. Lets the badge label it "Recovering".
backfill_bypass_seen: bool
# #830: a running deep-walk in RECAPTURE mode (re-grab post bodies/links +
# localize on-disk inline images, no media re-download). Lets the badge label
# it "Recapturing".
backfill_recapture: bool
# plan #704: cumulative posts processed across the walk's chunks — live
# progress for the badge.
backfill_posts: int
def to_dict(self) -> dict:
return {
"id": self.id,
"artist_id": self.artist_id,
"artist_name": self.artist_name,
"artist_slug": self.artist_slug,
"platform": self.platform,
"url": self.url,
"enabled": self.enabled,
"config_overrides": self.config_overrides,
"last_checked_at": self.last_checked_at,
"last_error": self.last_error,
"error_type": self.error_type,
"check_interval_override": self.check_interval_override,
"consecutive_failures": self.consecutive_failures,
"next_check_at": self.next_check_at,
"backfill_runs_remaining": self.backfill_runs_remaining,
"backfill_state": self.backfill_state,
"backfill_chunks": self.backfill_chunks,
"backfill_bypass_seen": self.backfill_bypass_seen,
"backfill_recapture": self.backfill_recapture,
"backfill_posts": self.backfill_posts,
}
# --- Service ----------------------------------------------------------------
_EDITABLE = {"enabled", "url", "config_overrides", "check_interval_override", "platform"}
# Plan #693: backfill safety cap. "Start backfill" (and a newly created
# enabled source) arms a run-until-done walk; this caps how many time-boxed
# chunks it may spend before pausing as "stalled", so a pathological walk that
# never reaches the bottom can't run forever. Generous on purpose — at
# BACKFILL_CHUNK_SECONDS (600s) per chunk this is ~33h of cumulative walk, far
# beyond any real catalog; the cursor stall-guard is the real terminator.
BACKFILL_MAX_CHUNKS = 200
class SourceService:
def __init__(self, session: AsyncSession):
self.session = session
async def _artist_or_raise(self, artist_id: int) -> Artist:
row = (await self.session.execute(
select(Artist).where(Artist.id == artist_id)
)).scalar_one_or_none()
if row is None:
raise ArtistNotFoundError(f"artist id={artist_id} not found")
return row
@staticmethod
def _validate_platform(platform: str) -> str:
if platform not in KNOWN_PLATFORMS:
raise UnknownPlatformError(platform)
return platform
@staticmethod
def _validate_url(url: str) -> str:
cleaned = (url or "").strip()
if not cleaned:
raise EmptyUrlError("url must not be empty")
return cleaned
@staticmethod
def _validate_config(config: object) -> dict | None:
if config is None:
return None
if not isinstance(config, dict):
raise InvalidConfigError("config_overrides must be a JSON object")
return config
async def _load_settings(self) -> ImportSettings:
return await ImportSettings.load(self.session)
def _build_record(
self, source: Source, artist: Artist, settings: ImportSettings,
) -> SourceRecord:
nxt = compute_next_check_at(source, artist, settings)
co = source.config_overrides or {}
return SourceRecord(
id=source.id,
artist_id=source.artist_id,
artist_name=artist.name,
artist_slug=artist.slug,
platform=source.platform,
url=source.url,
enabled=source.enabled,
config_overrides=source.config_overrides,
last_checked_at=source.last_checked_at.isoformat() if source.last_checked_at else None,
last_error=source.last_error,
error_type=source.error_type,
check_interval_override=source.check_interval_override,
consecutive_failures=source.consecutive_failures or 0,
next_check_at=nxt.isoformat() if nxt else None,
backfill_runs_remaining=source.backfill_runs_remaining or 0,
backfill_state=co.get("_backfill_state"),
backfill_chunks=int(co.get("_backfill_chunks", 0)),
backfill_bypass_seen=bool(co.get("_backfill_bypass_seen")),
backfill_recapture=bool(co.get("_backfill_recapture")),
backfill_posts=int(co.get("_backfill_posts", 0)),
)
async def _row_to_record(self, source: Source) -> SourceRecord:
artist = (await self.session.execute(
select(Artist).where(Artist.id == source.artist_id)
)).scalar_one()
settings = await self._load_settings()
return self._build_record(source, artist, settings)
async def list(
self, artist_id: int | None = None, failing: bool = False,
include_synthetic: bool = False,
) -> list[SourceRecord]:
stmt = select(Source, Artist).join(Artist, Artist.id == Source.artist_id)
if artist_id is not None:
stmt = stmt.where(Source.artist_id == artist_id)
if not include_synthetic:
# Pre-alembic-0030 sidecar synthetic anchors
# have url='sidecar:<platform>:<slug>' and exist only to give
# imported Posts a NOT-NULL Source FK. They aren't pollable
# feeds; the Subscriptions UI used to render them as phantom
# subscriptions. Hide by default.
stmt = stmt.where(~Source.url.like("sidecar:%"))
if failing:
# Worst-first so the rollup card surfaces the loudest failures.
stmt = stmt.where(Source.consecutive_failures > 0).order_by(
Source.consecutive_failures.desc(), Artist.name.asc(),
)
else:
stmt = stmt.order_by(Artist.name.asc(), Source.id.asc())
rows = (await self.session.execute(stmt)).all()
settings = await self._load_settings()
return [self._build_record(s, a, settings) for s, a in rows]
async def get(self, source_id: int) -> SourceRecord | None:
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
return None
return await self._row_to_record(source)
async def create(
self,
*,
artist_id: int,
platform: str,
url: str,
enabled: bool = True,
config_overrides: dict | None = None,
check_interval_override: int | None = None,
) -> SourceRecord:
await self._artist_or_raise(artist_id)
platform = self._validate_platform(platform)
url = self._validate_url(url)
config_overrides = self._validate_config(config_overrides)
prior_count = (await self.session.execute(
select(func.count(Source.id)).where(Source.artist_id == artist_id)
)).scalar_one()
# Plan #693: a freshly added subscription has no archive yet, so it
# should walk its full post history once. Arm run-until-done backfill
# (state="running" + the chunk cap); the time-boxed chunks march to the
# bottom across ticks, then flip to "complete" and tick mode takes over.
# Disabled sources (incl. sidecar synthetics, url='sidecar:...') are
# never polled, so leave them idle.
if enabled:
config_overrides = {**(config_overrides or {}), "_backfill_state": "running"}
backfill_runs = BACKFILL_MAX_CHUNKS
else:
backfill_runs = 0
source = Source(
artist_id=artist_id, platform=platform, url=url,
enabled=enabled, config_overrides=config_overrides,
check_interval_override=check_interval_override,
backfill_runs_remaining=backfill_runs,
)
self.session.add(source)
try:
await self.session.flush()
except IntegrityError as exc:
await self.session.rollback()
existing = (await self.session.execute(
select(Source.id).where(
Source.artist_id == artist_id,
Source.platform == platform,
Source.url == url,
)
)).scalar_one()
raise DuplicateSourceError(existing_id=existing) from exc
if prior_count == 0:
await self.session.execute(
Artist.__table__.update()
.where(Artist.id == artist_id)
.values(is_subscription=True)
)
await self.session.commit()
return await self._row_to_record(source)
async def update(self, source_id: int, **fields) -> SourceRecord:
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
raise LookupError(f"source id={source_id} not found")
for key in list(fields.keys()):
if key not in _EDITABLE:
fields.pop(key)
if "platform" in fields:
fields["platform"] = self._validate_platform(fields["platform"])
if "url" in fields:
fields["url"] = self._validate_url(fields["url"])
if "config_overrides" in fields:
fields["config_overrides"] = self._validate_config(fields["config_overrides"])
for key, value in fields.items():
setattr(source, key, value)
# Disabling a source clears its failure state (operator: disable the subs
# you're not paying for without them lingering as "failing"). Re-enabling
# then starts clean; the next real run re-derives health. Only on the
# explicit disable — an unrelated edit to an already-disabled source
# leaves its (already-clear) state alone.
if fields.get("enabled") is False:
source.last_error = None
source.error_type = None
source.consecutive_failures = 0
try:
await self.session.flush()
except IntegrityError as exc:
await self.session.rollback()
existing = (await self.session.execute(
select(Source.id).where(
Source.artist_id == source.artist_id,
Source.platform == source.platform,
Source.url == source.url,
Source.id != source.id,
)
)).scalar_one()
raise DuplicateSourceError(existing_id=existing) from exc
await self.session.commit()
return await self._row_to_record(source)
async def start_backfill(self, source_id: int) -> SourceRecord:
"""Plan #693: arm a run-until-done backfill. Sets state="running" and
the chunk cap; download runs then walk the full post history in
time-boxed chunks (skip:True + BACKFILL_CHUNK_SECONDS), resuming from
the cursor each chunk, until gallery-dl reaches the bottom (→ state
"complete") or the cap/stall-guard pauses it (→ "stalled"). Clears any
prior cursor/chunk/stall state so a re-start walks fresh from the top."""
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
raise LookupError(f"source id={source_id} not found")
co = dict(source.config_overrides or {})
co["_backfill_state"] = "running"
for k in ("_backfill_cursor", "_backfill_cursor_stalls", "_backfill_chunks",
"_backfill_posts"):
co.pop(k, None)
source.config_overrides = co
source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS
await self.session.commit()
return await self._row_to_record(source)
async def start_recovery(self, source_id: int) -> SourceRecord:
"""Plan #697: arm a RECOVERY walk — a backfill that bypasses the Patreon
seen-ledger so deliberately-dropped-and-deleted near-dups get re-fetched
and re-evaluated under the CURRENT pHash threshold (tier-2 disk still
spares files we kept). Reuses the entire #693 backfill state machine
(time-boxed chunks, cursor checkpoint, complete/stall lifecycle) plus the
`_backfill_bypass_seen` flag that flips download mode to recovery. Clears
any prior cursor/chunk/stall state so it walks fresh from the top. The
flag is cleared on completion (download_service) and on stop.
Recovery is Patreon-only (the seen-ledger is Patreon's); for other
platforms the flag is inert (download_service ignores it) and the walk
runs as a plain backfill. The UI gates the action to Patreon sources."""
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
raise LookupError(f"source id={source_id} not found")
co = dict(source.config_overrides or {})
co["_backfill_state"] = "running"
co["_backfill_bypass_seen"] = True
for k in ("_backfill_cursor", "_backfill_cursor_stalls", "_backfill_chunks",
"_backfill_posts"):
co.pop(k, None)
source.config_overrides = co
source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS
await self.session.commit()
return await self._row_to_record(source)
async def start_recapture(self, source_id: int) -> SourceRecord:
"""#830: arm a RECAPTURE walk — a backfill that re-grabs EVERY post's body
+ external links (detail-fetching empty bodies) and localizes already-on-
disk inline images, WITHOUT re-downloading media. Reuses the entire #693
backfill state machine plus a `_backfill_recapture` flag that flips
download mode to recapture. Distinct from recovery (which re-downloads the
whole source); the two flags are mutually exclusive, so arming recapture
clears bypass_seen. Clears prior cursor/chunk/stall state so it walks
fresh from the top. The flag is cleared on completion (download_service)
and on stop. Recapture is Patreon-only (the native ingester's post-record
capture); inert elsewhere. The UI gates the action to Patreon sources."""
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
raise LookupError(f"source id={source_id} not found")
co = dict(source.config_overrides or {})
co["_backfill_state"] = "running"
co["_backfill_recapture"] = True
co.pop("_backfill_bypass_seen", None) # mutually exclusive with recovery
for k in ("_backfill_cursor", "_backfill_cursor_stalls", "_backfill_chunks",
"_backfill_posts"):
co.pop(k, None)
source.config_overrides = co
source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS
await self.session.commit()
return await self._row_to_record(source)
async def stop_backfill(self, source_id: int) -> SourceRecord:
"""Plan #693: cancel an in-progress backfill — back to idle/tick mode.
Clears the running state + cursor/chunk/stall bookkeeping."""
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
raise LookupError(f"source id={source_id} not found")
co = dict(source.config_overrides or {})
for k in ("_backfill_state", "_backfill_cursor", "_backfill_cursor_stalls",
"_backfill_chunks", "_backfill_bypass_seen", "_backfill_recapture",
"_backfill_posts"):
co.pop(k, None)
source.config_overrides = co
source.backfill_runs_remaining = 0
await self.session.commit()
return await self._row_to_record(source)
async def reassign(self, source_id: int, target_artist_id: int) -> SourceRecord:
"""Move a Source — and the content it brought in — to another artist
(#130). The slug/storage path is IMMUTABLE, so NO files move: only the
artist attribution changes (reads use ImageRecord.path). Re-points the
source's Posts and the ImageRecords it contributed (those still
attributed to the old artist — images shared with another artist are
left alone). If the old artist is left fully empty (no sources, images,
or posts) it's deleted (ArtistVisit cascades); if it just lost its last
source, its is_subscription flag clears. No-op when already on target."""
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
raise LookupError(f"source id={source_id} not found")
target = await self._artist_or_raise(target_artist_id)
old_artist_id = source.artist_id
if old_artist_id == target_artist_id:
return await self._row_to_record(source)
source.artist_id = target_artist_id
target.is_subscription = True
# Re-attribute this source's posts (Post.artist_id is denormalized).
await self.session.execute(
update(Post).where(Post.source_id == source_id)
.values(artist_id=target_artist_id)
)
# Re-attribute the images this source contributed that are still on the
# OLD artist. Scoping to artist_id == old avoids stealing an image that
# a different artist's source also contributed.
contributed = select(ImageProvenance.image_record_id).where(
ImageProvenance.source_id == source_id
)
await self.session.execute(
update(ImageRecord)
.where(
ImageRecord.artist_id == old_artist_id,
ImageRecord.id.in_(contributed),
)
.values(artist_id=target_artist_id)
)
await self.session.flush()
old = (await self.session.execute(
select(Artist).where(Artist.id == old_artist_id)
)).scalar_one_or_none()
if old is not None:
n_src = (await self.session.execute(
select(func.count(Source.id)).where(Source.artist_id == old_artist_id)
)).scalar_one()
n_img = (await self.session.execute(
select(func.count(ImageRecord.id)).where(
ImageRecord.artist_id == old_artist_id
)
)).scalar_one()
n_post = (await self.session.execute(
select(func.count(Post.id)).where(Post.artist_id == old_artist_id)
)).scalar_one()
if n_src == 0 and n_img == 0 and n_post == 0:
await self.session.delete(old) # ArtistVisit cascades
elif n_src == 0:
old.is_subscription = False
await self.session.commit()
return await self._row_to_record(source)
async def delete(self, source_id: int) -> None:
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
raise LookupError(f"source id={source_id} not found")
artist_id = source.artist_id
await self.session.delete(source)
await self.session.flush()
remaining = (await self.session.execute(
select(func.count(Source.id)).where(Source.artist_id == artist_id)
)).scalar_one()
if remaining == 0:
await self.session.execute(
Artist.__table__.update()
.where(Artist.id == artist_id)
.values(is_subscription=False)
)
await self.session.commit()