Artist identity/storage/display decoupling — rename, move-source, add-time name resolution (#130) #193
@@ -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
|
||||
|
||||
|
||||
@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 ""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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>',
|
||||
|
||||
@@ -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</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.
|
||||
|
||||
Reference in New Issue
Block a user