feat(artist): move a source into another artist (#130 step 4)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m36s

Operator ask: a surface to merge new sources into existing artists (consolidate
the singleton artist a fresh add spins up). Enabled by the #130 slug decoupling —
the storage path is immutable, so re-attribution moves NO files. SourceService
.reassign moves the source, re-points its posts (Post.source_id==S) and the
images it contributed (ImageProvenance via S, scoped to the old artist so shared
images aren't stolen), and deletes the old artist if it's left fully empty (else
clears its subscription flag). POST /api/sources/<id>/reassign. Frontend: a
'Move…' action per source on the artist Management tab → artist-autocomplete
picker → confirm → routes to the target (whose slug is stable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-04 22:15:38 -04:00
parent 87d53db0cb
commit a69bd1baa8
5 changed files with 283 additions and 5 deletions
+19
View File
@@ -136,6 +136,25 @@ async def delete_source(source_id: int):
return "", 204 return "", 204
@sources_bp.route("/<int:source_id>/reassign", methods=["POST"])
async def reassign_source(source_id: int):
"""Move this source (and the content it brought in) to another artist
(#130). Files don't move — the slug is immutable — so this just re-attributes
the source, its posts, and its images. Body: {target_artist_id}."""
body = await request.get_json(silent=True) or {}
target = body.get("target_artist_id")
if not isinstance(target, int):
return _bad("invalid_body", detail="target_artist_id (int) required")
async with get_session() as session:
try:
record = await SourceService(session).reassign(source_id, target)
except LookupError:
return _bad("not_found", status=404)
except ArtistNotFoundError:
return _bad("artist_not_found", detail="target artist not found", status=404)
return jsonify(record.to_dict())
@sources_bp.route("/<int:source_id>/backfill", methods=["POST"]) @sources_bp.route("/<int:source_id>/backfill", methods=["POST"])
async def set_backfill(source_id: int): async def set_backfill(source_id: int):
"""Plan #693/#697 + #830: start/stop a backfill, or start a recovery / """Plan #693/#697 + #830: start/stop a backfill, or start a recovery /
+73 -2
View File
@@ -4,11 +4,18 @@ is_subscription auto-flip on first add / last delete.
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy import func, select from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, ImportSettings, Source from ..models import (
Artist,
ImageProvenance,
ImageRecord,
ImportSettings,
Post,
Source,
)
from .platforms import known_platform_keys from .platforms import known_platform_keys
from .scheduler_service import compute_next_check_at from .scheduler_service import compute_next_check_at
@@ -420,6 +427,70 @@ class SourceService:
await self.session.commit() await self.session.commit()
return await self._row_to_record(source) return await self._row_to_record(source)
async def reassign(self, source_id: int, target_artist_id: int) -> SourceRecord:
"""Move a Source — and the content it brought in — to another artist
(#130). The slug/storage path is IMMUTABLE, so NO files move: only the
artist attribution changes (reads use ImageRecord.path). Re-points the
source's Posts and the ImageRecords it contributed (those still
attributed to the old artist — images shared with another artist are
left alone). If the old artist is left fully empty (no sources, images,
or posts) it's deleted (ArtistVisit cascades); if it just lost its last
source, its is_subscription flag clears. No-op when already on target."""
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
raise LookupError(f"source id={source_id} not found")
target = await self._artist_or_raise(target_artist_id)
old_artist_id = source.artist_id
if old_artist_id == target_artist_id:
return await self._row_to_record(source)
source.artist_id = target_artist_id
target.is_subscription = True
# Re-attribute this source's posts (Post.artist_id is denormalized).
await self.session.execute(
update(Post).where(Post.source_id == source_id)
.values(artist_id=target_artist_id)
)
# Re-attribute the images this source contributed that are still on the
# OLD artist. Scoping to artist_id == old avoids stealing an image that
# a different artist's source also contributed.
contributed = select(ImageProvenance.image_record_id).where(
ImageProvenance.source_id == source_id
)
await self.session.execute(
update(ImageRecord)
.where(
ImageRecord.artist_id == old_artist_id,
ImageRecord.id.in_(contributed),
)
.values(artist_id=target_artist_id)
)
await self.session.flush()
old = (await self.session.execute(
select(Artist).where(Artist.id == old_artist_id)
)).scalar_one_or_none()
if old is not None:
n_src = (await self.session.execute(
select(func.count(Source.id)).where(Source.artist_id == old_artist_id)
)).scalar_one()
n_img = (await self.session.execute(
select(func.count(ImageRecord.id)).where(
ImageRecord.artist_id == old_artist_id
)
)).scalar_one()
n_post = (await self.session.execute(
select(func.count(Post.id)).where(Post.artist_id == old_artist_id)
)).scalar_one()
if n_src == 0 and n_img == 0 and n_post == 0:
await self.session.delete(old) # ArtistVisit cascades
elif n_src == 0:
old.is_subscription = False
await self.session.commit()
return await self._row_to_record(source)
async def delete(self, source_id: int) -> None: async def delete(self, source_id: int) -> None:
source = (await self.session.execute( source = (await self.session.execute(
select(Source).where(Source.id == source_id) select(Source).where(Source.id == source_id)
@@ -47,18 +47,60 @@
</div> </div>
<v-table density="compact"> <v-table density="compact">
<thead> <thead>
<tr><th>Platform</th><th>URL</th><th class="text-right">Images</th></tr> <tr>
<th>Platform</th><th>URL</th>
<th class="text-right">Images</th><th></th>
</tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="s in overview.sources" :key="s.id"> <tr v-for="s in overview.sources" :key="s.id">
<td>{{ s.platform }}</td> <td>{{ s.platform }}</td>
<td class="fc-artist-mgmt__url">{{ s.url }}</td> <td class="fc-artist-mgmt__url">{{ s.url }}</td>
<td class="text-right">{{ s.image_count }}</td> <td class="text-right">{{ s.image_count }}</td>
<td class="text-right">
<v-btn
size="x-small" variant="text" prepend-icon="mdi-account-arrow-right"
@click="openMove(s)"
>Move</v-btn>
</td>
</tr> </tr>
</tbody> </tbody>
</v-table> </v-table>
</section> </section>
<!-- #130: move a source into a different (existing) artist. The slug is
immutable so no files move just re-attribution. -->
<v-dialog v-model="moveOpen" max-width="480">
<v-card>
<v-card-title class="fc-h2">Move source to another artist</v-card-title>
<v-card-text>
<p class="mb-3 text-body-2">
Moving <strong>{{ moveSource?.platform }}</strong> and the posts/images
it brought in to another artist. Files aren't moved — only the
attribution changes. If <strong>{{ overview.name }}</strong> is left
empty it will be removed.
</p>
<v-autocomplete
v-model="moveTarget"
:items="artistItems"
:loading="searching"
item-title="name" item-value="id" return-object
label="Target artist" density="compact" variant="outlined"
no-filter hide-details autofocus clearable
@update:search="onSearch"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="moveOpen = false">Cancel</v-btn>
<v-btn
color="accent" :disabled="!moveTarget || moving" :loading="moving"
@click="confirmMove"
>Move</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<section class="fc-artist-mgmt__sec"> <section class="fc-artist-mgmt__sec">
<h2 class="fc-h2">Danger zone</h2> <h2 class="fc-h2">Danger zone</h2>
<ArtistDangerZone <ArtistDangerZone
@@ -71,9 +113,11 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRouter, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import { useSourcesStore } from '../../stores/sources.js'
import { toast } from '../../utils/toast.js'
import ArtistDangerZone from './ArtistDangerZone.vue' import ArtistDangerZone from './ArtistDangerZone.vue'
const props = defineProps({ const props = defineProps({
@@ -81,6 +125,53 @@ const props = defineProps({
}) })
const router = useRouter() const router = useRouter()
const sources = useSourcesStore()
// #130: move a source into another artist.
const moveOpen = ref(false)
const moveSource = ref(null)
const moveTarget = ref(null)
const artistItems = ref([])
const searching = ref(false)
const moving = ref(false)
function openMove (s) {
moveSource.value = s
moveTarget.value = null
artistItems.value = []
moveOpen.value = true
}
let searchSeq = 0
async function onSearch (q) {
const mine = ++searchSeq
if (!q || !q.trim()) { artistItems.value = []; return }
searching.value = true
try {
const rows = await sources.autocompleteArtist(q)
// Drop the current artist — you can't move a source onto itself.
if (mine === searchSeq) artistItems.value = rows.filter(a => a.id !== props.overview.id)
} finally {
if (mine === searchSeq) searching.value = false
}
}
async function confirmMove () {
if (!moveTarget.value || !moveSource.value) return
moving.value = true
try {
await sources.reassign(moveSource.value.id, moveTarget.value.id)
moveOpen.value = false
toast({ text: `Moved to ${moveTarget.value.name}`, type: 'success' })
// The current artist may now be empty (deleted); route to the target,
// whose slug is stable (immutable).
router.push({ name: 'artist', params: { slug: moveTarget.value.slug } })
} catch (e) {
toast({ text: `Move failed: ${e.message}`, type: 'error' })
} finally {
moving.value = false
}
}
const sparkW = 600 const sparkW = 600
const sparkH = 80 const sparkH = 80
+10 -1
View File
@@ -84,6 +84,15 @@ export const useSourcesStore = defineStore('sources', () => {
return await api.get('/api/artists/autocomplete', { params: { q: query, limit } }) return await api.get('/api/artists/autocomplete', { params: { q: query, limit } })
} }
// #130: move a source (+ its content) to another artist. Files don't move
// (slug is immutable); the backend re-attributes source/posts/images and
// deletes the old artist if it's left empty.
async function reassign(id, targetArtistId) {
return await api.post(`/api/sources/${id}/reassign`, {
body: { target_artist_id: targetArtistId },
})
}
async function loadScheduleStatus() { async function loadScheduleStatus() {
scheduleStatus.value = await api.get('/api/sources/schedule-status') scheduleStatus.value = await api.get('/api/sources/schedule-status')
return scheduleStatus.value return scheduleStatus.value
@@ -177,7 +186,7 @@ export const useSourcesStore = defineStore('sources', () => {
recoverSource, recoverSource,
recaptureSource, recaptureSource,
previewSource, previewSource,
findOrCreateArtist, autocompleteArtist, findOrCreateArtist, autocompleteArtist, reassign,
loadScheduleStatus, loadScheduleStatus,
sourcesByArtistGrouped, sourcesByArtistGrouped,
} }
+88
View File
@@ -186,6 +186,94 @@ async def test_update_while_enabled_keeps_failure_state(db):
assert refetched.consecutive_failures == 3 assert refetched.consecutive_failures == 3
async def _source_with_content(db, svc, artist):
"""A source under `artist` with one post + one image it contributed."""
from backend.app.models import ImageProvenance, ImageRecord, Post
rec = await svc.create(
artist_id=artist.id, platform="pixiv",
url=f"https://www.pixiv.net/users/{artist.id}",
)
post = Post(source_id=rec.id, artist_id=artist.id, external_post_id="p1")
db.add(post)
img = ImageRecord(
path=f"/images/{artist.slug}/pixiv/pixiv/1_a_00.jpg",
sha256=str(artist.id).rjust(64, "0"), size_bytes=1, mime="image/jpeg",
width=1, height=1, origin="imported_filesystem",
integrity_status="unknown", artist_id=artist.id,
)
db.add(img)
await db.flush()
db.add(ImageProvenance(image_record_id=img.id, post_id=post.id, source_id=rec.id))
await db.commit()
return rec, post, img
@pytest.mark.asyncio
async def test_reassign_moves_source_posts_images(db):
from backend.app.models import ImageRecord, Post
old = await _artist(db, "OldOwner")
new = await _artist(db, "NewOwner")
svc = SourceService(db)
rec, post, img = await _source_with_content(db, svc, old)
await svc.reassign(rec.id, new.id)
assert (await db.execute(
select(Source.artist_id).where(Source.id == rec.id)
)).scalar_one() == new.id
assert (await db.execute(
select(Post.artist_id).where(Post.id == post.id)
)).scalar_one() == new.id
assert (await db.execute(
select(ImageRecord.artist_id).where(ImageRecord.id == img.id)
)).scalar_one() == new.id
# Old artist is now empty → deleted.
assert (await db.execute(
select(Artist).where(Artist.id == old.id)
)).scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_reassign_keeps_nonempty_old_artist(db):
from backend.app.models import ImageRecord
old = await _artist(db, "OldMulti")
new = await _artist(db, "NewMulti")
svc = SourceService(db)
rec, _post, _img = await _source_with_content(db, svc, old)
# A second, unrelated image keeps `old` non-empty after the move.
db.add(ImageRecord(
path="/images/oldmulti/loose.jpg", sha256="e" * 64, size_bytes=1,
mime="image/jpeg", width=1, height=1, origin="imported_filesystem",
integrity_status="unknown", artist_id=old.id,
))
await db.commit()
await svc.reassign(rec.id, new.id)
still = (await db.execute(
select(Artist).where(Artist.id == old.id)
)).scalar_one()
assert still.is_subscription is False # lost its last source
@pytest.mark.asyncio
async def test_reassign_same_artist_is_noop(db):
a = await _artist(db, "Solo")
svc = SourceService(db)
rec, _post, _img = await _source_with_content(db, svc, a)
out = await svc.reassign(rec.id, a.id)
assert out.artist_id == a.id
@pytest.mark.asyncio
async def test_reassign_unknown_target_raises(db):
from backend.app.services.source_service import ArtistNotFoundError
a = await _artist(db, "Whom")
svc = SourceService(db)
rec, _post, _img = await _source_with_content(db, svc, a)
with pytest.raises(ArtistNotFoundError):
await svc.reassign(rec.id, 999999)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_hides_sidecar_synthetic_anchors(db): async def test_list_hides_sidecar_synthetic_anchors(db):
"""Filesystem-import synthetic Sources (url='sidecar:<platform>:<slug>', """Filesystem-import synthetic Sources (url='sidecar:<platform>:<slug>',