"""FC-3g: backend support for the Firefox extension. `ExtensionService.quick_add_source(url)` derives platform + artist slug from a URL using regex patterns mirrored from extension/lib/platforms.js, then find-or-creates Artist + Source rows and returns a JSON-shaped dict for the API layer. """ from __future__ import annotations import re from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ..models import Artist, Source from ..utils.slug import slugify from .source_service import BACKFILL_MAX_CHUNKS class UnknownPlatformError(Exception): """URL didn't match any platform pattern.""" class InvalidUrlError(Exception): """URL was empty or missing a scheme.""" # Mirrored byte-for-byte from extension/lib/platforms.js # PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand — # reviewers catch drift. _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ("patreon", re.compile( r"^https?://(?:www\.)?patreon\.com/" r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)" r"(?P[^/?#]+)/?$", re.IGNORECASE, )), ("subscribestar", re.compile( r"^https?://(?:www\.)?subscribestar\.(?:com|adult)/" r"(?!feed$|messages$|library$)" r"(?P[^/?#]+)/?$", re.IGNORECASE, )), ("hentaifoundry", re.compile( r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P[^/?#]+)", re.IGNORECASE, )), ("deviantart", re.compile( r"^https?://(?:www\.)?deviantart\.com/" r"(?!home$|watch\b|tag\b|browse\b)" r"(?P[^/?#]+)/?$", re.IGNORECASE, )), ("pixiv", re.compile( r"^https?://(?:www\.)?pixiv\.net/(?:en/)?users/(?P\d+)", re.IGNORECASE, )), ] class ExtensionService: def __init__(self, session: AsyncSession) -> None: self.session = session async def quick_add_source(self, url: str) -> dict: platform, raw_slug = self._derive(url) artist, created_artist = await self._find_or_create_artist(raw_slug) source, created_source = await self._find_or_create_source( artist_id=artist.id, platform=platform, url=url, ) return { "source": { "id": source.id, "artist_id": source.artist_id, "platform": source.platform, "url": source.url, "enabled": source.enabled, }, "artist": { "id": artist.id, "name": artist.name, "slug": artist.slug, }, "created_source": created_source, "created_artist": created_artist, } async def probe(self, url: str) -> dict: """Read-only resolution of a creator-page URL against the FC DB. Returns one of: - {state: 'unknown_platform'} — URL didn't match any platform's strict artist-page pattern - {state: 'new', platform, slug} — would create both artist and source on quick-add - {state: 'artist_match', platform, slug, artist} — artist exists, this exact URL isn't a Source yet (collapses the sidecar-synthetic case too — the synthetic anchor counts as an existing artist row but not as a pollable Source for this URL) - {state: 'source_match', platform, slug, artist, source} — exact (artist, platform, url) Source already exists Side-effect-free: two SELECTs at most. """ try: platform, raw_slug = self._derive(url) except (UnknownPlatformError, InvalidUrlError): return {"state": "unknown_platform"} slug = slugify(raw_slug) artist = (await self.session.execute( select(Artist).where(Artist.slug == slug) )).scalar_one_or_none() if artist is None: return {"state": "new", "platform": platform, "slug": slug} artist_payload = {"id": artist.id, "name": artist.name, "slug": artist.slug} source = (await self.session.execute( select(Source).where( Source.artist_id == artist.id, Source.platform == platform, Source.url == url, ) )).scalar_one_or_none() if source is None: return { "state": "artist_match", "platform": platform, "slug": slug, "artist": artist_payload, } return { "state": "source_match", "platform": platform, "slug": slug, "artist": artist_payload, "source": { "id": source.id, "artist_id": source.artist_id, "platform": source.platform, "url": source.url, "enabled": source.enabled, }, } def _derive(self, url: str) -> tuple[str, str]: if not isinstance(url, str) or not url.strip(): raise InvalidUrlError("url is empty") if not url.startswith(("http://", "https://")): raise InvalidUrlError(f"url must include http:// or https:// scheme: {url!r}") for platform, pattern in _PLATFORM_PATTERNS: m = pattern.match(url) if m: return platform, m.group("slug") raise UnknownPlatformError(f"no platform pattern matched {url!r}") async def _find_or_create_artist(self, raw_name: str) -> tuple[Artist, bool]: """Race-safe find-or-create on Artist by slug. Mirrors the savepoint + IntegrityError recovery pattern used in Importer._find_or_create_source/post (see reference_scalar_one_or_none_duplicates memory). Without this, two concurrent quick-add-source calls hitting the same artist would both miss the existence check and the second INSERT would 500 against uq_artist_slug. """ slug = slugify(raw_name) existing = (await self.session.execute( select(Artist).where(Artist.slug == slug) )).scalar_one_or_none() if existing is not None: return existing, False sp = await self.session.begin_nested() try: artist = Artist(name=raw_name, slug=slug, is_subscription=True) self.session.add(artist) await self.session.flush() await sp.commit() return artist, True except IntegrityError: await sp.rollback() recovered = (await self.session.execute( select(Artist).where(Artist.slug == slug) )).scalar_one() return recovered, False async def _find_or_create_source( self, *, artist_id: int, platform: str, url: str, ) -> tuple[Source, bool]: """Race-safe — same pattern as _find_or_create_artist above. The uq_source_artist_platform_url constraint catches the duplicate insert; we roll the savepoint back and re-select.""" existing = (await self.session.execute( select(Source).where( Source.artist_id == artist_id, Source.platform == platform, Source.url == url, ) )).scalar_one_or_none() if existing is not None: return existing, False sp = await self.session.begin_nested() try: # New subscription sources arm run-until-done backfill (plan #693) # so the first ticks walk the full history (otherwise gallery-dl's # exit:20 short-circuits before the archive is built). Mirrors # SourceService.create — without it, Firefox quick-add on a creator # with >20 unsynced posts would surface as "check failed" with no # diagnosis. Audit 2026-06-02. src = Source( artist_id=artist_id, platform=platform, url=url, enabled=True, config_overrides={"_backfill_state": "running"}, backfill_runs_remaining=BACKFILL_MAX_CHUNKS, ) self.session.add(src) await self.session.flush() await sp.commit() except IntegrityError: await sp.rollback() recovered = (await self.session.execute( select(Source).where( Source.artist_id == artist_id, Source.platform == platform, Source.url == url, ) )).scalar_one() return recovered, False await self.session.commit() return src, True