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