Artist identity/storage/display decoupling — rename, move-source, add-time name resolution (#130) #193

Merged
bvandeusen merged 4 commits from dev into main 2026-07-04 22:43:12 -04:00
23 changed files with 771 additions and 14 deletions
@@ -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"])
+18
View File
@@ -31,6 +31,24 @@ async def create_or_get():
}), 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"])
async def autocomplete():
q = request.args.get("q") or ""
+5 -1
View File
@@ -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",
+19
View File
@@ -136,6 +136,25 @@ async def delete_source(source_id: int):
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"])
async def set_backfill(source_id: int):
"""Plan #693/#697 + #830: start/stop a backfill, or start a recovery /
+8 -1
View File
@@ -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/<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)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+17
View File
@@ -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:
+72 -2
View File
@@ -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:
+26
View File
@@ -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):
+13
View File
@@ -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]:
+73 -2
View File
@@ -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)
@@ -273,6 +273,30 @@ def _parse_ss_datetime(text: str) -> str | 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:
"""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]:
@@ -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;
}
@@ -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
+11 -1
View File
@@ -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,
}
})
+10 -1
View File
@@ -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,
}
+13
View File
@@ -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)
+25
View File
@@ -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
+58
View File
@@ -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"),
@@ -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
+25
View File
@@ -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()
+18
View File
@@ -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():
+88
View File
@@ -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>',
+22
View File
@@ -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 &amp; 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():
# 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.