On-disk content and thumbnail cache with per-photo fetch and prefetch (closes #46)
check / check (push) Successful in 33s
check / check (push) Successful in 33s
Adds the on-disk content and thumbnail cache keyed by fileID: originals/ and thumbnails/ under cacheDirectory, present-means-complete (streaming atomic rename), orphan temp reaping on open. Photo.original/thumbnail return a cached path with no network when present, else fetch through the shared request pool; thumbnails.ensure drives the thumbnail pool with priority, dedup and AbortSignal. One shared RequestPools set serves both the ML fetch and the content cache. Content-hash integrity is deferred (#68); authenticated streaming decrypt guarantees integrity now. Model: opus-4-8
This commit was merged in pull request #66.
This commit is contained in:
+45
-10
@@ -10,10 +10,14 @@
|
||||
// those records: a caller that holds an object reference gets typed field
|
||||
// access and, for an album, its photos. They are not sent across IPC — the
|
||||
// plain records are the serializable surface, and `record()` returns one.
|
||||
// Content-fetch methods (`Photo.original` / `thumbnail`) belong to a later
|
||||
// unit; this surface is read-only.
|
||||
//
|
||||
// A `Photo` also fetches its own bytes: `original()` and `thumbnail()` go
|
||||
// through the on-disk content cache (issue #46), the one place in this module
|
||||
// that is not synchronous and RAM-only. A library opened without a content
|
||||
// source leaves that cache absent, and those two methods then throw.
|
||||
|
||||
import type { CollectionType, FileType } from "../model/types.js";
|
||||
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
||||
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
||||
|
||||
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
||||
@@ -29,7 +33,10 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
||||
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
|
||||
// underlying plain record for callers that need the IPC-safe value.
|
||||
export class Photo {
|
||||
constructor(private readonly rec: PhotoRecord) {}
|
||||
constructor(
|
||||
private readonly rec: PhotoRecord,
|
||||
private readonly content?: PhotoContent,
|
||||
) {}
|
||||
|
||||
get fileID(): number {
|
||||
return this.rec.fileID;
|
||||
@@ -71,6 +78,27 @@ export class Photo {
|
||||
record(): PhotoRecord {
|
||||
return this.rec;
|
||||
}
|
||||
|
||||
// Fetch and cache the full-resolution original, returning its on-disk path
|
||||
// and byte length. Served from the cache (or the backup download directory)
|
||||
// when already present, otherwise fetched through the content pool.
|
||||
async original(opts?: ContentOptions): Promise<ContentResult> {
|
||||
return this.contentOrThrow().original(this.rec.fileID, opts);
|
||||
}
|
||||
|
||||
// As `original`, for the thumbnail, through the thumbnail pool.
|
||||
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
||||
return this.contentOrThrow().thumbnail(this.rec.fileID, opts);
|
||||
}
|
||||
|
||||
private contentOrThrow(): PhotoContent {
|
||||
if (!this.content) {
|
||||
throw new Error(
|
||||
"Photo content requires a library opened with a content cache",
|
||||
);
|
||||
}
|
||||
return this.content;
|
||||
}
|
||||
}
|
||||
|
||||
// A single album. `photos.list()` returns the album's photos as wrappers,
|
||||
@@ -79,6 +107,7 @@ export class Album {
|
||||
constructor(
|
||||
private readonly rec: AlbumRecord,
|
||||
private readonly records: DerivedRecords,
|
||||
private readonly content?: PhotoContent,
|
||||
) {}
|
||||
|
||||
get collectionID(): number {
|
||||
@@ -112,7 +141,7 @@ export class Album {
|
||||
const out: Photo[] = [];
|
||||
for (const id of this.rec.fileIDs) {
|
||||
const p = this.records.photos.get(id);
|
||||
if (p) out.push(new Photo(p));
|
||||
if (p) out.push(new Photo(p, this.content));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -164,17 +193,20 @@ export interface TimelineAPI {
|
||||
groups(args: { groupBy: GroupBy; filter?: PhotoFilter }): TimelineGroup[];
|
||||
}
|
||||
|
||||
export const makeAlbumsAPI = (derive: () => DerivedRecords): AlbumsAPI => ({
|
||||
export const makeAlbumsAPI = (
|
||||
derive: () => DerivedRecords,
|
||||
content?: PhotoContent,
|
||||
): AlbumsAPI => ({
|
||||
list: (): Album[] => {
|
||||
const records = derive();
|
||||
return [...records.albums.values()]
|
||||
.sort(byNewestAlbum)
|
||||
.map((rec) => new Album(rec, records));
|
||||
.map((rec) => new Album(rec, records, content));
|
||||
},
|
||||
byID: ({ collectionID }): Album | undefined => {
|
||||
const records = derive();
|
||||
const rec = records.albums.get(collectionID);
|
||||
return rec ? new Album(rec, records) : undefined;
|
||||
return rec ? new Album(rec, records, content) : undefined;
|
||||
},
|
||||
byName: ({ albumName }): Album | undefined => {
|
||||
const records = derive();
|
||||
@@ -183,14 +215,17 @@ export const makeAlbumsAPI = (derive: () => DerivedRecords): AlbumsAPI => ({
|
||||
const match = [...records.albums.values()]
|
||||
.sort(byNewestAlbum)
|
||||
.find((rec) => rec.name === albumName);
|
||||
return match ? new Album(match, records) : undefined;
|
||||
return match ? new Album(match, records, content) : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
export const makePhotosAPI = (derive: () => DerivedRecords): PhotosAPI => ({
|
||||
export const makePhotosAPI = (
|
||||
derive: () => DerivedRecords,
|
||||
content?: PhotoContent,
|
||||
): PhotosAPI => ({
|
||||
byID: ({ fileID }): Photo | undefined => {
|
||||
const rec = derive().photos.get(fileID);
|
||||
return rec ? new Photo(rec) : undefined;
|
||||
return rec ? new Photo(rec, content) : undefined;
|
||||
},
|
||||
records: ({ fileIDs }): PhotoRecord[] => {
|
||||
const { photos } = derive();
|
||||
|
||||
Reference in New Issue
Block a user