Merge pull request 'Artist identity/storage/display decoupling — rename, move-source, add-time name resolution (#130)' (#193) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
This commit was merged in pull request #193.
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
"""drop uq_artist_name — decouple display name from identity/storage
|
||||||
|
|
||||||
|
Revision ID: 0077
|
||||||
|
Revises: 0076
|
||||||
|
Create Date: 2026-07-04
|
||||||
|
|
||||||
|
Artist model fragility fix (milestone #130). One `slug` column was doing
|
||||||
|
identity + storage-path + display, and BOTH `name` and `slug` were UNIQUE, so
|
||||||
|
the display name couldn't be edited freely and two genuinely different creators
|
||||||
|
collided. Decouple: `slug` stays the immutable, unique storage/identity key (the
|
||||||
|
on-disk path component — untouched here); `name` becomes freely editable, NON-
|
||||||
|
unique display text. This migration only drops the `uq_artist_name` constraint;
|
||||||
|
no data moves and no path changes.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0077"
|
||||||
|
down_revision: Union[str, None] = "0076"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_constraint("uq_artist_name", "artist", type_="unique")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Re-adding the UNIQUE would fail if duplicate names now exist; callers that
|
||||||
|
# need to reverse this must dedupe names first.
|
||||||
|
op.create_unique_constraint("uq_artist_name", "artist", ["name"])
|
||||||
@@ -31,6 +31,24 @@ async def create_or_get():
|
|||||||
}), 201
|
}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@artists_bp.route("/<int:artist_id>", methods=["PATCH"])
|
||||||
|
async def rename(artist_id: int):
|
||||||
|
"""Rename an artist's DISPLAY NAME (#130). Name only — the slug and every
|
||||||
|
on-disk path stay put, so this is instant and safe. Name is non-unique."""
|
||||||
|
body = await request.get_json()
|
||||||
|
if not isinstance(body, dict) or not isinstance(body.get("name"), str):
|
||||||
|
return jsonify({"error": "invalid_body"}), 400
|
||||||
|
async with get_session() as session:
|
||||||
|
svc = ArtistService(session)
|
||||||
|
try:
|
||||||
|
artist = await svc.rename(artist_id, body["name"])
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": "empty_name", "detail": str(exc)}), 400
|
||||||
|
if artist is None:
|
||||||
|
return jsonify({"error": "not_found"}), 404
|
||||||
|
return jsonify({"id": artist.id, "name": artist.name, "slug": artist.slug})
|
||||||
|
|
||||||
|
|
||||||
@artists_bp.route("/autocomplete", methods=["GET"])
|
@artists_bp.route("/autocomplete", methods=["GET"])
|
||||||
async def autocomplete():
|
async def autocomplete():
|
||||||
q = request.args.get("q") or ""
|
q = request.args.get("q") or ""
|
||||||
|
|||||||
@@ -84,11 +84,15 @@ async def quick_add_source():
|
|||||||
if not isinstance(url, str) or not url.strip():
|
if not isinstance(url, str) or not url.strip():
|
||||||
return _bad("invalid_body", detail="url is required")
|
return _bad("invalid_body", detail="url is required")
|
||||||
|
|
||||||
|
from .credentials import _get_crypto
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
if not await _ext_key_required(session):
|
if not await _ext_key_required(session):
|
||||||
return _bad("unauthorized", status=401)
|
return _bad("unauthorized", status=401)
|
||||||
try:
|
try:
|
||||||
result = await ExtensionService(session).quick_add_source(url)
|
# crypto lets a pixiv add resolve the artist's display name via the
|
||||||
|
# stored OAuth token (else it falls back to the numeric id). #130.
|
||||||
|
result = await ExtensionService(session, _get_crypto()).quick_add_source(url)
|
||||||
except UnknownPlatformError as exc:
|
except UnknownPlatformError as exc:
|
||||||
return _bad(
|
return _bad(
|
||||||
"unknown_platform",
|
"unknown_platform",
|
||||||
|
|||||||
@@ -136,6 +136,25 @@ async def delete_source(source_id: int):
|
|||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@sources_bp.route("/<int:source_id>/reassign", methods=["POST"])
|
||||||
|
async def reassign_source(source_id: int):
|
||||||
|
"""Move this source (and the content it brought in) to another artist
|
||||||
|
(#130). Files don't move — the slug is immutable — so this just re-attributes
|
||||||
|
the source, its posts, and its images. Body: {target_artist_id}."""
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
target = body.get("target_artist_id")
|
||||||
|
if not isinstance(target, int):
|
||||||
|
return _bad("invalid_body", detail="target_artist_id (int) required")
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
record = await SourceService(session).reassign(source_id, target)
|
||||||
|
except LookupError:
|
||||||
|
return _bad("not_found", status=404)
|
||||||
|
except ArtistNotFoundError:
|
||||||
|
return _bad("artist_not_found", detail="target artist not found", status=404)
|
||||||
|
return jsonify(record.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@sources_bp.route("/<int:source_id>/backfill", methods=["POST"])
|
@sources_bp.route("/<int:source_id>/backfill", methods=["POST"])
|
||||||
async def set_backfill(source_id: int):
|
async def set_backfill(source_id: int):
|
||||||
"""Plan #693/#697 + #830: start/stop a backfill, or start a recovery /
|
"""Plan #693/#697 + #830: start/stop a backfill, or start a recovery /
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ class Artist(Base):
|
|||||||
__tablename__ = "artist"
|
__tablename__ = "artist"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
# Display name: freely editable, NON-unique (two real creators can share a
|
||||||
|
# name). Decoupled from identity/storage in migration 0077 (#130) — renaming
|
||||||
|
# touches ONLY this. Was unique until then.
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
# Storage/identity key: IMMUTABLE + unique. This is the on-disk path
|
||||||
|
# component (download_service artist_slug = artist.slug → images_root/<slug>/
|
||||||
|
# <platform>/…), so it is set once at creation (collision-suffixed) and NEVER
|
||||||
|
# changes — a rename must not move files. Existing artists keep their slug.
|
||||||
slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -283,6 +283,23 @@ class ArtistService:
|
|||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
return artist, created
|
return artist, created
|
||||||
|
|
||||||
|
async def rename(self, artist_id: int, name: str) -> Artist | None:
|
||||||
|
"""Change the display NAME only (#130). The slug — and every on-disk path
|
||||||
|
keyed off it — is IMMUTABLE, so a rename never moves files or risks a path
|
||||||
|
collision. Name is free text and NON-unique (migration 0077). Returns the
|
||||||
|
updated Artist, None if not found; raises ValueError on empty name."""
|
||||||
|
cleaned = (name or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("artist name must not be empty")
|
||||||
|
artist = (await self.session.execute(
|
||||||
|
select(Artist).where(Artist.id == artist_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if artist is None:
|
||||||
|
return None
|
||||||
|
artist.name = cleaned
|
||||||
|
await self.session.commit()
|
||||||
|
return artist
|
||||||
|
|
||||||
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
|
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
|
||||||
cleaned = (prefix or "").strip()
|
cleaned = (prefix or "").strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ and returns a JSON-shaped dict for the API layer.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -18,6 +19,8 @@ from ..utils.slug import slugify
|
|||||||
from .db_helpers import get_or_create
|
from .db_helpers import get_or_create
|
||||||
from .source_service import BACKFILL_MAX_CHUNKS
|
from .source_service import BACKFILL_MAX_CHUNKS
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class UnknownPlatformError(Exception):
|
class UnknownPlatformError(Exception):
|
||||||
"""URL didn't match any platform pattern."""
|
"""URL didn't match any platform pattern."""
|
||||||
@@ -61,15 +64,38 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|||||||
|
|
||||||
|
|
||||||
class ExtensionService:
|
class ExtensionService:
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession, crypto=None) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
|
# Optional decryptor for resolving a token-auth platform's display name
|
||||||
|
# (pixiv) at add-time. None → skip resolution, fall back to the handle.
|
||||||
|
self._crypto = crypto
|
||||||
|
|
||||||
async def quick_add_source(self, url: str) -> dict:
|
async def quick_add_source(self, url: str) -> dict:
|
||||||
platform, raw_slug = self._derive(url)
|
platform, raw_slug = self._derive(url)
|
||||||
artist, created_artist = await self._find_or_create_artist(raw_slug)
|
# Identity by SOURCE handle (#130): an existing (platform, url) source
|
||||||
|
# keeps its artist on re-add — even if that artist was since renamed (its
|
||||||
|
# frozen slug no longer matches the current name). Only a genuinely new
|
||||||
|
# source resolves/creates an artist.
|
||||||
|
existing = (await self.session.execute(
|
||||||
|
select(Source).where(Source.platform == platform, Source.url == url)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
artist = (await self.session.execute(
|
||||||
|
select(Artist).where(Artist.id == existing.artist_id)
|
||||||
|
)).scalar_one()
|
||||||
|
return self._shape(existing, artist, created_source=False, created_artist=False)
|
||||||
|
|
||||||
|
# New source → name the artist properly by resolving the real display
|
||||||
|
# name from the platform (falls back to the URL handle).
|
||||||
|
name = await self._resolve_artist_name(platform, raw_slug, url)
|
||||||
|
artist, created_artist = await self._find_or_create_artist(name)
|
||||||
source, created_source = await self._find_or_create_source(
|
source, created_source = await self._find_or_create_source(
|
||||||
artist_id=artist.id, platform=platform, url=url,
|
artist_id=artist.id, platform=platform, url=url,
|
||||||
)
|
)
|
||||||
|
return self._shape(source, artist, created_source, created_artist)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _shape(source, artist, created_source: bool, created_artist: bool) -> dict:
|
||||||
return {
|
return {
|
||||||
"source": {
|
"source": {
|
||||||
"id": source.id,
|
"id": source.id,
|
||||||
@@ -87,6 +113,50 @@ class ExtensionService:
|
|||||||
"created_artist": created_artist,
|
"created_artist": created_artist,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _resolve_artist_name(
|
||||||
|
self, platform: str, raw_slug: str, url: str
|
||||||
|
) -> str:
|
||||||
|
"""The real display name for a new artist, resolved from the platform at
|
||||||
|
add-time (#130). Our native platforms each have a name source — pixiv the
|
||||||
|
app API (token), patreon the campaigns API, subscribestar the profile
|
||||||
|
page (both cookies). Other platforms (and any failure — no credential,
|
||||||
|
network error) fall back to the URL handle, which is already readable.
|
||||||
|
The resolvers are sync, so they run in an executor."""
|
||||||
|
if self._crypto is None or platform not in ("pixiv", "patreon", "subscribestar"):
|
||||||
|
return raw_slug
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from .credential_service import CredentialService
|
||||||
|
cred = CredentialService(self.session, self._crypto)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
if platform == "pixiv":
|
||||||
|
token = await cred.get_token("pixiv")
|
||||||
|
if not token:
|
||||||
|
return raw_slug
|
||||||
|
from .pixiv_client import PixivClient
|
||||||
|
name = await loop.run_in_executor(
|
||||||
|
None, PixivClient(token).resolve_display_name, raw_slug
|
||||||
|
)
|
||||||
|
elif platform == "patreon":
|
||||||
|
cookies = await cred.get_cookies_path("patreon")
|
||||||
|
from .patreon_resolver import resolve_display_name
|
||||||
|
name = await loop.run_in_executor(
|
||||||
|
None, resolve_display_name, raw_slug,
|
||||||
|
str(cookies) if cookies else None,
|
||||||
|
)
|
||||||
|
else: # subscribestar
|
||||||
|
cookies = await cred.get_cookies_path("subscribestar")
|
||||||
|
from .subscribestar_client import SubscribeStarClient
|
||||||
|
client = SubscribeStarClient(str(cookies) if cookies else None)
|
||||||
|
name = await loop.run_in_executor(
|
||||||
|
None, client.resolve_display_name, url
|
||||||
|
)
|
||||||
|
except Exception as exc: # resolution is best-effort — never block the add
|
||||||
|
log.warning("artist display-name resolution failed (%s): %s", platform, exc)
|
||||||
|
return raw_slug
|
||||||
|
return name or raw_slug
|
||||||
|
|
||||||
async def probe(self, url: str) -> dict:
|
async def probe(self, url: str) -> dict:
|
||||||
"""Read-only resolution of a creator-page URL against the FC DB.
|
"""Read-only resolution of a creator-page URL against the FC DB.
|
||||||
Returns one of:
|
Returns one of:
|
||||||
|
|||||||
@@ -139,6 +139,32 @@ def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
|
|||||||
return campaign_id
|
return campaign_id
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_display_name(vanity: str, cookies_path: str | None) -> str | None:
|
||||||
|
"""The Patreon campaign's display name for `vanity` via the campaigns API
|
||||||
|
(`fields[campaign]=name`), used to name the Artist at add-time (#130). None
|
||||||
|
on any failure — the caller falls back to the vanity handle. Sync: call from
|
||||||
|
an executor."""
|
||||||
|
jar = _load_cookie_jar(cookies_path)
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
_CAMPAIGNS_URL,
|
||||||
|
params={"filter[vanity]": vanity, "fields[campaign]": "name"},
|
||||||
|
headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
|
||||||
|
cookies=jar,
|
||||||
|
timeout=_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = resp.json().get("data")
|
||||||
|
except (requests.RequestException, ValueError) as exc:
|
||||||
|
log.warning("Patreon name lookup failed for vanity=%s: %s", vanity, exc)
|
||||||
|
return None
|
||||||
|
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
||||||
|
return None
|
||||||
|
name = (data[0].get("attributes") or {}).get("name")
|
||||||
|
return name.strip() if isinstance(name, str) and name.strip() else None
|
||||||
|
|
||||||
|
|
||||||
def _scrape_campaign_id(html: str) -> str | None:
|
def _scrape_campaign_id(html: str) -> str | None:
|
||||||
"""First campaign id found in creator-page HTML via the known embeddings."""
|
"""First campaign id found in creator-page HTML via the known embeddings."""
|
||||||
if not isinstance(html, str):
|
if not isinstance(html, str):
|
||||||
|
|||||||
@@ -540,6 +540,19 @@ class PixivClient:
|
|||||||
return
|
return
|
||||||
current = str(next_url).rpartition("?")[2]
|
current = str(next_url).rpartition("?")[2]
|
||||||
|
|
||||||
|
# -- user detail ---------------------------------------------------------
|
||||||
|
|
||||||
|
def resolve_display_name(self, user_id: str) -> str | None:
|
||||||
|
"""The pixiv user's display name via `/v1/user/detail` (gallery-dl's
|
||||||
|
user_detail) — used to name the Artist when a source is added by numeric
|
||||||
|
id. None on any failure (the caller falls back to the id)."""
|
||||||
|
try:
|
||||||
|
body = self._call("/v1/user/detail", {"user_id": str(user_id)})
|
||||||
|
except PixivAPIError:
|
||||||
|
return None
|
||||||
|
name = (body.get("user") or {}).get("name") if isinstance(body, dict) else None
|
||||||
|
return name if isinstance(name, str) and name.strip() else None
|
||||||
|
|
||||||
# -- verify --------------------------------------------------------------
|
# -- verify --------------------------------------------------------------
|
||||||
|
|
||||||
def verify_auth(self) -> tuple[bool | None, str]:
|
def verify_auth(self) -> tuple[bool | None, str]:
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ is_subscription auto-flip on first add / last delete.
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models import Artist, ImportSettings, Source
|
from ..models import (
|
||||||
|
Artist,
|
||||||
|
ImageProvenance,
|
||||||
|
ImageRecord,
|
||||||
|
ImportSettings,
|
||||||
|
Post,
|
||||||
|
Source,
|
||||||
|
)
|
||||||
from .platforms import known_platform_keys
|
from .platforms import known_platform_keys
|
||||||
from .scheduler_service import compute_next_check_at
|
from .scheduler_service import compute_next_check_at
|
||||||
|
|
||||||
@@ -420,6 +427,70 @@ class SourceService:
|
|||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
return await self._row_to_record(source)
|
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:
|
async def delete(self, source_id: int) -> None:
|
||||||
source = (await self.session.execute(
|
source = (await self.session.execute(
|
||||||
select(Source).where(Source.id == source_id)
|
select(Source).where(Source.id == source_id)
|
||||||
|
|||||||
@@ -273,6 +273,30 @@ def _parse_ss_datetime(text: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_OG_TITLE_RE = re.compile(
|
||||||
|
r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||||
|
# Trailing " | SubscribeStar" / " on SubscribeStar" the profile <title> carries.
|
||||||
|
_SS_TITLE_SUFFIX_RE = re.compile(
|
||||||
|
r"\s*[|·]\s*SubscribeStar.*$|\s+on\s+SubscribeStar.*$", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_creator_name(html: str) -> str | None:
|
||||||
|
"""The creator's display name from a SubscribeStar profile page: prefer the
|
||||||
|
og:title meta (it's the bare creator name), else the <title> with the
|
||||||
|
SubscribeStar suffix stripped. None when neither yields anything (#130)."""
|
||||||
|
m = _OG_TITLE_RE.search(html)
|
||||||
|
name = unescape(m.group(1)).strip() if m else ""
|
||||||
|
if not name:
|
||||||
|
t = _TITLE_RE.search(html)
|
||||||
|
raw = unescape(t.group(1)).strip() if t else ""
|
||||||
|
name = _SS_TITLE_SUFFIX_RE.sub("", raw).strip()
|
||||||
|
return name or None
|
||||||
|
|
||||||
|
|
||||||
class SubscribeStarClient:
|
class SubscribeStarClient:
|
||||||
"""Synchronous SubscribeStar HTML-scrape read client. Construct with a path
|
"""Synchronous SubscribeStar HTML-scrape read client. Construct with a path
|
||||||
to a Netscape cookies.txt (the same file CredentialService.get_cookies_path
|
to a Netscape cookies.txt (the same file CredentialService.get_cookies_path
|
||||||
@@ -604,6 +628,23 @@ class SubscribeStarClient:
|
|||||||
current = next_href
|
current = next_href
|
||||||
first_page = False
|
first_page = False
|
||||||
|
|
||||||
|
# -- display name -------------------------------------------------------
|
||||||
|
|
||||||
|
def resolve_display_name(self, campaign_id: str) -> str | None:
|
||||||
|
"""The creator's display name from their profile page, used to name the
|
||||||
|
Artist at add-time (#130). `campaign_id` is the creator URL. None on any
|
||||||
|
failure — the caller falls back to the URL handle. Sync: run in an
|
||||||
|
executor."""
|
||||||
|
base, slug = _split_creator_url(campaign_id)
|
||||||
|
if not slug:
|
||||||
|
return None
|
||||||
|
self._session.headers["Referer"] = f"{base}/"
|
||||||
|
try:
|
||||||
|
html = self._feed_html(f"{base}/{slug}")
|
||||||
|
except SubscribeStarAPIError:
|
||||||
|
return None
|
||||||
|
return _extract_creator_name(html)
|
||||||
|
|
||||||
# -- verify ------------------------------------------------------------
|
# -- verify ------------------------------------------------------------
|
||||||
|
|
||||||
def verify_auth(self, campaign_id: str) -> tuple[bool | None, str]:
|
def verify_auth(self, campaign_id: str) -> tuple[bool | None, str]:
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="fc-artist-header">
|
<header class="fc-artist-header">
|
||||||
<div class="fc-artist-header__left">
|
<div class="fc-artist-header__left">
|
||||||
<h1 class="fc-artist-header__name">{{ name }}</h1>
|
<template v-if="!editing">
|
||||||
<span v-if="stats" class="fc-artist-header__stats">{{ stats }}</span>
|
<h1 class="fc-artist-header__name">{{ name }}</h1>
|
||||||
|
<v-icon
|
||||||
|
class="fc-artist-header__edit" size="16" icon="mdi-pencil"
|
||||||
|
title="Rename artist (display name only)" @click="startEdit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-text-field
|
||||||
|
v-else
|
||||||
|
v-model="draft"
|
||||||
|
density="compact" variant="outlined" hide-details autofocus
|
||||||
|
class="fc-artist-header__edit-field"
|
||||||
|
@keydown.enter="submit" @keydown.esc="cancel" @blur="cancel"
|
||||||
|
/>
|
||||||
|
<span v-if="stats && !editing" class="fc-artist-header__stats">{{ stats }}</span>
|
||||||
</div>
|
</div>
|
||||||
<v-tabs
|
<v-tabs
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
@@ -34,7 +47,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { formatLocalDate } from '../../utils/date.js'
|
import { formatLocalDate } from '../../utils/date.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -45,7 +58,23 @@ const props = defineProps({
|
|||||||
modelValue: { type: String, required: true },
|
modelValue: { type: String, required: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue', 'rename'])
|
||||||
|
|
||||||
|
// Inline rename (display name only — #130). Mirrors TagCard's pencil-edit.
|
||||||
|
const editing = ref(false)
|
||||||
|
const draft = ref('')
|
||||||
|
function startEdit () {
|
||||||
|
draft.value = props.name
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
function cancel () {
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
function submit () {
|
||||||
|
const next = draft.value.trim()
|
||||||
|
editing.value = false
|
||||||
|
if (next && next !== props.name) emit('rename', next)
|
||||||
|
}
|
||||||
|
|
||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
const parts = []
|
const parts = []
|
||||||
@@ -111,6 +140,20 @@ const stats = computed(() => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fc-artist-header__edit {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
transition: opacity .15s ease;
|
||||||
|
}
|
||||||
|
.fc-artist-header__left:hover .fc-artist-header__edit { opacity: .7; }
|
||||||
|
.fc-artist-header__edit:hover { opacity: 1; }
|
||||||
|
.fc-artist-header__edit-field {
|
||||||
|
max-width: 340px;
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
.fc-artist-header__tabs {
|
.fc-artist-header__tabs {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,18 +47,60 @@
|
|||||||
</div>
|
</div>
|
||||||
<v-table density="compact">
|
<v-table density="compact">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Platform</th><th>URL</th><th class="text-right">Images</th></tr>
|
<tr>
|
||||||
|
<th>Platform</th><th>URL</th>
|
||||||
|
<th class="text-right">Images</th><th></th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="s in overview.sources" :key="s.id">
|
<tr v-for="s in overview.sources" :key="s.id">
|
||||||
<td>{{ s.platform }}</td>
|
<td>{{ s.platform }}</td>
|
||||||
<td class="fc-artist-mgmt__url">{{ s.url }}</td>
|
<td class="fc-artist-mgmt__url">{{ s.url }}</td>
|
||||||
<td class="text-right">{{ s.image_count }}</td>
|
<td class="text-right">{{ s.image_count }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<v-btn
|
||||||
|
size="x-small" variant="text" prepend-icon="mdi-account-arrow-right"
|
||||||
|
@click="openMove(s)"
|
||||||
|
>Move…</v-btn>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</v-table>
|
</v-table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- #130: move a source into a different (existing) artist. The slug is
|
||||||
|
immutable so no files move — just re-attribution. -->
|
||||||
|
<v-dialog v-model="moveOpen" max-width="480">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="fc-h2">Move source to another artist</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-3 text-body-2">
|
||||||
|
Moving <strong>{{ moveSource?.platform }}</strong> and the posts/images
|
||||||
|
it brought in to another artist. Files aren't moved — only the
|
||||||
|
attribution changes. If <strong>{{ overview.name }}</strong> is left
|
||||||
|
empty it will be removed.
|
||||||
|
</p>
|
||||||
|
<v-autocomplete
|
||||||
|
v-model="moveTarget"
|
||||||
|
:items="artistItems"
|
||||||
|
:loading="searching"
|
||||||
|
item-title="name" item-value="id" return-object
|
||||||
|
label="Target artist" density="compact" variant="outlined"
|
||||||
|
no-filter hide-details autofocus clearable
|
||||||
|
@update:search="onSearch"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="moveOpen = false">Cancel</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="accent" :disabled="!moveTarget || moving" :loading="moving"
|
||||||
|
@click="confirmMove"
|
||||||
|
>Move</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
<section class="fc-artist-mgmt__sec">
|
<section class="fc-artist-mgmt__sec">
|
||||||
<h2 class="fc-h2">Danger zone</h2>
|
<h2 class="fc-h2">Danger zone</h2>
|
||||||
<ArtistDangerZone
|
<ArtistDangerZone
|
||||||
@@ -71,9 +113,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import { useSourcesStore } from '../../stores/sources.js'
|
||||||
|
import { toast } from '../../utils/toast.js'
|
||||||
import ArtistDangerZone from './ArtistDangerZone.vue'
|
import ArtistDangerZone from './ArtistDangerZone.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -81,6 +125,53 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const sources = useSourcesStore()
|
||||||
|
|
||||||
|
// #130: move a source into another artist.
|
||||||
|
const moveOpen = ref(false)
|
||||||
|
const moveSource = ref(null)
|
||||||
|
const moveTarget = ref(null)
|
||||||
|
const artistItems = ref([])
|
||||||
|
const searching = ref(false)
|
||||||
|
const moving = ref(false)
|
||||||
|
|
||||||
|
function openMove (s) {
|
||||||
|
moveSource.value = s
|
||||||
|
moveTarget.value = null
|
||||||
|
artistItems.value = []
|
||||||
|
moveOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchSeq = 0
|
||||||
|
async function onSearch (q) {
|
||||||
|
const mine = ++searchSeq
|
||||||
|
if (!q || !q.trim()) { artistItems.value = []; return }
|
||||||
|
searching.value = true
|
||||||
|
try {
|
||||||
|
const rows = await sources.autocompleteArtist(q)
|
||||||
|
// Drop the current artist — you can't move a source onto itself.
|
||||||
|
if (mine === searchSeq) artistItems.value = rows.filter(a => a.id !== props.overview.id)
|
||||||
|
} finally {
|
||||||
|
if (mine === searchSeq) searching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmMove () {
|
||||||
|
if (!moveTarget.value || !moveSource.value) return
|
||||||
|
moving.value = true
|
||||||
|
try {
|
||||||
|
await sources.reassign(moveSource.value.id, moveTarget.value.id)
|
||||||
|
moveOpen.value = false
|
||||||
|
toast({ text: `Moved to ${moveTarget.value.name}`, type: 'success' })
|
||||||
|
// The current artist may now be empty (deleted); route to the target,
|
||||||
|
// whose slug is stable (immutable).
|
||||||
|
router.push({ name: 'artist', params: { slug: moveTarget.value.slug } })
|
||||||
|
} catch (e) {
|
||||||
|
toast({ text: `Move failed: ${e.message}`, type: 'error' })
|
||||||
|
} finally {
|
||||||
|
moving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sparkW = 600
|
const sparkW = 600
|
||||||
const sparkH = 80
|
const sparkH = 80
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rename the DISPLAY name only (#130). The slug is immutable, so the route
|
||||||
|
// (slug-based) and on-disk paths are unaffected — just patch the shown name.
|
||||||
|
async function rename (name) {
|
||||||
|
if (!overview.value) return
|
||||||
|
const id = overview.value.id
|
||||||
|
const body = await api.patch(`/api/artists/${id}`, { body: { name } })
|
||||||
|
if (overview.value && overview.value.id === id) overview.value.name = body.name
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
const hasMoreImages = computed(() => !started || nextCursor.value !== null)
|
const hasMoreImages = computed(() => !started || nextCursor.value !== null)
|
||||||
const postCount = computed(() => overview.value?.post_count ?? null)
|
const postCount = computed(() => overview.value?.post_count ?? null)
|
||||||
const imageCount = computed(() => overview.value?.image_count ?? null)
|
const imageCount = computed(() => overview.value?.image_count ?? null)
|
||||||
@@ -81,6 +91,6 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
return {
|
return {
|
||||||
overview, images, loading, imagesLoading, error, notFound,
|
overview, images, loading, imagesLoading, error, notFound,
|
||||||
hasMoreImages, postCount, imageCount, lastAdded,
|
hasMoreImages, postCount, imageCount, lastAdded,
|
||||||
load, loadMoreImages,
|
load, loadMoreImages, rename,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ export const useSourcesStore = defineStore('sources', () => {
|
|||||||
return await api.get('/api/artists/autocomplete', { params: { q: query, limit } })
|
return await api.get('/api/artists/autocomplete', { params: { q: query, limit } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #130: move a source (+ its content) to another artist. Files don't move
|
||||||
|
// (slug is immutable); the backend re-attributes source/posts/images and
|
||||||
|
// deletes the old artist if it's left empty.
|
||||||
|
async function reassign(id, targetArtistId) {
|
||||||
|
return await api.post(`/api/sources/${id}/reassign`, {
|
||||||
|
body: { target_artist_id: targetArtistId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function loadScheduleStatus() {
|
async function loadScheduleStatus() {
|
||||||
scheduleStatus.value = await api.get('/api/sources/schedule-status')
|
scheduleStatus.value = await api.get('/api/sources/schedule-status')
|
||||||
return scheduleStatus.value
|
return scheduleStatus.value
|
||||||
@@ -177,7 +186,7 @@ export const useSourcesStore = defineStore('sources', () => {
|
|||||||
recoverSource,
|
recoverSource,
|
||||||
recaptureSource,
|
recaptureSource,
|
||||||
previewSource,
|
previewSource,
|
||||||
findOrCreateArtist, autocompleteArtist,
|
findOrCreateArtist, autocompleteArtist, reassign,
|
||||||
loadScheduleStatus,
|
loadScheduleStatus,
|
||||||
sourcesByArtistGrouped,
|
sourcesByArtistGrouped,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
:image-count="store.imageCount"
|
:image-count="store.imageCount"
|
||||||
:post-count="store.postCount"
|
:post-count="store.postCount"
|
||||||
:last-added="store.lastAdded"
|
:last-added="store.lastAdded"
|
||||||
|
@rename="onRename"
|
||||||
/>
|
/>
|
||||||
<v-container fluid class="pt-2 pb-4">
|
<v-container fluid class="pt-2 pb-4">
|
||||||
<!-- "N new since last visit" banner. Visible only on the initial
|
<!-- "N new since last visit" banner. Visible only on the initial
|
||||||
@@ -59,6 +60,7 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { useArtistStore } from '../stores/artist.js'
|
import { useArtistStore } from '../stores/artist.js'
|
||||||
|
import { toast } from '../utils/toast.js'
|
||||||
import ArtistHeader from '../components/artist/ArtistHeader.vue'
|
import ArtistHeader from '../components/artist/ArtistHeader.vue'
|
||||||
import ArtistPostsTab from '../components/artist/ArtistPostsTab.vue'
|
import ArtistPostsTab from '../components/artist/ArtistPostsTab.vue'
|
||||||
import ArtistGalleryTab from '../components/artist/ArtistGalleryTab.vue'
|
import ArtistGalleryTab from '../components/artist/ArtistGalleryTab.vue'
|
||||||
@@ -76,6 +78,17 @@ const { tab, resolve } = useTabQuery(
|
|||||||
() => ((store.postCount ?? 0) > 0 ? 'posts' : 'gallery'),
|
() => ((store.postCount ?? 0) > 0 ? 'posts' : 'gallery'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Rename the artist's display name (#130). Slug/route unchanged, so no
|
||||||
|
// navigation — the header just reflects the new name; also refresh the tab title.
|
||||||
|
async function onRename (name) {
|
||||||
|
try {
|
||||||
|
await store.rename(name)
|
||||||
|
document.title = `${store.overview.name} — FabledCurator`
|
||||||
|
} catch (e) {
|
||||||
|
toast({ text: `Rename failed: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One-shot banner — reset on each new artist-slug load so it re-appears
|
// One-shot banner — reset on each new artist-slug load so it re-appears
|
||||||
// when navigating between artists that each have unseen content.
|
// when navigating between artists that each have unseen content.
|
||||||
const unseenBanner = ref(false)
|
const unseenBanner = ref(false)
|
||||||
|
|||||||
@@ -47,3 +47,28 @@ async def test_autocomplete_empty_query(client):
|
|||||||
resp = await client.get("/api/artists/autocomplete?q=")
|
resp = await client.get("/api/artists/autocomplete?q=")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert await resp.get_json() == []
|
assert await resp.get_json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_renames_display_name_only(client):
|
||||||
|
# #130: PATCH renames the display name; slug unchanged (paths safe).
|
||||||
|
created = await (await client.post("/api/artists", json={"name": "12345678"})).get_json()
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/artists/{created['id']}", json={"name": "Kurotsuchi Machi"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["name"] == "Kurotsuchi Machi"
|
||||||
|
assert body["slug"] == created["slug"] # slug frozen
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_rename_validation(client):
|
||||||
|
created = await (await client.post("/api/artists", json={"name": "Nn"})).get_json()
|
||||||
|
assert (await client.patch(
|
||||||
|
f"/api/artists/{created['id']}", json={"name": " "}
|
||||||
|
)).status_code == 400
|
||||||
|
assert (await client.patch(
|
||||||
|
f"/api/artists/{created['id']}", json={})).status_code == 400
|
||||||
|
assert (await client.patch(
|
||||||
|
"/api/artists/999999", json={"name": "Ghost"})).status_code == 404
|
||||||
|
|||||||
@@ -65,6 +65,64 @@ async def test_quick_add_source_idempotent(client, ext_key):
|
|||||||
assert body2["created_artist"] is False
|
assert body2["created_artist"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quick_add_reuses_source_artist_after_rename(client, ext_key):
|
||||||
|
# #130 identity-by-source: after renaming the artist (slug frozen ≠ new
|
||||||
|
# name-slug), re-adding the SAME source must reuse it — a slug-based lookup
|
||||||
|
# would miss and duplicate the artist.
|
||||||
|
url = "https://www.subscribestar.com/renamed-creator"
|
||||||
|
h = {"X-Extension-Key": ext_key}
|
||||||
|
a1 = (await (await client.post(
|
||||||
|
"/api/extension/quick-add-source", json={"url": url}, headers=h
|
||||||
|
)).get_json())["artist"]
|
||||||
|
await client.patch(f"/api/artists/{a1['id']}", json={"name": "Totally Different"})
|
||||||
|
b2 = await (await client.post(
|
||||||
|
"/api/extension/quick-add-source", json={"url": url}, headers=h
|
||||||
|
)).get_json()
|
||||||
|
assert b2["created_artist"] is False
|
||||||
|
assert b2["artist"]["id"] == a1["id"]
|
||||||
|
assert b2["artist"]["name"] == "Totally Different"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_artist_name_dispatches_per_platform(db, monkeypatch):
|
||||||
|
# #130: each native platform resolves its real display name at add-time
|
||||||
|
# (pixiv=token API, patreon=campaigns API, subscribestar=profile page);
|
||||||
|
# gallery-dl platforms and any failure fall back to the URL handle.
|
||||||
|
from backend.app.services import patreon_resolver
|
||||||
|
from backend.app.services.credential_service import CredentialService
|
||||||
|
from backend.app.services.extension_service import ExtensionService
|
||||||
|
from backend.app.services.pixiv_client import PixivClient
|
||||||
|
from backend.app.services.subscribestar_client import SubscribeStarClient
|
||||||
|
|
||||||
|
async def _tok(self, platform):
|
||||||
|
return "tok"
|
||||||
|
|
||||||
|
async def _cookies(self, platform):
|
||||||
|
return "/tmp/cookies.txt"
|
||||||
|
|
||||||
|
monkeypatch.setattr(CredentialService, "get_token", _tok)
|
||||||
|
monkeypatch.setattr(CredentialService, "get_cookies_path", _cookies)
|
||||||
|
monkeypatch.setattr(PixivClient, "resolve_display_name", lambda self, uid: "Pixiv Name")
|
||||||
|
monkeypatch.setattr(patreon_resolver, "resolve_display_name", lambda v, c: "Patreon Name")
|
||||||
|
monkeypatch.setattr(SubscribeStarClient, "resolve_display_name", lambda self, u: "SS Name")
|
||||||
|
|
||||||
|
svc = ExtensionService(db, crypto=object()) # crypto seam only (calls stubbed)
|
||||||
|
assert await svc._resolve_artist_name(
|
||||||
|
"pixiv", "555", "https://www.pixiv.net/users/555") == "Pixiv Name"
|
||||||
|
assert await svc._resolve_artist_name(
|
||||||
|
"patreon", "maewix", "https://patreon.com/maewix") == "Patreon Name"
|
||||||
|
assert await svc._resolve_artist_name(
|
||||||
|
"subscribestar", "sabu", "https://subscribestar.adult/sabu") == "SS Name"
|
||||||
|
# gallery-dl platform → readable handle passthrough (no resolver).
|
||||||
|
assert await svc._resolve_artist_name("hentaifoundry", "Foo", "u") == "Foo"
|
||||||
|
# No crypto → no resolution attempt → the raw handle.
|
||||||
|
assert await ExtensionService(db)._resolve_artist_name("pixiv", "555", "u") == "555"
|
||||||
|
# Resolver returns None → fall back to the handle.
|
||||||
|
monkeypatch.setattr(patreon_resolver, "resolve_display_name", lambda v, c: None)
|
||||||
|
assert await svc._resolve_artist_name("patreon", "maewix", "u") == "maewix"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("url,platform,slug", [
|
@pytest.mark.parametrize("url,platform,slug", [
|
||||||
("https://www.patreon.com/maewix", "patreon", "maewix"),
|
("https://www.patreon.com/maewix", "patreon", "maewix"),
|
||||||
("https://patreon.com/maewix", "patreon", "maewix"),
|
("https://patreon.com/maewix", "patreon", "maewix"),
|
||||||
|
|||||||
@@ -54,3 +54,40 @@ async def test_autocomplete_ranks_exact_prefix_substring(db):
|
|||||||
async def test_autocomplete_empty_query_returns_empty(db):
|
async def test_autocomplete_empty_query_returns_empty(db):
|
||||||
svc = ArtistService(db)
|
svc = ArtistService(db)
|
||||||
assert await svc.autocomplete("") == []
|
assert await svc.autocomplete("") == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_changes_name_not_slug(db):
|
||||||
|
# #130: rename touches the display name ONLY — the slug (and every on-disk
|
||||||
|
# path keyed off it) is immutable.
|
||||||
|
svc = ArtistService(db)
|
||||||
|
artist, _ = await svc.find_or_create("12345678")
|
||||||
|
orig_slug = artist.slug
|
||||||
|
renamed = await svc.rename(artist.id, "Kurotsuchi Machi")
|
||||||
|
assert renamed.name == "Kurotsuchi Machi"
|
||||||
|
assert renamed.slug == orig_slug # slug frozen
|
||||||
|
fresh = await db.get(Artist, artist.id)
|
||||||
|
assert fresh.name == "Kurotsuchi Machi"
|
||||||
|
assert fresh.slug == orig_slug
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_rejects_empty_and_missing(db):
|
||||||
|
svc = ArtistService(db)
|
||||||
|
artist, _ = await svc.find_or_create("Someone")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await svc.rename(artist.id, " ")
|
||||||
|
assert await svc.rename(999999, "Ghost") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_two_artists_can_share_a_display_name(db):
|
||||||
|
# #130: name is non-unique now (two genuinely different creators can share a
|
||||||
|
# display name); the immutable slug keeps them distinct.
|
||||||
|
a = Artist(name="Same Name", slug="same-name")
|
||||||
|
b = Artist(name="Same Name", slug="same-name-2")
|
||||||
|
db.add_all([a, b])
|
||||||
|
await db.flush()
|
||||||
|
assert a.id != b.id
|
||||||
|
assert a.name == b.name
|
||||||
|
assert a.slug != b.slug
|
||||||
|
|||||||
@@ -5,9 +5,34 @@ import pytest
|
|||||||
from backend.app.services.patreon_resolver import (
|
from backend.app.services.patreon_resolver import (
|
||||||
resolve_campaign_id,
|
resolve_campaign_id,
|
||||||
resolve_campaign_id_for_source,
|
resolve_campaign_id_for_source,
|
||||||
|
resolve_display_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_display_name_from_campaign():
|
||||||
|
fake = MagicMock()
|
||||||
|
fake.status_code = 200
|
||||||
|
fake.json.return_value = {"data": [
|
||||||
|
{"id": "1", "type": "campaign", "attributes": {"name": "Maewix Studios"}},
|
||||||
|
]}
|
||||||
|
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake):
|
||||||
|
assert resolve_display_name("maewix", None) == "Maewix Studios"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_display_name_none_on_empty_or_error():
|
||||||
|
empty = MagicMock()
|
||||||
|
empty.status_code = 200
|
||||||
|
empty.json.return_value = {"data": []}
|
||||||
|
with patch("backend.app.services.patreon_resolver.requests.get", return_value=empty):
|
||||||
|
assert resolve_display_name("maewix", None) is None
|
||||||
|
import requests as _rq
|
||||||
|
with patch(
|
||||||
|
"backend.app.services.patreon_resolver.requests.get",
|
||||||
|
side_effect=_rq.ConnectionError("x"),
|
||||||
|
):
|
||||||
|
assert resolve_display_name("maewix", None) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolves_on_happy_path():
|
async def test_resolves_on_happy_path():
|
||||||
fake_response = MagicMock()
|
fake_response = MagicMock()
|
||||||
|
|||||||
@@ -390,6 +390,24 @@ def test_verify_auth_bad_token():
|
|||||||
assert "rotate" in message.lower() or "rejected" in message.lower()
|
assert "rotate" in message.lower() or "rejected" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_display_name(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
client, "_call",
|
||||||
|
lambda e, p: {"user": {"name": "Kurotsuchi Machi", "id": p["user_id"]}},
|
||||||
|
)
|
||||||
|
assert client.resolve_display_name("99") == "Kurotsuchi Machi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_display_name_none_on_failure(client, monkeypatch):
|
||||||
|
def boom(endpoint, params):
|
||||||
|
raise PixivAPIError("nope", status_code=404)
|
||||||
|
monkeypatch.setattr(client, "_call", boom)
|
||||||
|
assert client.resolve_display_name("99") is None
|
||||||
|
# Empty/whitespace name → None (caller falls back to the id).
|
||||||
|
monkeypatch.setattr(client, "_call", lambda e, p: {"user": {"name": " "}})
|
||||||
|
assert client.resolve_display_name("99") is None
|
||||||
|
|
||||||
|
|
||||||
# -- rating ------------------------------------------------------------------------
|
# -- rating ------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_rating_label():
|
def test_rating_label():
|
||||||
|
|||||||
@@ -186,6 +186,94 @@ async def test_update_while_enabled_keeps_failure_state(db):
|
|||||||
assert refetched.consecutive_failures == 3
|
assert refetched.consecutive_failures == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def _source_with_content(db, svc, artist):
|
||||||
|
"""A source under `artist` with one post + one image it contributed."""
|
||||||
|
from backend.app.models import ImageProvenance, ImageRecord, Post
|
||||||
|
rec = await svc.create(
|
||||||
|
artist_id=artist.id, platform="pixiv",
|
||||||
|
url=f"https://www.pixiv.net/users/{artist.id}",
|
||||||
|
)
|
||||||
|
post = Post(source_id=rec.id, artist_id=artist.id, external_post_id="p1")
|
||||||
|
db.add(post)
|
||||||
|
img = ImageRecord(
|
||||||
|
path=f"/images/{artist.slug}/pixiv/pixiv/1_a_00.jpg",
|
||||||
|
sha256=str(artist.id).rjust(64, "0"), size_bytes=1, mime="image/jpeg",
|
||||||
|
width=1, height=1, origin="imported_filesystem",
|
||||||
|
integrity_status="unknown", artist_id=artist.id,
|
||||||
|
)
|
||||||
|
db.add(img)
|
||||||
|
await db.flush()
|
||||||
|
db.add(ImageProvenance(image_record_id=img.id, post_id=post.id, source_id=rec.id))
|
||||||
|
await db.commit()
|
||||||
|
return rec, post, img
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reassign_moves_source_posts_images(db):
|
||||||
|
from backend.app.models import ImageRecord, Post
|
||||||
|
old = await _artist(db, "OldOwner")
|
||||||
|
new = await _artist(db, "NewOwner")
|
||||||
|
svc = SourceService(db)
|
||||||
|
rec, post, img = await _source_with_content(db, svc, old)
|
||||||
|
|
||||||
|
await svc.reassign(rec.id, new.id)
|
||||||
|
|
||||||
|
assert (await db.execute(
|
||||||
|
select(Source.artist_id).where(Source.id == rec.id)
|
||||||
|
)).scalar_one() == new.id
|
||||||
|
assert (await db.execute(
|
||||||
|
select(Post.artist_id).where(Post.id == post.id)
|
||||||
|
)).scalar_one() == new.id
|
||||||
|
assert (await db.execute(
|
||||||
|
select(ImageRecord.artist_id).where(ImageRecord.id == img.id)
|
||||||
|
)).scalar_one() == new.id
|
||||||
|
# Old artist is now empty → deleted.
|
||||||
|
assert (await db.execute(
|
||||||
|
select(Artist).where(Artist.id == old.id)
|
||||||
|
)).scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reassign_keeps_nonempty_old_artist(db):
|
||||||
|
from backend.app.models import ImageRecord
|
||||||
|
old = await _artist(db, "OldMulti")
|
||||||
|
new = await _artist(db, "NewMulti")
|
||||||
|
svc = SourceService(db)
|
||||||
|
rec, _post, _img = await _source_with_content(db, svc, old)
|
||||||
|
# A second, unrelated image keeps `old` non-empty after the move.
|
||||||
|
db.add(ImageRecord(
|
||||||
|
path="/images/oldmulti/loose.jpg", sha256="e" * 64, size_bytes=1,
|
||||||
|
mime="image/jpeg", width=1, height=1, origin="imported_filesystem",
|
||||||
|
integrity_status="unknown", artist_id=old.id,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await svc.reassign(rec.id, new.id)
|
||||||
|
still = (await db.execute(
|
||||||
|
select(Artist).where(Artist.id == old.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert still.is_subscription is False # lost its last source
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reassign_same_artist_is_noop(db):
|
||||||
|
a = await _artist(db, "Solo")
|
||||||
|
svc = SourceService(db)
|
||||||
|
rec, _post, _img = await _source_with_content(db, svc, a)
|
||||||
|
out = await svc.reassign(rec.id, a.id)
|
||||||
|
assert out.artist_id == a.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reassign_unknown_target_raises(db):
|
||||||
|
from backend.app.services.source_service import ArtistNotFoundError
|
||||||
|
a = await _artist(db, "Whom")
|
||||||
|
svc = SourceService(db)
|
||||||
|
rec, _post, _img = await _source_with_content(db, svc, a)
|
||||||
|
with pytest.raises(ArtistNotFoundError):
|
||||||
|
await svc.reassign(rec.id, 999999)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_hides_sidecar_synthetic_anchors(db):
|
async def test_list_hides_sidecar_synthetic_anchors(db):
|
||||||
"""Filesystem-import synthetic Sources (url='sidecar:<platform>:<slug>',
|
"""Filesystem-import synthetic Sources (url='sidecar:<platform>:<slug>',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from backend.app.services.subscribestar_client import (
|
|||||||
SubscribeStarAuthError,
|
SubscribeStarAuthError,
|
||||||
SubscribeStarClient,
|
SubscribeStarClient,
|
||||||
SubscribeStarDriftError,
|
SubscribeStarDriftError,
|
||||||
|
_extract_creator_name,
|
||||||
_parse_ss_datetime,
|
_parse_ss_datetime,
|
||||||
_split_creator_url,
|
_split_creator_url,
|
||||||
)
|
)
|
||||||
@@ -102,6 +103,27 @@ def test_split_creator_url_leaves_com_and_adult_untouched():
|
|||||||
"https://www.subscribestar.com"
|
"https://www.subscribestar.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_creator_name_prefers_og_title():
|
||||||
|
html = (
|
||||||
|
'<head><meta property="og:title" content="Sabu & Friends">'
|
||||||
|
'<title>Sabu | SubscribeStar</title></head>'
|
||||||
|
)
|
||||||
|
assert _extract_creator_name(html) == "Sabu & Friends"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_creator_name_title_fallback_strips_suffix():
|
||||||
|
assert _extract_creator_name(
|
||||||
|
"<head><title>Elasid on SubscribeStar</title></head>"
|
||||||
|
) == "Elasid"
|
||||||
|
assert _extract_creator_name(
|
||||||
|
"<head><title>Cheun | SubscribeStar — adult</title></head>"
|
||||||
|
) == "Cheun"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_creator_name_none_when_absent():
|
||||||
|
assert _extract_creator_name("<html><body>no title</body></html>") is None
|
||||||
|
|
||||||
|
|
||||||
def test_date_parses_when_wrapped_in_permalink_anchor():
|
def test_date_parses_when_wrapped_in_permalink_anchor():
|
||||||
# Image posts wrap the date in an <a> permalink; a plain regex missed them
|
# Image posts wrap the date in an <a> permalink; a plain regex missed them
|
||||||
# → null dates (cheunart 2026-06-17). gallery-dl's method handles both.
|
# → null dates (cheunart 2026-06-17). gallery-dl's method handles both.
|
||||||
|
|||||||
Reference in New Issue
Block a user