d65f0b2091
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 20s
extension / lint (push) Successful in 13s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m41s
Operator-asked 2026-05-31 (during sidecar synthetic anchor cleanup): "the add source/subscription button idea to the firefox extension so it can tell me if a source/artist is added or not and offer an option to add it if it isn't." Plan tracked in Scribe task #507. ## Backend - `ExtensionService.probe(url)` — read-only resolution. Reuses `_derive` for platform+slug, then 2 SELECTs. Returns one of: - `source_match` (exact (artist, platform, url) Source exists) - `artist_match` (artist exists, this URL isn't a Source yet; collapses the sidecar-synthetic-only case from v26.06.01.0) - `new` (neither exists) - `unknown_platform` (URL didn't match any artist-page regex) - `GET /api/extension/probe?url=...` route with `X-Extension-Key` auth posture matching `/quick-add-source`. Read-only, side-effect free. - 6 backend tests in tests/test_api_extension.py covering each state + auth + invalid URL. ## Extension - `api.js`: `probeSource(url)` mirroring `quickAddSource` shape. - `background.js`: `PROBE_SOURCE` + `OPEN_ARTIST_PAGE` handlers. The latter strips the `/api` suffix from configured `apiUrl` (placeholder format per options.html) and opens `${base}/artist/{slug}` in a new tab via `browser.tabs.create`. - `content-script.js`: probe-first render — on page-load and SPA navigation, asks the backend for the URL's state and renders the chip in the matching color/copy on FIRST paint instead of flashing generic "Add" and updating after. Click handler branches: `source_match` → OPEN_ARTIST_PAGE; `artist_match`/`new` → existing ADD_AS_SOURCE flow (then re-probes so the chip flips green immediately, no wait for next nav). - `content-script.css`: three state-color modifiers (--new, --artist-match, --source-match) on the FC parchment-on-slate palette. Sage for already-added, amber for artist-exists, accent orange for new. ## Versioning - `extension/manifest.json` + `extension/package.json` → 1.0.6. build.yml's sign-extension job will fire on push to main since no `ext-1.0.6` Forgejo/Gitea release exists yet — exercises the regenerated AMO keys end-to-end. ## Behavior on the sidecar-synthetic case Filesystem-imported "Dymkens"-style artist with only a sidecar synthetic Source: probe returns `artist_match` (not `new`), so the chip reads "+ Add Patreon source to Dymkens" rather than offering to recreate the artist. Clicking adds the real Source; existing `_source_for_sidecar` preference logic (v26.06.01.0) routes future gallery-dl Posts to the real one.
226 lines
8.1 KiB
Python
226 lines
8.1 KiB
Python
"""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
|
|
|
|
|
|
class UnknownPlatformError(Exception):
|
|
"""URL didn't match any platform pattern."""
|
|
|
|
|
|
class InvalidUrlError(Exception):
|
|
"""URL was empty or missing a scheme."""
|
|
|
|
|
|
# Mirrored byte-for-byte from extension/lib/platforms.js
|
|
# PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand —
|
|
# reviewers catch drift.
|
|
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
("patreon", re.compile(
|
|
r"^https?://(?:www\.)?patreon\.com/"
|
|
r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)"
|
|
r"(?P<slug>[^/?#]+)/?$",
|
|
re.IGNORECASE,
|
|
)),
|
|
("subscribestar", re.compile(
|
|
r"^https?://(?:www\.)?subscribestar\.(?:com|adult)/"
|
|
r"(?!feed$|messages$|library$)"
|
|
r"(?P<slug>[^/?#]+)/?$",
|
|
re.IGNORECASE,
|
|
)),
|
|
("hentaifoundry", re.compile(
|
|
r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)",
|
|
re.IGNORECASE,
|
|
)),
|
|
("deviantart", re.compile(
|
|
r"^https?://(?:www\.)?deviantart\.com/"
|
|
r"(?!home$|watch\b|tag\b|browse\b)"
|
|
r"(?P<slug>[^/?#]+)/?$",
|
|
re.IGNORECASE,
|
|
)),
|
|
("pixiv", re.compile(
|
|
r"^https?://(?:www\.)?pixiv\.net/(?:en/)?users/(?P<slug>\d+)",
|
|
re.IGNORECASE,
|
|
)),
|
|
]
|
|
|
|
|
|
class ExtensionService:
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
|
|
async def quick_add_source(self, url: str) -> dict:
|
|
platform, raw_slug = self._derive(url)
|
|
artist, created_artist = await self._find_or_create_artist(raw_slug)
|
|
source, created_source = await self._find_or_create_source(
|
|
artist_id=artist.id, platform=platform, url=url,
|
|
)
|
|
return {
|
|
"source": {
|
|
"id": source.id,
|
|
"artist_id": source.artist_id,
|
|
"platform": source.platform,
|
|
"url": source.url,
|
|
"enabled": source.enabled,
|
|
},
|
|
"artist": {
|
|
"id": artist.id,
|
|
"name": artist.name,
|
|
"slug": artist.slug,
|
|
},
|
|
"created_source": created_source,
|
|
"created_artist": created_artist,
|
|
}
|
|
|
|
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:
|
|
src = Source(
|
|
artist_id=artist_id, platform=platform,
|
|
url=url, enabled=True,
|
|
)
|
|
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
|