feat(artist): move a source into another artist (#130 step 4)
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:
@@ -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