Removes the OpenCLIP-on-ONNX classifier and everything that fed or
consumed it:
- backend: app/services/vision/, app/tasks/vision.py,
app/services/feature_flags.py, app/routers/features.py — all
deleted; admin AI/feature-flag endpoints and the worker-vision
bootstrap call gone. Photo.needs_review and its index dropped.
- frontend: AI Settings tab, useFeaturesQuery hook, FeatureFlag
types, "Needs Review" sidebar entry + filter, needs_review filter
URL param all gone.
- infra: worker-vision compose service + models_data volume deleted;
worker-light command no longer runs bootstrap_models; the db
image switches from pgvector/pgvector:pg16 to postgres:16; backend
Dockerfile drops the dedicated torch RUN layer; requirements.txt
drops torch/torchvision/open-clip-torch/onnxruntime.
Alembic 0019_drop_ai_remnants:
- drops photos.needs_review + ix_photos_needs_review
- DROP EXTENSION IF EXISTS vector (must run before the image swap;
the new postgres:16 doesn't ship pgvector)
New scripts/full_refresh.py: one-shot DB ↔ filesystem reconciliation.
Runs cleanup_data_integrity, scans every active SourceRoot inline
(no celery dependency so the worker can be stopped), hard-prunes
photo + folder rows for files that are gone, removes orphan
/data/thumbs/{user}/{photo}/ directories. New helper
prune_orphan_thumbnails in cleanup.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 lines
5.4 KiB
TypeScript
147 lines
5.4 KiB
TypeScript
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
|
import { useShallow } from 'zustand/react/shallow'
|
|
import { useFilterStore, filtersToParams } from '../store/filterStore'
|
|
import { usePhotosBackgroundLoadingStore } from '../store/photosBackgroundLoadingStore'
|
|
import api from '../services/api'
|
|
import type { Photo } from '../types/photo'
|
|
|
|
export function usePhotosLoadingMore(): boolean {
|
|
return usePhotosBackgroundLoadingStore((s) => s.inFlight > 0)
|
|
}
|
|
|
|
/**
|
|
* Optimistically strip the supplied photo ids from every cached
|
|
* timeline list. Used by discard / delete flows so the photos vanish
|
|
* from the grid the instant the user acts, without waiting for the
|
|
* mutation round-trip + invalidation refetch. The subsequent invalidate
|
|
* still runs and reconciles the cache with server truth.
|
|
*/
|
|
export function stripPhotosFromCache(queryClient: QueryClient, ids: string[]) {
|
|
if (ids.length === 0) return
|
|
const removed = new Set(ids)
|
|
queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) => {
|
|
if (!prev) return prev
|
|
return prev.filter((p) => !removed.has(p.id))
|
|
})
|
|
}
|
|
|
|
interface CursorPage {
|
|
photos: Photo[]
|
|
next_cursor: string | null
|
|
}
|
|
|
|
async function fetchCursorPage(
|
|
params: Record<string, unknown>,
|
|
signal?: AbortSignal,
|
|
): Promise<CursorPage> {
|
|
const resp = await api.get<CursorPage>('/photos', { params, signal })
|
|
return resp.data
|
|
}
|
|
|
|
/**
|
|
* Single source of truth for the timeline photos query. Both Timeline and
|
|
* PreviewView call this so they share one cache entry — previously
|
|
* PreviewView looked the cache up by key directly, which broke the moment
|
|
* Timeline's key gained the filter params.
|
|
*/
|
|
export function usePhotosQuery() {
|
|
// Single shallow-compared selector that returns just the filter
|
|
// surface area. Previously this hook ran 14 individual selectors,
|
|
// each a fresh subscription that could trigger a re-render and a
|
|
// useMemo recompute on any unrelated filter-store update. useShallow
|
|
// collapses them into one subscription that only fires when the
|
|
// shape's values actually change.
|
|
const filterParams = useFilterStore(
|
|
useShallow((s) =>
|
|
filtersToParams({
|
|
dateFrom: s.dateFrom,
|
|
dateTo: s.dateTo,
|
|
mediaTypes: s.mediaTypes,
|
|
ratingMin: s.ratingMin,
|
|
ratingMax: s.ratingMax,
|
|
colorLabel: s.colorLabel,
|
|
flag: s.flag,
|
|
heapId: s.heapId,
|
|
folderId: s.folderId,
|
|
tagIds: s.tagIds,
|
|
duplicates: s.duplicates,
|
|
groupBy: s.groupBy,
|
|
sortBy: s.sortBy,
|
|
sortOrder: s.sortOrder,
|
|
}),
|
|
),
|
|
)
|
|
|
|
const queryClient = useQueryClient()
|
|
|
|
return useQuery({
|
|
queryKey: ['photos', filterParams],
|
|
queryFn: async ({ signal }) => {
|
|
// Two-phase fetch using cursor-based (keyset) pagination.
|
|
// Phase 1 returns the first page (resolves the useQuery promise
|
|
// so consumers exit loading state). Phase 2 chains a bounded
|
|
// background loop — each response includes a `next_cursor` that
|
|
// seeks directly to the next slice via an indexed range scan,
|
|
// O(1) regardless of depth (no OFFSET skipping).
|
|
//
|
|
// First page is small (~one viewport) so the grid paints fast;
|
|
// background pages are larger so we still cover ~10k photos in
|
|
// a few round-trips without burning through a 100k library on
|
|
// cold load. If a user scrolls past that horizon we'll add an
|
|
// infinite-query trigger; for now the cap keeps cold-load
|
|
// memory / network pressure sane.
|
|
const PER_PAGE_INITIAL = 100
|
|
const PER_PAGE_BACKGROUND = 500
|
|
const MAX_PAGES = 20
|
|
const INTER_PAGE_DELAY_MS = 50
|
|
|
|
const first = await fetchCursorPage(
|
|
{ per_page: PER_PAGE_INITIAL, ...filterParams },
|
|
signal,
|
|
)
|
|
const firstBatch = first.photos || []
|
|
let nextCursor: string | null = first.next_cursor
|
|
|
|
if (nextCursor) {
|
|
// Fire-and-forget background loop using cursor chaining.
|
|
// start/stop balanced in try/finally so an aborted or erroring
|
|
// loop can't leak the in-flight counter that drives the
|
|
// bottom-of-grid spinner.
|
|
usePhotosBackgroundLoadingStore.getState().start()
|
|
void (async () => {
|
|
try {
|
|
for (let i = 0; i < MAX_PAGES && nextCursor; i++) {
|
|
if (signal?.aborted) return
|
|
try {
|
|
const page = await fetchCursorPage(
|
|
{ per_page: PER_PAGE_BACKGROUND, cursor: nextCursor, ...filterParams },
|
|
signal,
|
|
)
|
|
if (signal?.aborted) return
|
|
const more = page.photos || []
|
|
nextCursor = page.next_cursor
|
|
queryClient.setQueryData<Photo[]>(
|
|
['photos', filterParams],
|
|
(prev) => (prev ? [...prev, ...more] : more)
|
|
)
|
|
if (!nextCursor || more.length < PER_PAGE_BACKGROUND) return
|
|
// Yield a beat between pages so the main thread stays
|
|
// responsive (thumbnail decode, scroll handling) while
|
|
// we're back-filling in the background.
|
|
await new Promise((r) => setTimeout(r, INTER_PAGE_DELAY_MS))
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
} finally {
|
|
usePhotosBackgroundLoadingStore.getState().stop()
|
|
}
|
|
})()
|
|
}
|
|
|
|
return firstBatch
|
|
},
|
|
staleTime: 30_000,
|
|
})
|
|
}
|