diff --git a/alembic/versions/0077_artist_name_not_unique.py b/alembic/versions/0077_artist_name_not_unique.py new file mode 100644 index 0000000..6a09288 --- /dev/null +++ b/alembic/versions/0077_artist_name_not_unique.py @@ -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"]) diff --git a/backend/app/api/artists.py b/backend/app/api/artists.py index fa6da74..939fe4f 100644 --- a/backend/app/api/artists.py +++ b/backend/app/api/artists.py @@ -31,6 +31,24 @@ async def create_or_get(): }), 201 +@artists_bp.route("/", 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"]) async def autocomplete(): q = request.args.get("q") or "" diff --git a/backend/app/api/extension.py b/backend/app/api/extension.py index b917882..20b1b30 100644 --- a/backend/app/api/extension.py +++ b/backend/app/api/extension.py @@ -84,11 +84,15 @@ async def quick_add_source(): if not isinstance(url, str) or not url.strip(): return _bad("invalid_body", detail="url is required") + from .credentials import _get_crypto + async with get_session() as session: if not await _ext_key_required(session): return _bad("unauthorized", status=401) 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: return _bad( "unknown_platform", diff --git a/backend/app/api/sources.py b/backend/app/api/sources.py index 5b3fcaf..44227cb 100644 --- a/backend/app/api/sources.py +++ b/backend/app/api/sources.py @@ -136,6 +136,25 @@ async def delete_source(source_id: int): return "", 204 +@sources_bp.route("//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("//backfill", methods=["POST"]) async def set_backfill(source_id: int): """Plan #693/#697 + #830: start/stop a backfill, or start a recovery / diff --git a/backend/app/models/artist.py b/backend/app/models/artist.py index d6847f4..e7ca894 100644 --- a/backend/app/models/artist.py +++ b/backend/app/models/artist.py @@ -15,7 +15,14 @@ class Artist(Base): __tablename__ = "artist" 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// + # /…), 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) notes: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/services/artist_service.py b/backend/app/services/artist_service.py index 20acf06..a4150c8 100644 --- a/backend/app/services/artist_service.py +++ b/backend/app/services/artist_service.py @@ -283,6 +283,23 @@ class ArtistService: await self.session.commit() 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]: cleaned = (prefix or "").strip() if not cleaned: diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py index a5a932f..f8fea43 100644 --- a/backend/app/services/extension_service.py +++ b/backend/app/services/extension_service.py @@ -8,6 +8,7 @@ and returns a JSON-shaped dict for the API layer. from __future__ import annotations +import logging import re from sqlalchemy import select @@ -18,6 +19,8 @@ from ..utils.slug import slugify from .db_helpers import get_or_create from .source_service import BACKFILL_MAX_CHUNKS +log = logging.getLogger(__name__) + class UnknownPlatformError(Exception): """URL didn't match any platform pattern.""" @@ -61,15 +64,38 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ class ExtensionService: - def __init__(self, session: AsyncSession) -> None: + def __init__(self, session: AsyncSession, crypto=None) -> None: 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: 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( 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 { "source": { "id": source.id, @@ -87,6 +113,50 @@ class ExtensionService: "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: """Read-only resolution of a creator-page URL against the FC DB. Returns one of: diff --git a/backend/app/services/patreon_resolver.py b/backend/app/services/patreon_resolver.py index 591745c..f4dc3ef 100644 --- a/backend/app/services/patreon_resolver.py +++ b/backend/app/services/patreon_resolver.py @@ -139,6 +139,32 @@ def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None: 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: """First campaign id found in creator-page HTML via the known embeddings.""" if not isinstance(html, str): diff --git a/backend/app/services/pixiv_client.py b/backend/app/services/pixiv_client.py index e6b5fea..6335827 100644 --- a/backend/app/services/pixiv_client.py +++ b/backend/app/services/pixiv_client.py @@ -540,6 +540,19 @@ class PixivClient: return 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 -------------------------------------------------------------- def verify_auth(self) -> tuple[bool | None, str]: diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py index 525cb1e..dcc66d5 100644 --- a/backend/app/services/source_service.py +++ b/backend/app/services/source_service.py @@ -4,11 +4,18 @@ is_subscription auto-flip on first add / last delete. from dataclasses import dataclass -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.exc import IntegrityError 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 .scheduler_service import compute_next_check_at @@ -420,6 +427,70 @@ class SourceService: 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) diff --git a/backend/app/services/subscribestar_client.py b/backend/app/services/subscribestar_client.py index 538e2df..ffdedba 100644 --- a/backend/app/services/subscribestar_client.py +++ b/backend/app/services/subscribestar_client.py @@ -273,6 +273,30 @@ def _parse_ss_datetime(text: str) -> str | None: return None +_OG_TITLE_RE = re.compile( + r']+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']', + re.IGNORECASE, +) +_TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) +# Trailing " | SubscribeStar" / " on SubscribeStar" the profile 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: """Synchronous SubscribeStar HTML-scrape read client. Construct with a path to a Netscape cookies.txt (the same file CredentialService.get_cookies_path @@ -604,6 +628,23 @@ class SubscribeStarClient: current = next_href 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 ------------------------------------------------------------ def verify_auth(self, campaign_id: str) -> tuple[bool | None, str]: diff --git a/frontend/src/components/artist/ArtistHeader.vue b/frontend/src/components/artist/ArtistHeader.vue index a2b1348..2c63210 100644 --- a/frontend/src/components/artist/ArtistHeader.vue +++ b/frontend/src/components/artist/ArtistHeader.vue @@ -1,8 +1,21 @@ <template> <header class="fc-artist-header"> <div class="fc-artist-header__left"> - <h1 class="fc-artist-header__name">{{ name }}</h1> - <span v-if="stats" class="fc-artist-header__stats">{{ stats }}</span> + <template v-if="!editing"> + <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> <v-tabs :model-value="modelValue" @@ -34,7 +47,7 @@ </template> <script setup> -import { computed } from 'vue' +import { computed, ref } from 'vue' import { formatLocalDate } from '../../utils/date.js' const props = defineProps({ @@ -45,7 +58,23 @@ const props = defineProps({ 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 parts = [] @@ -111,6 +140,20 @@ const stats = computed(() => { 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 { flex: 0 0 auto; } diff --git a/frontend/src/components/artist/ArtistManagementTab.vue b/frontend/src/components/artist/ArtistManagementTab.vue index b78c2a1..c6ab511 100644 --- a/frontend/src/components/artist/ArtistManagementTab.vue +++ b/frontend/src/components/artist/ArtistManagementTab.vue @@ -47,18 +47,60 @@ </div> <v-table density="compact"> <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> <tbody> <tr v-for="s in overview.sources" :key="s.id"> <td>{{ s.platform }}</td> <td class="fc-artist-mgmt__url">{{ s.url }}</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> </tbody> </v-table> </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"> <h2 class="fc-h2">Danger zone</h2> <ArtistDangerZone @@ -71,9 +113,11 @@ </template> <script setup> -import { computed } from 'vue' +import { computed, ref } from 'vue' import { useRouter, RouterLink } from 'vue-router' +import { useSourcesStore } from '../../stores/sources.js' +import { toast } from '../../utils/toast.js' import ArtistDangerZone from './ArtistDangerZone.vue' const props = defineProps({ @@ -81,6 +125,53 @@ const props = defineProps({ }) 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 sparkH = 80 diff --git a/frontend/src/stores/artist.js b/frontend/src/stores/artist.js index 8940009..3e8fa63 100644 --- a/frontend/src/stores/artist.js +++ b/frontend/src/stores/artist.js @@ -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 postCount = computed(() => overview.value?.post_count ?? null) const imageCount = computed(() => overview.value?.image_count ?? null) @@ -81,6 +91,6 @@ export const useArtistStore = defineStore('artist', () => { return { overview, images, loading, imagesLoading, error, notFound, hasMoreImages, postCount, imageCount, lastAdded, - load, loadMoreImages, + load, loadMoreImages, rename, } }) diff --git a/frontend/src/stores/sources.js b/frontend/src/stores/sources.js index 10c16b5..d169873 100644 --- a/frontend/src/stores/sources.js +++ b/frontend/src/stores/sources.js @@ -84,6 +84,15 @@ export const useSourcesStore = defineStore('sources', () => { 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() { scheduleStatus.value = await api.get('/api/sources/schedule-status') return scheduleStatus.value @@ -177,7 +186,7 @@ export const useSourcesStore = defineStore('sources', () => { recoverSource, recaptureSource, previewSource, - findOrCreateArtist, autocompleteArtist, + findOrCreateArtist, autocompleteArtist, reassign, loadScheduleStatus, sourcesByArtistGrouped, } diff --git a/frontend/src/views/ArtistView.vue b/frontend/src/views/ArtistView.vue index e9ff68a..372eec2 100644 --- a/frontend/src/views/ArtistView.vue +++ b/frontend/src/views/ArtistView.vue @@ -18,6 +18,7 @@ :image-count="store.imageCount" :post-count="store.postCount" :last-added="store.lastAdded" + @rename="onRename" /> <v-container fluid class="pt-2 pb-4"> <!-- "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 { useArtistStore } from '../stores/artist.js' +import { toast } from '../utils/toast.js' import ArtistHeader from '../components/artist/ArtistHeader.vue' import ArtistPostsTab from '../components/artist/ArtistPostsTab.vue' import ArtistGalleryTab from '../components/artist/ArtistGalleryTab.vue' @@ -76,6 +78,17 @@ const { tab, resolve } = useTabQuery( () => ((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 // when navigating between artists that each have unseen content. const unseenBanner = ref(false) diff --git a/tests/test_api_artists_create.py b/tests/test_api_artists_create.py index 5e44b16..5e7750e 100644 --- a/tests/test_api_artists_create.py +++ b/tests/test_api_artists_create.py @@ -47,3 +47,28 @@ async def test_autocomplete_empty_query(client): resp = await client.get("/api/artists/autocomplete?q=") assert resp.status_code == 200 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 diff --git a/tests/test_api_extension.py b/tests/test_api_extension.py index 98c7dec..d3010e0 100644 --- a/tests/test_api_extension.py +++ b/tests/test_api_extension.py @@ -65,6 +65,64 @@ async def test_quick_add_source_idempotent(client, ext_key): 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", [ ("https://www.patreon.com/maewix", "patreon", "maewix"), ("https://patreon.com/maewix", "patreon", "maewix"), diff --git a/tests/test_artist_service_find_or_create.py b/tests/test_artist_service_find_or_create.py index 4bc3959..e9bb838 100644 --- a/tests/test_artist_service_find_or_create.py +++ b/tests/test_artist_service_find_or_create.py @@ -54,3 +54,40 @@ async def test_autocomplete_ranks_exact_prefix_substring(db): async def test_autocomplete_empty_query_returns_empty(db): svc = ArtistService(db) 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 diff --git a/tests/test_patreon_resolver.py b/tests/test_patreon_resolver.py index 4a92b1c..cd86025 100644 --- a/tests/test_patreon_resolver.py +++ b/tests/test_patreon_resolver.py @@ -5,9 +5,34 @@ import pytest from backend.app.services.patreon_resolver import ( resolve_campaign_id, 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 async def test_resolves_on_happy_path(): fake_response = MagicMock() diff --git a/tests/test_pixiv_client.py b/tests/test_pixiv_client.py index 1c60443..805c409 100644 --- a/tests/test_pixiv_client.py +++ b/tests/test_pixiv_client.py @@ -390,6 +390,24 @@ def test_verify_auth_bad_token(): 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 ------------------------------------------------------------------------ def test_rating_label(): diff --git a/tests/test_source_service.py b/tests/test_source_service.py index e9fc942..a9bdc28 100644 --- a/tests/test_source_service.py +++ b/tests/test_source_service.py @@ -186,6 +186,94 @@ async def test_update_while_enabled_keeps_failure_state(db): 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 async def test_list_hides_sidecar_synthetic_anchors(db): """Filesystem-import synthetic Sources (url='sidecar:<platform>:<slug>', diff --git a/tests/test_subscribestar_native.py b/tests/test_subscribestar_native.py index e360551..0545a7b 100644 --- a/tests/test_subscribestar_native.py +++ b/tests/test_subscribestar_native.py @@ -21,6 +21,7 @@ from backend.app.services.subscribestar_client import ( SubscribeStarAuthError, SubscribeStarClient, SubscribeStarDriftError, + _extract_creator_name, _parse_ss_datetime, _split_creator_url, ) @@ -102,6 +103,27 @@ def test_split_creator_url_leaves_com_and_adult_untouched(): "https://www.subscribestar.com" +def test_extract_creator_name_prefers_og_title(): + html = ( + '<head><meta property="og:title" content="Sabu & Friends">' + '<title>Sabu | SubscribeStar' + ) + assert _extract_creator_name(html) == "Sabu & Friends" + + +def test_extract_creator_name_title_fallback_strips_suffix(): + assert _extract_creator_name( + "Elasid on SubscribeStar" + ) == "Elasid" + assert _extract_creator_name( + "Cheun | SubscribeStar — adult" + ) == "Cheun" + + +def test_extract_creator_name_none_when_absent(): + assert _extract_creator_name("no title") is None + + def test_date_parses_when_wrapped_in_permalink_anchor(): # Image posts wrap the date in an permalink; a plain regex missed them # → null dates (cheunart 2026-06-17). gallery-dl's method handles both.