Replaces "PhotoPrism" in UI strings (empty states, tooltips, toasts, log header, login screen) with neutral terms like "the indexer", "the library", "the server" — accurate regardless of backend. The login header becomes "Mulimage" and drops the explicit PhotoPrism mention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
5.7 KiB
TypeScript
176 lines
5.7 KiB
TypeScript
/**
|
||
* 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<CauseKey, CauseMeta> = {
|
||
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<ReviewGroup[]> {
|
||
// `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<CauseKey, PpPhoto[]>();
|
||
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 }];
|
||
});
|
||
}
|