diff --git a/backend/app/services/download_service.py b/backend/app/services/download_service.py index 1c8b6ae..dae6f06 100644 --- a/backend/app/services/download_service.py +++ b/backend/app/services/download_service.py @@ -279,6 +279,21 @@ class DownloadService: for img_id in ids: generate_thumbnail.delay(img_id) tag_and_embed.delay(img_id) + elif result.status == "attached": + # Non-media or extracted archive captured as PostAttachment + # (FC-2d-iii). The canonical copy lives in the attachments + # store; the original download path is now redundant — + # mirror duplicate_hash cleanup so we don't keep two copies. + # Operator-flagged 2026-06-02 (Lustria OST zip). + import_summary["attached"] += 1 + try: + bytes_downloaded += path.stat().st_size # noqa: ASYNC240 + except OSError: + pass + try: + path.unlink(missing_ok=True) # noqa: ASYNC240 + except OSError: + pass elif result.status == "skipped" and result.skip_reason and result.skip_reason.value in ( "duplicate_hash", "duplicate_phash", ): @@ -287,6 +302,12 @@ class DownloadService: path.unlink(missing_ok=True) # noqa: ASYNC240 except OSError: pass + elif result.status == "skipped": + # Soft skip (too_small, too_transparent, invalid_image) — + # the file just didn't qualify, not a download/ingest + # failure. Don't flag the run as error; the file stays + # on disk for operator inspection. + import_summary["skipped"] += 1 else: import_summary["errors"] += 1 diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py index 6e3ed23..fb39052 100644 --- a/backend/app/services/importer.py +++ b/backend/app/services/importer.py @@ -650,16 +650,34 @@ class Importer: them through. The sidecar JSON gallery-dl emits next to each downloaded file is read by `_apply_sidecar` via `find_sidecar`. + File-type dispatch parity with `import_one` (FC-2d-iii): zips, + PDFs, audio etc. become PostAttachments; archives are extracted. + Without this dispatch, gallery-dl-downloaded non-media bounced + back as `skipped+invalid_image`, which DownloadService counted + as an ingest error and flipped otherwise-successful runs to + status="error". Operator-flagged 2026-06-02 after a Lustria + patreon run with a 94MB OST zip went red despite 21 successful + image attaches. + Caller's responsibilities after this returns: - duplicate_hash / duplicate_phash skip → delete the on-disk file - superseded → file stays where it is (now canonical) - imported → file stays where it is + - attached → the file's been copied into the attachments store; + caller may delete the on-disk original (mirrors duplicate_hash) - failed → file untouched; caller decides """ - if not is_supported(path): + if path.suffix.lower() == ".json": return ImportResult( status="skipped", skip_reason=SkipReason.invalid_image, - error=f"unsupported extension {path.suffix}", + error="sidecar json is metadata, not content", + ) + if is_archive(path): + return self._import_archive(path) + if not is_supported(path): + post = self._post_for_sidecar(path, artist) if artist else None + return self._capture_attachment( + path, post=post, artist=artist, resolved=True, ) # Format / dimension / transparency filters (mirror _import_media). diff --git a/frontend/src/components/modal/SuggestionsPanel.vue b/frontend/src/components/modal/SuggestionsPanel.vue index 68e8040..e56d7a5 100644 --- a/frontend/src/components/modal/SuggestionsPanel.vue +++ b/frontend/src/components/modal/SuggestionsPanel.vue @@ -41,11 +41,13 @@ import { toast } from '../../utils/toast.js' import { computed, ref, watch } from 'vue' import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js' +import { useModalStore } from '../../stores/modal.js' import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue' import AliasPickerDialog from './AliasPickerDialog.vue' const props = defineProps({ imageId: { type: Number, required: true } }) const store = useSuggestionsStore() +const modal = useModalStore() // 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as // suggestion categories. Only 'character' remains as a people-style @@ -59,9 +61,20 @@ const isEmpty = computed(() => watch(() => props.imageId, (id) => { if (id != null) store.load(id) }, { immediate: true }) +// After a successful accept/alias-accept, refresh the modal's current +// tag list so TagPanel's chip rail reflects the newly-attached tag. +// Operator-flagged 2026-06-01: the suggestion store dropped the +// suggestion (correct) but didn't propagate the new tag back into the +// modal store, so the chip rail looked unchanged even though the +// backend had recorded the application. Mirrors the addExistingTag / +// createAndAdd flows which already call reloadTags() after applying. async function onAccept(s) { - try { await store.accept(s) } - catch (e) { toast({ text: `Accept failed: ${e.message}`, type: 'error' }) } + try { + await store.accept(s) + await modal.reloadTags() + } catch (e) { + toast({ text: `Accept failed: ${e.message}`, type: 'error' }) + } } const aliasDialog = ref(false) @@ -71,6 +84,7 @@ async function onAliasConfirm(canonicalTagId) { try { await store.aliasAccept(aliasTarget.value, canonicalTagId) aliasDialog.value = false + await modal.reloadTags() } catch (e) { toast({ text: `Alias failed: ${e.message}`, type: 'error' }) } diff --git a/frontend/src/stores/showcase.js b/frontend/src/stores/showcase.js index 72e5967..78da18b 100644 --- a/frontend/src/stores/showcase.js +++ b/frontend/src/stores/showcase.js @@ -19,6 +19,16 @@ import { useApi } from '../composables/useApi.js' const PAGE = 3 const INITIAL_BATCHES = 20 const APPEND_DELAY_MS = 80 // ≈ the MasonryGrid stagger animation (70 ms) +// Operator-flagged 2026-06-01: scrolling the showcase eventually hit a +// premature "End." because /api/showcase returns a *random sample* and +// after enough scrolling the `seen` Set accumulated enough to fully +// collide with a 3-item batch. The showcase is supposed to be endless; +// only a genuinely empty API response (library has zero images) should +// mark it exhausted. Retry up to FETCH_RETRY_CAP times on all-dupe +// batches; only flip `exhausted` when the API returns 0 items OR every +// retry came back dupe-only (graceful fallback for tiny libraries +// where retries will keep returning the same handful of items). +const FETCH_RETRY_CAP = 8 function _sleep(ms) { return new Promise(r => setTimeout(r, ms)) } @@ -48,17 +58,32 @@ export const useShowcaseStore = defineStore('showcase', () => { } } - // Single batch — used by infinite-scroll appends. Trickles its 5 items - // in for the same one-at-a-time cadence as the initial load. + // Single batch — used by infinite-scroll appends. Trickles its items + // in for the same one-at-a-time cadence as the initial load. Retries + // up to FETCH_RETRY_CAP times when the API's random sample comes back + // all-duplicates (the showcase is endless by design; only a genuinely + // empty API response should mark it exhausted, not an unlucky sample). async function fetchPage() { if (loading.value) return loading.value = true error.value = null try { - const body = await api.get('/api/showcase', { params: { limit: PAGE } }) - const fresh = (body.images || []).filter(i => !seen.has(i.id)) - if (fresh.length === 0) { exhausted.value = true; return } - await _trickleAppend(fresh, _seq) + for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) { + const body = await api.get('/api/showcase', { params: { limit: PAGE } }) + const items = body.images || [] + // API genuinely empty → library is empty / endpoint exhausted. + if (items.length === 0) { exhausted.value = true; return } + const fresh = items.filter(i => !seen.has(i.id)) + if (fresh.length > 0) { + await _trickleAppend(fresh, _seq) + return + } + // All-dupes batch — keep trying. Showcase is endless by intent. + } + // Retry cap hit with zero fresh items: library is probably much + // smaller than the running `seen` set, fall back to exhausted so + // the UI stops trying. Operator can shuffle to reset `seen`. + exhausted.value = true } catch (e) { error.value = e.message || String(e) } finally { diff --git a/frontend/src/views/ShowcaseView.vue b/frontend/src/views/ShowcaseView.vue index 63de41f..293e596 100644 --- a/frontend/src/views/ShowcaseView.vue +++ b/frontend/src/views/ShowcaseView.vue @@ -70,7 +70,12 @@ function onKeydown(e) { } onMounted(() => { - if (store.images.length === 0) store.loadInitial() + // Operator-stated 2026-06-02: every load of the showcase view should + // show new images. Pinia persists the store across navigations, so + // returning to /showcase used to render the same set as before. Now + // we always reshuffle on mount — loadInitial() resets `seen`, + // `images`, and `exhausted` and pipelines a fresh batch. + store.loadInitial() window.addEventListener('keydown', onKeydown) }) onUnmounted(() => window.removeEventListener('keydown', onKeydown)) diff --git a/tests/test_importer_attach_in_place.py b/tests/test_importer_attach_in_place.py index b3985ab..8e81ef5 100644 --- a/tests/test_importer_attach_in_place.py +++ b/tests/test_importer_attach_in_place.py @@ -159,3 +159,30 @@ def test_attach_in_place_invalid_image_returns_skipped(importer): result = importer.attach_in_place(bad) assert result.status == "skipped" assert result.skip_reason.value == "invalid_image" + + +def test_attach_in_place_non_media_routes_to_attachment(importer, db_sync): + """FC-2d-iii dispatch parity for the download path. A non-media file + (.pdf, .txt etc.) downloaded by gallery-dl must become a PostAttachment, + not bounce back as `skipped+invalid_image` — the latter flipped + otherwise-successful runs to status="error" downstream. Operator-flagged + 2026-06-02 (Lustria patreon OST zip).""" + from backend.app.models import PostAttachment + + images_root = importer.images_root + artist = Artist(name="Gus", slug="gus") + db_sync.add(artist) + db_sync.flush() + + txt = images_root / "gus" / "patreon" / "post" / "notes.txt" + txt.parent.mkdir(parents=True, exist_ok=True) + txt.write_bytes(b"some non-media payload that the importer should preserve") + + result = importer.attach_in_place(txt, artist=artist) + assert result.status == "attached" + + row = db_sync.execute( + select(PostAttachment).where(PostAttachment.original_filename == "notes.txt") + ).scalar_one() + assert row.ext == ".txt" + assert row.artist_id == artist.id