Merge pull request 'UX fixes: suggestion-accept chip refresh, showcase endless feed, non-media downloads' (#48) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m20s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m20s
This commit was merged in pull request #48.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user