From 937421485d75ab347c886d00568cb61d77781bbf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 23:02:57 -0400 Subject: [PATCH 1/3] fix(modal): refresh tag chips after accepting a suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-flagged 2026-06-01: clicking Accept on a suggestion added the tag to the database but the modal's chip rail kept showing the old set. Suggestion would disappear from the suggestions list (suggestionsStore drops it from byCategory) and the toast would confirm "Tagged: …", but visually the modal looked unchanged until you closed and reopened it. Root cause: useSuggestionsStore.accept() POSTs to the backend and updates its own state, but never tells useModalStore that the backing image's tag set has changed. The addExistingTag and createAndAdd flows in TagPanel already call modal.reloadTags() after applying — the suggestions path needed the same refresh. Two-line fix in SuggestionsPanel: after store.accept() and store.aliasAccept(), call modal.reloadTags() so TagPanel's `modal.current?.tags` rebinds. --- .../src/components/modal/SuggestionsPanel.vue | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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' }) } From 412edec028b3414912e85ab3a1f86b5383937113 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 23:37:21 -0400 Subject: [PATCH 2/3] fix(showcase): don't exhaust on all-dupe batches; retry up to 8x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/showcase returns a random sample; after enough scrolling, an unlucky 3-item batch can be entirely in the `seen` Set, which was flipping `exhausted=true` and surfacing "End." mid-scroll. The showcase is endless by intent — only a genuinely empty API response (library has 0 images) should mark it exhausted. Tiny-library fallback: cap retries at 8 to avoid spinning forever. --- frontend/src/stores/showcase.js | 37 +++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) 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 { From 91be9df671830015e88221d2a187a9024d230db1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 08:16:13 -0400 Subject: [PATCH 3/3] fix(download): dispatch archive/non-media in attach_in_place; reshuffle showcase on mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attach_in_place mirrored only the media flow, so gallery-dl-downloaded zips/PDFs/audio bounced back as `skipped+invalid_image`, which download_service counted as an ingest error and flipped runs to status="error" despite N successful image attaches. Lustria patreon event #38998 (21 images + 1 OST zip) went red for exactly this reason. Now attach_in_place dispatches the same way as import_one: archives → _import_archive (extracts media members, captures archive as PostAttachment), non-media → _capture_attachment. Download_service accepts the new `attached` result and treats non-duplicate skips as soft skips, not ingest errors. Also: ShowcaseView always loadInitial() on mount, not just when the store is empty — Pinia persists across navigations and operator wants a fresh shuffle every time the showcase loads. --- backend/app/services/download_service.py | 21 ++++++++++++++++++ backend/app/services/importer.py | 22 +++++++++++++++++-- frontend/src/views/ShowcaseView.vue | 7 +++++- tests/test_importer_attach_in_place.py | 27 ++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) 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/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