a69bd1baa8
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
514 lines
22 KiB
Python
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()
|