/** * Adapter for the /review route. Fetches PhotoPrism's review queue and * groups photos by the most likely reason the indexer flagged them for * review. * * PhotoPrism exposes `Quality` as a single 1–7 score and the * `review:true` DSL term filters to `Quality < 3`, but it never tells us * *why* a given photo scored low. We derive that on the client from the * other metadata it does return — `TakenSrc`, `CameraID`, `Resolution`, * `Year`, `Type`. A photo can match multiple causes; the group it lands * in is determined by the priority order below, while every matching * cause is shown as a chip on the tile so the user sees the full * picture. */ import { listPhotos } from '$lib/services/photoprism'; import type { PpPhoto } from '$lib/types/photoprism'; export type CauseKey = | 'low_resolution' | 'stripped_exif' | 'implausible_year' | 'non_image_type' | 'quality_other'; export interface CauseMeta { /** Group card header. */ title: string; /** Short label on a tile chip. */ chip: string; /** Sentence guiding the bulk decision. */ suggestion: string; /** Which bulk action the suggestion line should preselect. */ suggestedAction: 'approve' | 'archive' | 'manual'; } export const CAUSES: Record = { low_resolution: { title: 'Low resolution', chip: '< 2 MP', suggestion: "Mostly messenger / web-saved images. Archive all if you don't want them in the timeline.", suggestedAction: 'archive' }, stripped_exif: { title: 'EXIF stripped', chip: 'no EXIF', suggestion: "Date and camera info were missing — usually downloaded keepers worth reviewing one-by-one. Open the first to fix the date, then bulk-approve.", suggestedAction: 'manual' }, implausible_year: { title: 'Implausible year', chip: 'bad year', suggestion: "Filenames suggest a date the indexer doesn't trust. Open one to set the real TakenAt, then bulk-approve the rest.", suggestedAction: 'manual' }, non_image_type: { title: 'Animated / vector / scan', chip: 'special type', suggestion: 'Decide per item — no obvious bulk move.', suggestedAction: 'manual' }, quality_other: { title: 'Other quality issues', chip: 'low quality', suggestion: 'The indexer flagged these but the metadata looks fine. Open the first one to investigate.', suggestedAction: 'manual' } }; /** * Priority order for picking the primary cause when a photo matches * several. `low_resolution` ranks first because it's the most actionable * — under-2 MP photos are almost always non-keepers, regardless of how * the EXIF looks. `stripped_exif` catches the higher-res survivors so * the user only sees them as their own group. */ const CAUSE_PRIORITY: CauseKey[] = [ 'low_resolution', 'stripped_exif', 'implausible_year', 'non_image_type', 'quality_other' ]; /** * All causes that apply to a photo. Stable order = priority order, so * the first entry of the returned array is the primary cause. */ export function deriveCauses(photo: PpPhoto): CauseKey[] { const causes: CauseKey[] = []; const now = new Date().getUTCFullYear(); // Resolution is the integer megapixel count PhotoPrism stored at // index time; 0 means "couldn't compute" (treat as low). Anything // under 3 MP is the messenger / web-image bucket. if (typeof photo.Resolution === 'number' && photo.Resolution < 3) { causes.push('low_resolution'); } // Stripped-EXIF lumps the two signals that almost always co-occur: // no trusted date (TakenSrc empty or guessed from filename) and an // unknown camera (CameraID 1 is PhotoPrism's "Unknown" sentinel). // Splitting them produced two heavily-overlapping groups; in // practice the user wants one decision per group. if (!photo.TakenSrc || photo.TakenSrc === 'name' || photo.CameraID === 1) { causes.push('stripped_exif'); } if (typeof photo.Year === 'number' && photo.Year > 0) { if (photo.Year < 1900 || photo.Year > now + 1) { causes.push('implausible_year'); } } // Animated GIFs and vector images are perfectly valid but PhotoPrism // doesn't trust the timeline placement; scanned documents look like // photos but rarely belong with them. if (photo.Type === 'animated' || photo.Type === 'vector') { causes.push('non_image_type'); } // Catch-all: PhotoPrism flagged it but nothing above explained why. if (causes.length === 0) { causes.push('quality_other'); } // Sort by global priority so the primary cause comes first. causes.sort((a, b) => CAUSE_PRIORITY.indexOf(a) - CAUSE_PRIORITY.indexOf(b)); return causes; } export interface ReviewGroup { cause: CauseKey; meta: CauseMeta; /** All photos whose primary cause is this group's cause. */ photos: PpPhoto[]; } /** * Fetch the review queue and bucket every photo into a ReviewGroup by * its primary cause. Empty buckets are filtered out so the page renders * only the causes that actually have hits today. */ export async function listReviewGroups(): Promise { // `count: 1000` is well above the realistic review-queue size for // this LXC (290 today). PhotoPrism caps `count` server-side at 1000 // — paginating further would require multiple calls and a merge, // which we'll bolt on if the queue ever crosses the cap. const photos = await listPhotos({ q: 'review:true', count: 1000, order: 'newest', merged: true }); const buckets = new Map(); for (const p of photos) { const primary = deriveCauses(p)[0]; const arr = buckets.get(primary) ?? []; arr.push(p); buckets.set(primary, arr); } // Return in priority order so the cause that matters most ranks // first on the page, regardless of how big each bucket is. return CAUSE_PRIORITY.flatMap((cause) => { const ps = buckets.get(cause); if (!ps || ps.length === 0) return []; return [{ cause, meta: CAUSES[cause], photos: ps }]; }); }