diff --git a/alembic/versions/0036_siglip_embedding_hnsw_index.py b/alembic/versions/0036_siglip_embedding_hnsw_index.py
new file mode 100644
index 0000000..a8c1251
--- /dev/null
+++ b/alembic/versions/0036_siglip_embedding_hnsw_index.py
@@ -0,0 +1,41 @@
+"""image_record.siglip_embedding: HNSW cosine index for "more like this"
+
+Revision ID: 0036
+Revises: 0035
+Create Date: 2026-06-04
+
+Gallery Phase 3 (visual similarity search) ranks images by
+`siglip_embedding.cosine_distance(source_embedding)`. Without an index that's
+a sequential scan computing a 1152-dim distance for every row — fine at small
+scale, but it grows linearly with the library. Add an HNSW index with
+`vector_cosine_ops` so the top-N nearest search is sub-50ms ANN.
+
+1152 dims is under pgvector's 2000-dim HNSW limit, so HNSW (no training,
+better recall than IVFFlat) is the right choice. ONE-TIME COST: building the
+index over the existing embeddings (~57k vectors on the operator's library)
+locks image_record for ~30-60s during this migration on deploy — acceptable
+for a single-operator homelab. NULL embeddings (videos / not-yet-embedded
+rows) are simply not indexed.
+"""
+from typing import Sequence, Union
+
+from alembic import op
+
+revision: str = "0036"
+down_revision: Union[str, None] = "0035"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Raw SQL: alembic's create_index doesn't express the `USING hnsw (...
+ # vector_cosine_ops)` access-method + opclass cleanly. Must match the
+ # query's cosine_distance operator class to be usable by the planner.
+ op.execute(
+ "CREATE INDEX ix_image_record_siglip_hnsw "
+ "ON image_record USING hnsw (siglip_embedding vector_cosine_ops)"
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_image_record_siglip_hnsw", table_name="image_record")
diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py
index 807dd77..4e35bab 100644
--- a/backend/app/api/gallery.py
+++ b/backend/app/api/gallery.py
@@ -10,6 +10,21 @@ from ..services.gallery_service import GalleryService
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
+def _image_json(i):
+ """Serialize a GalleryImage for the scroll/similar list responses."""
+ return {
+ "id": i.id,
+ "sha256": i.sha256,
+ "mime": i.mime,
+ "width": i.width,
+ "height": i.height,
+ "created_at": i.created_at.isoformat(),
+ "posted_at": i.posted_at.isoformat() if i.posted_at else None,
+ "thumbnail_url": i.thumbnail_url,
+ "artist": i.artist,
+ }
+
+
def _parse_date(raw):
"""Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None.
Raises ValueError (→ 400) on a malformed value."""
@@ -76,20 +91,7 @@ async def scroll():
return jsonify(
{
- "images": [
- {
- "id": i.id,
- "sha256": i.sha256,
- "mime": i.mime,
- "width": i.width,
- "height": i.height,
- "created_at": i.created_at.isoformat(),
- "posted_at": i.posted_at.isoformat() if i.posted_at else None,
- "thumbnail_url": i.thumbnail_url,
- "artist": i.artist,
- }
- for i in page.images
- ],
+ "images": [_image_json(i) for i in page.images],
"next_cursor": page.next_cursor,
"date_groups": [
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups
@@ -98,6 +100,36 @@ async def scroll():
)
+@gallery_bp.route("/similar", methods=["GET"])
+async def similar():
+ """Visual "more like this": images ranked by cosine distance to the
+ `similar_to` image's embedding. Composes with the scope filters (AND) but
+ ignores post_id and sort. Bounded top-N, no cursor."""
+ try:
+ similar_to = int(request.args["similar_to"])
+ limit = int(request.args.get("limit", "100"))
+ filters, _sort = _parse_filters()
+ except (KeyError, ValueError):
+ return jsonify({"error": "similar_to query param required"}), 400
+ # post_id is the exclusive post-detail view — not a similarity scope.
+ scope = {k: v for k, v in filters.items() if k != "post_id"}
+ async with get_session() as session:
+ svc = GalleryService(session)
+ try:
+ images = await svc.similar(image_id=similar_to, limit=limit, **scope)
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
+ if images is None:
+ return jsonify({"error": "not found"}), 404
+ return jsonify(
+ {
+ "images": [_image_json(i) for i in images],
+ "next_cursor": None,
+ "date_groups": [],
+ }
+ )
+
+
@gallery_bp.route("/timeline", methods=["GET"])
async def timeline():
try:
diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py
index b5060f3..9c31476 100644
--- a/backend/app/services/gallery_service.py
+++ b/backend/app/services/gallery_service.py
@@ -245,6 +245,27 @@ def _provenance_clause(post_id, artist_id):
return None
+def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]:
+ """Build GalleryImage list from (record, posted_at, eff_date) rows + the
+ artist hydration map. Shared by scroll() and similar()."""
+ return [
+ GalleryImage(
+ id=record.id,
+ path=record.path,
+ sha256=record.sha256,
+ mime=record.mime,
+ width=record.width,
+ height=record.height,
+ created_at=record.created_at,
+ effective_date=eff_date,
+ posted_at=posted_at,
+ thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
+ artist=artists.get(record.id),
+ )
+ for record, posted_at, eff_date in rows
+ ]
+
+
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
"""Map image_id -> {"name","slug"} via the canonical
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
@@ -324,22 +345,7 @@ class GalleryService:
artists = await _artists_for(
self.session, [r[0].id for r in rows]
)
- images = [
- GalleryImage(
- id=record.id,
- path=record.path,
- sha256=record.sha256,
- mime=record.mime,
- width=record.width,
- height=record.height,
- created_at=record.created_at,
- effective_date=eff_date,
- posted_at=posted_at,
- thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
- artist=artists.get(record.id),
- )
- for record, posted_at, eff_date in rows
- ]
+ images = _gallery_images(rows, artists)
return GalleryPage(
images=images,
next_cursor=next_cursor,
@@ -505,6 +511,49 @@ class GalleryService:
date_min=dmin, date_max=dmax,
)
+ async def similar(
+ self, image_id: int, limit: int = 100, *,
+ tag_ids: list[int] | None = None, artist_id: int | None = None,
+ media_type: str | None = None, platform: str | None = None,
+ untagged: bool = False, no_artist: bool = False,
+ date_from: datetime | None = None, date_to: datetime | None = None,
+ ) -> list[GalleryImage] | None:
+ """Visual "more like this": images ranked by cosine distance to
+ `image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036).
+ No ML inference here; the embedding was computed at import.
+
+ Returns None if the source image doesn't exist (→ 404), [] if it has
+ no embedding (a video / not-yet-embedded). Composes with the Phase-1/2
+ scope filters (AND) but REPLACES the date sort — always nearest-first,
+ bounded to `limit` (no cursor; distance-ranking has no date cursor).
+ """
+ if limit < 1 or limit > 200:
+ raise ValueError("limit must be between 1 and 200")
+ src = await self.session.get(ImageRecord, image_id)
+ if src is None:
+ return None
+ if src.siglip_embedding is None:
+ return []
+
+ distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
+ eff = _effective_date_col()
+ stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
+ stmt = _outer_join_primary_post(stmt)
+ stmt = stmt.where(
+ ImageRecord.siglip_embedding.is_not(None),
+ ImageRecord.id != image_id,
+ )
+ stmt = _apply_scope(
+ stmt, tag_ids=tag_ids, post_id=None,
+ artist_id=artist_id, media_type=media_type,
+ platform=platform, untagged=untagged, no_artist=no_artist,
+ date_from=date_from, date_to=date_to,
+ )
+ stmt = stmt.order_by(distance.asc()).limit(limit)
+ rows = (await self.session.execute(stmt)).all()
+ artists = await _artists_for(self.session, [r[0].id for r in rows])
+ return _gallery_images(rows, artists)
+
async def get_image_with_tags(self, image_id: int) -> dict | None:
record = await self.session.get(ImageRecord, image_id)
if record is None:
@@ -542,6 +591,9 @@ class GalleryService:
"height": record.height,
"size_bytes": record.size_bytes,
"integrity_status": record.integrity_status,
+ # Phase 3: lets the modal hide the "Related"/find-similar surface
+ # for images that have no embedding yet (videos / pending ML).
+ "has_embedding": record.siglip_embedding is not None,
"created_at": record.created_at.isoformat(),
"posted_at": posted_at.isoformat() if posted_at else None,
"thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
diff --git a/frontend/src/components/TopNav.vue b/frontend/src/components/TopNav.vue
index 07b5983..3800d8a 100644
--- a/frontend/src/components/TopNav.vue
+++ b/frontend/src/components/TopNav.vue
@@ -11,6 +11,8 @@
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -137,7 +161,7 @@ const health = computed(() => {
align-items: center;
flex-shrink: 0;
}
-.fc-nav-actions {
+.fc-nav-right {
flex: 1 1 0;
min-width: 0;
display: flex;
@@ -145,4 +169,22 @@ const health = computed(() => {
justify-content: flex-end;
gap: 0.5rem;
}
+.fc-nav-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+/* The hamburger only exists on mobile; the inline links carry desktop. */
+.fc-nav-burger { display: none; }
+
+@media (max-width: 768px) {
+ .fc-topnav { gap: 0.5rem; padding: 0.6rem 0.75rem; }
+ /* Fold the link row into the hamburger menu. */
+ .fc-links { display: none; }
+ .fc-nav-burger { display: inline-flex; }
+}
+@media (max-width: 480px) {
+ /* Reclaim width on the smallest phones — the glyph alone still brands. */
+ .fc-brand__text { display: none; }
+}
diff --git a/frontend/src/components/gallery/BulkEditorPanel.vue b/frontend/src/components/gallery/BulkEditorPanel.vue
index 2f8e11e..e24b335 100644
--- a/frontend/src/components/gallery/BulkEditorPanel.vue
+++ b/frontend/src/components/gallery/BulkEditorPanel.vue
@@ -186,7 +186,9 @@ async function onDeleteConfirm(token) {
diff --git a/frontend/src/components/gallery/GalleryFilterBar.vue b/frontend/src/components/gallery/GalleryFilterBar.vue
index a19063f..39c86ac 100644
--- a/frontend/src/components/gallery/GalleryFilterBar.vue
+++ b/frontend/src/components/gallery/GalleryFilterBar.vue
@@ -38,6 +38,12 @@
prepend-icon="mdi-account"
@click:close="clearArtist"
>{{ store.artistLabel || `Artist #${store.filter.artist_id}` }}
+ Similar to #{{ store.filter.similar_to }}
@@ -52,7 +58,9 @@
Videos
+
store.filter.artist_id != null ||
store.filter.media_type != null ||
store.filter.sort !== 'newest' ||
+ store.filter.similar_to != null ||
hasRefineFilters.value
)
@@ -190,6 +199,7 @@ function clearArtist() {
store.noteArtistLabel(null)
pushFilter((n) => { n.artist_id = null })
}
+function clearSimilar() { pushFilter((n) => { n.similar_to = null }) }
function setMedia(m) { pushFilter((n) => { n.media_type = m }) }
function setSort(s) { pushFilter((n) => { n.sort = s }) }
function clearAll() { router.push({ name: 'gallery', query: {} }) }
@@ -207,7 +217,7 @@ function pushFilter(mutate) {
diff --git a/frontend/src/components/modal/ImageViewer.vue b/frontend/src/components/modal/ImageViewer.vue
index 4cb8fe5..1529bbc 100644
--- a/frontend/src/components/modal/ImageViewer.vue
+++ b/frontend/src/components/modal/ImageViewer.vue
@@ -51,6 +51,9 @@
@@ -65,6 +68,7 @@ import ImageCanvas from './ImageCanvas.vue'
import VideoCanvas from './VideoCanvas.vue'
import TagPanel from './TagPanel.vue'
import ProvenancePanel from './ProvenancePanel.vue'
+import RelatedStrip from './RelatedStrip.vue'
const emit = defineEmits(['close'])
diff --git a/frontend/src/components/modal/RelatedStrip.vue b/frontend/src/components/modal/RelatedStrip.vue
new file mode 100644
index 0000000..7264df4
--- /dev/null
+++ b/frontend/src/components/modal/RelatedStrip.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/posts/PostsFilterBar.vue b/frontend/src/components/posts/PostsFilterBar.vue
index 34e0c01..d7639b1 100644
--- a/frontend/src/components/posts/PostsFilterBar.vue
+++ b/frontend/src/components/posts/PostsFilterBar.vue
@@ -13,7 +13,7 @@
clearable
no-filter
return-object
- style="min-width: 240px"
+ class="fc-posts-filters__artist"
@update:search="onArtistSearch"
@update:model-value="onArtistPicked"
/>
@@ -25,7 +25,7 @@
density="compact"
hide-details
clearable
- style="min-width: 180px"
+ class="fc-posts-filters__platform"
@update:model-value="emitFilters"
/>
@@ -135,6 +135,14 @@ watch(() => props.platform, (val) => {
display: flex;
gap: 0.75rem;
align-items: center;
+ flex-wrap: wrap;
padding-bottom: 1rem;
}
+.fc-posts-filters__artist { min-width: 240px; flex: 1 1 240px; max-width: 360px; }
+.fc-posts-filters__platform { min-width: 180px; flex: 1 1 180px; max-width: 240px; }
+/* Phones: each field takes a full-width row instead of overflowing. */
+@media (max-width: 600px) {
+ .fc-posts-filters__artist,
+ .fc-posts-filters__platform { min-width: 100%; max-width: none; flex-basis: 100%; }
+}
diff --git a/frontend/src/components/subscriptions/SubscriptionsTab.vue b/frontend/src/components/subscriptions/SubscriptionsTab.vue
index 73cbb0b..cee737b 100644
--- a/frontend/src/components/subscriptions/SubscriptionsTab.vue
+++ b/frontend/src/components/subscriptions/SubscriptionsTab.vue
@@ -26,14 +26,14 @@
:items="STATUS_OPTIONS"
:disabled="needsAttention"
density="compact" variant="outlined" hide-details
- style="max-width: 180px"
+ class="fc-subs__status"
/>
@@ -568,6 +568,14 @@ async function bulkDelete() {
padding: 8px 0 1rem;
}
.fc-subs__bar .v-chip { cursor: pointer; }
+.fc-subs__status { max-width: 180px; }
+.fc-subs__search { max-width: 320px; flex: 1 1 200px; }
+/* Phones: status + search each take a full-width row; drop the spacer that
+ would otherwise eat a row pushing them around. */
+@media (max-width: 600px) {
+ .fc-subs__status, .fc-subs__search { max-width: none; flex-basis: 100%; }
+ .fc-subs__bar :deep(.v-spacer) { display: none; }
+}
.fc-subs__loading, .fc-subs__empty {
display: flex; justify-content: center; padding: 2rem;
color: rgb(var(--v-theme-on-surface-variant));
diff --git a/frontend/src/stores/gallery.js b/frontend/src/stores/gallery.js
index 39169f7..bad0d8b 100644
--- a/frontend/src/stores/gallery.js
+++ b/frontend/src/stores/gallery.js
@@ -27,6 +27,9 @@ export const useGalleryStore = defineStore('gallery', () => {
// Phase-2 faceted refine params.
platform: null, untagged: false, no_artist: false,
date_from: null, date_to: null,
+ // Phase-3 visual similarity: when set, the gallery is in "similar mode" —
+ // ranked by cosine distance to this image, bounded top-N, no cursor.
+ similar_to: null,
})
// Live facet counts for the refine panel; fetched on-demand (panel open +
@@ -84,6 +87,39 @@ export const useGalleryStore = defineStore('gallery', () => {
}
}
+ // Visual "more like this": ranked top-N by cosine distance, scope filters
+ // composed (AND). No cursor / no timeline — bounded result set.
+ async function loadSimilar() {
+ inflight.cancel()
+ images.value = []
+ dateGroups.value = []
+ nextCursor.value = null
+ timelineBuckets.value = []
+ loading.value = true
+ error.value = null
+ const t = inflight.claim()
+ try {
+ const f = filter.value
+ const params = { similar_to: f.similar_to, limit: 100 }
+ if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',')
+ if (f.artist_id) params.artist_id = f.artist_id
+ if (f.media_type) params.media = f.media_type
+ if (f.platform) params.platform = f.platform
+ if (f.untagged) params.untagged = '1'
+ if (f.no_artist) params.no_artist = '1'
+ if (f.date_from) params.date_from = f.date_from
+ if (f.date_to) params.date_to = f.date_to
+ const body = await api.get('/api/gallery/similar', { params })
+ if (!t.isCurrent()) return
+ images.value = body.images
+ // ranked + bounded → no next page (nextCursor stays null → hasMore false)
+ } catch (e) {
+ error.value = e.message
+ } finally {
+ if (t.isCurrent()) loading.value = false
+ }
+ }
+
async function jumpTo(year, month) {
// Rapid timeline-jump clicks need the same race guard as
// loadMore — first jump's late body could clobber the second
@@ -150,9 +186,15 @@ export const useGalleryStore = defineStore('gallery', () => {
no_artist: _truthy(q.no_artist),
date_from: _parseDate(q.date_from),
date_to: _parseDate(q.date_to),
+ similar_to: _toId(q.similar_to),
+ }
+ if (filter.value.similar_to) {
+ // Similar mode: ranked, no timeline scroll.
+ await loadSimilar()
+ } else {
+ await loadInitial()
+ await loadTimeline()
}
- await loadInitial()
- await loadTimeline()
_resolveLabels()
}
@@ -200,7 +242,7 @@ export const useGalleryStore = defineStore('gallery', () => {
images, dateGroups, hasMore, isEmpty, loading, error,
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
facets, facetsLoading,
- loadInitial, loadMore, loadTimeline, jumpTo, loadFacets,
+ loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, loadSimilar,
applyFilterFromQuery, noteTagLabel, noteArtistLabel,
}
})
@@ -213,6 +255,7 @@ export function cloneFilter(f) {
tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type,
sort: f.sort, platform: f.platform, untagged: f.untagged,
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
+ similar_to: f.similar_to,
}
}
@@ -227,6 +270,7 @@ export function filterToQuery(f) {
if (f.no_artist) q.no_artist = '1'
if (f.date_from) q.date_from = f.date_from
if (f.date_to) q.date_to = f.date_to
+ if (f.similar_to) q.similar_to = String(f.similar_to)
return q
}
diff --git a/frontend/src/stores/showcase.js b/frontend/src/stores/showcase.js
index 23ed096..600d4f5 100644
--- a/frontend/src/stores/showcase.js
+++ b/frontend/src/stores/showcase.js
@@ -24,7 +24,8 @@ import { preloadImage } from '../utils/preloadImage.js'
// fetch + decode (a cold TABLESAMPLE is a separate, query-side concern);
// everything after it is buffer-smoothed.
const PAGE = 3
-const CADENCE_MS = 80 // floor between fully-loaded reveals
+const CADENCE_MS = 160 // floor between fully-loaded reveals (doubled
+ // 2026-06-04 — slower, more deliberate cadence)
const PRIME = 6 // items buffered before the drain starts
const BUFFER_TARGET = 30 // producer tops the queue up to this
const BUFFER_MIN = 12 // ...and refills once the queue dips below this
diff --git a/frontend/src/views/ArtistsView.vue b/frontend/src/views/ArtistsView.vue
index b79ca83..3e3c613 100644
--- a/frontend/src/views/ArtistsView.vue
+++ b/frontend/src/views/ArtistsView.vue
@@ -76,7 +76,9 @@ onMounted(async () => {
}
.fc-artists__grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
+ /* min(440px, 100%) so a card never exceeds the viewport — on phones the
+ min-track collapses to 100% (single column) instead of overflowing. */
+ grid-template-columns: repeat(auto-fill, minmax(min(440px, 100%), 1fr));
gap: 12px;
}
.fc-artists__sentinel {
diff --git a/frontend/src/views/GalleryView.vue b/frontend/src/views/GalleryView.vue
index a264a11..fa64033 100644
--- a/frontend/src/views/GalleryView.vue
+++ b/frontend/src/views/GalleryView.vue
@@ -16,7 +16,10 @@
-
+
@@ -68,4 +71,9 @@ function openImage(id) {
.fc-gallery-layout { flex-direction: column-reverse; }
.fc-gallery-layout__sidebar { width: 100%; max-height: 200px; }
}
+/* Phones: the year/month timeline strip eats vertical space and reads poorly
+ as a horizontal band — drop it; the gallery scroll is the primary nav here. */
+@media (max-width: 600px) {
+ .fc-gallery-layout__sidebar { display: none; }
+}
diff --git a/frontend/test/components/galleryRelatedStrip.spec.js b/frontend/test/components/galleryRelatedStrip.spec.js
new file mode 100644
index 0000000..28ce944
--- /dev/null
+++ b/frontend/test/components/galleryRelatedStrip.spec.js
@@ -0,0 +1,73 @@
+// @vitest-environment happy-dom
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { flushPromises } from '@vue/test-utils'
+
+import RelatedStrip from '../../src/components/modal/RelatedStrip.vue'
+import { useModalStore } from '../../src/stores/modal.js'
+import { freshPinia, mountComponent } from '../support/mountComponent.js'
+
+function stubFetch(handler) {
+ globalThis.fetch = vi.fn(async (url) => {
+ const { status, body } = handler(url)
+ return {
+ ok: status >= 200 && status < 300,
+ status, statusText: String(status),
+ text: async () => JSON.stringify(body),
+ }
+ })
+}
+
+describe('RelatedStrip (modal "more like this")', () => {
+ beforeEach(() => vi.useFakeTimers())
+ afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() })
+
+ it('stays hidden and never fetches when the image has no embedding', async () => {
+ const pinia = freshPinia()
+ useModalStore().current = { id: 1, has_embedding: false }
+ const fetchSpy = vi.fn()
+ globalThis.fetch = fetchSpy
+ const w = mountComponent(RelatedStrip, { pinia })
+ await vi.advanceTimersByTimeAsync(500)
+ expect(fetchSpy).not.toHaveBeenCalled()
+ expect(w.find('.fc-related').exists()).toBe(false)
+ })
+
+ it('deferred-fetches and renders similar thumbs for an embedded image', async () => {
+ const pinia = freshPinia()
+ useModalStore().current = { id: 1, has_embedding: true }
+ stubFetch(() => ({
+ status: 200,
+ body: { images: [{ id: 2, thumbnail_url: '/t2' }, { id: 3, thumbnail_url: '/t3' }] },
+ }))
+ const w = mountComponent(RelatedStrip, { pinia })
+ // Nothing before the defer elapses (modal image gets priority).
+ expect(globalThis.fetch).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(250)
+ await flushPromises()
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1)
+ expect(w.findAll('.fc-related__item').length).toBe(2)
+ })
+
+ it('collapses silently when the similar fetch returns nothing', async () => {
+ const pinia = freshPinia()
+ useModalStore().current = { id: 1, has_embedding: true }
+ stubFetch(() => ({ status: 200, body: { images: [] } }))
+ const w = mountComponent(RelatedStrip, { pinia })
+ await vi.advanceTimersByTimeAsync(250)
+ await flushPromises()
+ expect(w.find('.fc-related').exists()).toBe(false)
+ })
+
+ it('clicking a thumb opens that image in the modal', async () => {
+ const pinia = freshPinia()
+ const modal = useModalStore()
+ modal.current = { id: 1, has_embedding: true }
+ const openSpy = vi.spyOn(modal, 'open').mockResolvedValue()
+ stubFetch(() => ({ status: 200, body: { images: [{ id: 7, thumbnail_url: '/t7' }] } }))
+ const w = mountComponent(RelatedStrip, { pinia })
+ await vi.advanceTimersByTimeAsync(250)
+ await flushPromises()
+ await w.find('.fc-related__item').trigger('click')
+ expect(openSpy).toHaveBeenCalledWith(7)
+ })
+})
diff --git a/frontend/test/gallery.spec.js b/frontend/test/gallery.spec.js
index c5d52f8..93ce5c6 100644
--- a/frontend/test/gallery.spec.js
+++ b/frontend/test/gallery.spec.js
@@ -122,6 +122,28 @@ describe('gallery store: composable filter', () => {
expect(s.facets.total).toBe(4)
})
+ it('similar_to routes applyFilterFromQuery to the /similar endpoint, not scroll', async () => {
+ const s = useGalleryStore()
+ const urls = []
+ stubFetch((url) => {
+ urls.push(url)
+ if (url.includes('/api/gallery/similar')) {
+ return { status: 200, body: { images: [{ id: 9 }], next_cursor: null, date_groups: [] } }
+ }
+ return { status: 200, body: EMPTY }
+ })
+ await s.applyFilterFromQuery({ similar_to: '5', tag_id: '3' })
+ expect(s.filter.similar_to).toBe(5)
+ const sim = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/similar')))
+ expect(sim).toContain('similar_to=5')
+ expect(sim).toContain('limit=100')
+ expect(sim).toContain('tag_id=3') // scope composes
+ expect(s.images.map((i) => i.id)).toEqual([9])
+ expect(s.hasMore).toBe(false) // ranked + bounded → no pages
+ expect(urls.some((u) => u.includes('/api/gallery/scroll'))).toBe(false)
+ expect(urls.some((u) => u.includes('/api/gallery/timeline'))).toBe(false)
+ })
+
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
const s = useGalleryStore()
const urls = []
@@ -154,6 +176,11 @@ describe('filterToQuery / cloneFilter', () => {
expect(q.sort).toBeUndefined() // newest is the default → omitted
})
+ it('serializes similar_to', () => {
+ expect(filterToQuery({ tag_ids: [], similar_to: 42 }).similar_to).toBe('42')
+ expect(filterToQuery({ tag_ids: [], similar_to: null }).similar_to).toBeUndefined()
+ })
+
it('cloneFilter copies refine fields and detaches tag_ids', () => {
const orig = {
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
diff --git a/frontend/test/showcase.spec.js b/frontend/test/showcase.spec.js
index 9fbbbcb..29d77bd 100644
--- a/frontend/test/showcase.spec.js
+++ b/frontend/test/showcase.spec.js
@@ -46,8 +46,8 @@ describe('showcase store: buffered cascade', () => {
stubShowcase()
const s = useShowcaseStore()
const p = s.loadInitial()
- // Past INITIAL_COUNT (60) × CADENCE (80ms) plus fetch microtasks.
- await vi.advanceTimersByTimeAsync(6000)
+ // Past INITIAL_COUNT (60) × CADENCE (160ms = 9.6s) plus fetch microtasks.
+ await vi.advanceTimersByTimeAsync(12000)
await p
expect(s.images.length).toBe(60)
const ids = s.images.map((i) => i.id)
@@ -65,7 +65,7 @@ describe('showcase store: buffered cascade', () => {
// the cadence — a 1s window must not dump the whole buffer at once.
expect(early).toBeGreaterThan(0)
expect(early).toBeLessThan(60)
- await vi.advanceTimersByTimeAsync(6000)
+ await vi.advanceTimersByTimeAsync(12000)
await p
expect(s.images.length).toBe(60)
})
@@ -80,7 +80,7 @@ describe('showcase store: buffered cascade', () => {
await vi.advanceTimersByTimeAsync(1000)
expect(s.images.length).toBe(0) // gated on the un-decoded first image
release()
- await vi.advanceTimersByTimeAsync(6000)
+ await vi.advanceTimersByTimeAsync(12000)
await p
expect(s.images.length).toBe(60) // cascade proceeds once it decodes
})
diff --git a/tests/test_gallery_similar.py b/tests/test_gallery_similar.py
new file mode 100644
index 0000000..07ae149
--- /dev/null
+++ b/tests/test_gallery_similar.py
@@ -0,0 +1,134 @@
+"""Phase-3 visual "more like this" — pgvector cosine ranking over the
+precomputed SigLIP image embeddings. No query-time ML inference."""
+
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+from backend.app.models import ImageRecord, Tag, TagKind
+from backend.app.models.tag import image_tag
+from backend.app.services.gallery_service import GalleryService
+
+pytestmark = pytest.mark.integration
+
+
+def _vec(*head):
+ """A 1152-dim embedding with the given leading values, rest zero. Cosine
+ distance to _vec(1,0) grows as the 2nd component grows, so callers can
+ order fixtures deterministically by direction."""
+ v = [0.0] * 1152
+ for i, x in enumerate(head):
+ v[i] = float(x)
+ return v
+
+
+async def _img(db, n, emb):
+ rec = ImageRecord(
+ path=f"/images/sim/{n}.jpg", sha256=f"e{n:063d}",
+ size_bytes=1, mime="image/jpeg", width=1, height=1,
+ origin="imported_filesystem", integrity_status="unknown",
+ siglip_embedding=emb,
+ )
+ base = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
+ rec.created_at = base - timedelta(minutes=n)
+ rec.effective_date = rec.created_at
+ db.add(rec)
+ await db.flush()
+ return rec
+
+
+@pytest.mark.asyncio
+async def test_similar_ranks_nearest_first(db):
+ src = await _img(db, 1, _vec(1, 0))
+ near = await _img(db, 2, _vec(1, 0.05)) # almost same direction
+ mid = await _img(db, 3, _vec(1, 1)) # 45°
+ far = await _img(db, 4, _vec(0, 1)) # orthogonal
+ svc = GalleryService(db)
+ res = await svc.similar(src.id, limit=10)
+ assert [i.id for i in res] == [near.id, mid.id, far.id] # self excluded
+
+
+@pytest.mark.asyncio
+async def test_similar_excludes_null_embeddings(db):
+ src = await _img(db, 1, _vec(1, 0))
+ have = await _img(db, 2, _vec(1, 0.1))
+ await _img(db, 3, None) # un-embedded (e.g. a video) → excluded
+ svc = GalleryService(db)
+ res = await svc.similar(src.id, limit=10)
+ assert [i.id for i in res] == [have.id]
+
+
+@pytest.mark.asyncio
+async def test_similar_source_without_embedding_returns_empty(db):
+ src = await _img(db, 1, None)
+ await _img(db, 2, _vec(1, 0))
+ svc = GalleryService(db)
+ assert await svc.similar(src.id, limit=10) == []
+
+
+@pytest.mark.asyncio
+async def test_similar_missing_source_returns_none(db):
+ svc = GalleryService(db)
+ assert await svc.similar(99999, limit=10) is None
+
+
+@pytest.mark.asyncio
+async def test_similar_composes_with_tag_filter(db):
+ src = await _img(db, 1, _vec(1, 0))
+ await _img(db, 2, _vec(1, 0.02)) # nearest, but untagged
+ tagged = await _img(db, 3, _vec(1, 0.6)) # farther, but carries the tag
+ tag = Tag(name="t", kind=TagKind.general)
+ db.add(tag)
+ await db.flush()
+ await db.execute(image_tag.insert().values(
+ image_record_id=tagged.id, tag_id=tag.id, source="manual"))
+ svc = GalleryService(db)
+ res = await svc.similar(src.id, limit=10, tag_ids=[tag.id])
+ assert [i.id for i in res] == [tagged.id] # scope AND-narrows the ranked set
+
+
+@pytest.mark.asyncio
+async def test_similar_respects_limit(db):
+ src = await _img(db, 1, _vec(1, 0))
+ for n in range(2, 7):
+ await _img(db, n, _vec(1, 0.1 * n))
+ svc = GalleryService(db)
+ res = await svc.similar(src.id, limit=2)
+ assert len(res) == 2
+
+
+# --- API ---
+
+@pytest.mark.asyncio
+async def test_api_similar_endpoint(client, db):
+ src = await _img(db, 1, _vec(1, 0))
+ near = await _img(db, 2, _vec(1, 0.05))
+ await db.commit()
+ resp = await client.get(f"/api/gallery/similar?similar_to={src.id}&limit=10")
+ assert resp.status_code == 200
+ body = await resp.get_json()
+ assert [i["id"] for i in body["images"]] == [near.id]
+ assert body["next_cursor"] is None
+
+
+@pytest.mark.asyncio
+async def test_api_similar_404_when_source_missing(client):
+ resp = await client.get("/api/gallery/similar?similar_to=99999")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_api_similar_requires_param(client):
+ resp = await client.get("/api/gallery/similar")
+ assert resp.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_image_detail_reports_has_embedding(client, db):
+ embedded = await _img(db, 1, _vec(1, 0))
+ plain = await _img(db, 2, None)
+ await db.commit()
+ e = await (await client.get(f"/api/gallery/image/{embedded.id}")).get_json()
+ p = await (await client.get(f"/api/gallery/image/{plain.id}")).get_json()
+ assert e["has_embedding"] is True
+ assert p["has_embedding"] is False