Save path ./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.fileID.ext; download() from the cache first (closes #143)
check / check (push) Successful in 1m25s
check / check (push) Successful in 1m25s
Originals are saved at `{downloadDirectory}/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.{fileID}{ext}`. The date is the photo's `takenAt` in local time, and `downloadDirectory` defaults to `./photos`, resolved when the library opens. The old `originals/` layout is gone.
`photo.download()` writes the original to `savePath`. It copies from the cache when the cache holds the original, and fetches otherwise. `lib.backup()` uses the same path and rule, and every album in `collections/` links to it. `isLocal` is true only when the original is at `savePath`.
For a file in several albums, one rule picks the copy everything uses: the most recently synced, with the lowest album ID breaking a tie.
Model: opus-5-5
This commit was merged in pull request #146.
This commit is contained in:
+51
-23
@@ -12,18 +12,26 @@
|
||||
// plain records are the serializable surface, and `record()` returns one.
|
||||
//
|
||||
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
|
||||
// `content()` and `exif()` go through the on-disk content cache (issue #46),
|
||||
// and are the one place in this module that may touch the network. A library
|
||||
// opened without a content source leaves that cache absent, and those methods
|
||||
// then throw. `savePath` and `isLocal` look only at the disk.
|
||||
// `download()`, `content()` and `exif()` go through the on-disk content cache
|
||||
// (issue #46), and are the one place in this module that may touch the
|
||||
// network. A library opened without a content source leaves that cache absent,
|
||||
// and those methods then throw. `savePath` and `isLocal` look only at the disk
|
||||
// and need no cache.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { readPhotoExif, type PhotoExif } from "../exif.js";
|
||||
import type { CollectionType, FileType } from "../model/types.js";
|
||||
import type { CollectionType, EnteFile, FileType } from "../model/types.js";
|
||||
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
||||
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
||||
|
||||
// Where a photo's original is saved, and whether all of it is there. The
|
||||
// library answers both from the disk, with or without a content cache.
|
||||
export interface SavePathLookup {
|
||||
savePath(file: EnteFile): string;
|
||||
isLocal(file: EnteFile): boolean;
|
||||
}
|
||||
|
||||
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
||||
// deterministically — the same order the record projection uses.
|
||||
const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
|
||||
@@ -35,10 +43,14 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
||||
b.updationTime - a.updationTime || b.collectionID - a.collectionID;
|
||||
|
||||
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
|
||||
// underlying plain record for callers that need the IPC-safe value.
|
||||
// underlying plain record for callers that need the IPC-safe value. `file` is
|
||||
// the membership the record is read from, so the save path carries the date of
|
||||
// `takenAt` and stays known after a refresh removes the file from the library.
|
||||
export class Photo {
|
||||
constructor(
|
||||
private readonly rec: PhotoRecord,
|
||||
private readonly file: EnteFile,
|
||||
private readonly saves: SavePathLookup,
|
||||
private readonly cache?: PhotoContent,
|
||||
) {}
|
||||
|
||||
@@ -89,20 +101,20 @@ export class Photo {
|
||||
return this.rec.isHidden;
|
||||
}
|
||||
|
||||
// Where `lib.backup()` stores the original in the library's download
|
||||
// directory, whether or not it is there yet: for a live photo already
|
||||
// stored, its image. For a live photo not yet stored, it carries the
|
||||
// title's extension, and the backup may store the image under a different
|
||||
// one. Undefined when the library has no download directory or no content
|
||||
// cache.
|
||||
get savePath(): string | undefined {
|
||||
return this.cache?.savePath(this.rec.fileID);
|
||||
// Where `download()` and `lib.backup()` put the original under the
|
||||
// library's download directory, whether or not it is there yet:
|
||||
// `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`. For a live photo
|
||||
// already stored, its image. For a live photo not yet stored, it carries
|
||||
// the title's extension, and the image may be stored under a different
|
||||
// one.
|
||||
get savePath(): string {
|
||||
return this.saves.savePath(this.file);
|
||||
}
|
||||
|
||||
// Whether the whole original is at `savePath`. A copy only in the cache
|
||||
// does not count.
|
||||
get isLocal(): boolean {
|
||||
return this.cache?.isLocal(this.rec.fileID) ?? false;
|
||||
return this.saves.isLocal(this.file);
|
||||
}
|
||||
|
||||
record(): PhotoRecord {
|
||||
@@ -111,12 +123,20 @@ export class Photo {
|
||||
|
||||
// Fetch and cache the full-resolution original, returning its on-disk path
|
||||
// and byte length; for a live photo, its image's, and its video's path as
|
||||
// `videoPath`. Served from the cache (or the backup download directory)
|
||||
// when already present, otherwise fetched through the content pool.
|
||||
// `videoPath`. Served from the cache (or the save path) when already
|
||||
// present, otherwise fetched through the content pool.
|
||||
async original(opts?: ContentOptions): Promise<ContentResult> {
|
||||
return this.cacheOrThrow().original(this.rec.fileID, opts);
|
||||
}
|
||||
|
||||
// Put the original at `savePath` and return it there, as `original()`
|
||||
// does. When it is already there, nothing is written. When the cache holds
|
||||
// it, it is copied from there; otherwise it is fetched straight to
|
||||
// `savePath`.
|
||||
async download(): Promise<ContentResult> {
|
||||
return this.cacheOrThrow().download(this.file);
|
||||
}
|
||||
|
||||
// As `original`, for the thumbnail, through the thumbnail pool.
|
||||
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
||||
return this.cacheOrThrow().thumbnail(this.rec.fileID, opts);
|
||||
@@ -156,6 +176,7 @@ export class Album {
|
||||
constructor(
|
||||
private readonly rec: AlbumRecord,
|
||||
private readonly records: DerivedRecords,
|
||||
private readonly saves: SavePathLookup,
|
||||
private readonly content?: PhotoContent,
|
||||
) {}
|
||||
|
||||
@@ -190,7 +211,10 @@ 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, this.content));
|
||||
const file = this.records.files.get(id);
|
||||
if (p && file) {
|
||||
out.push(new Photo(p, file, this.saves, this.content));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -253,18 +277,19 @@ export interface FreshReads {
|
||||
|
||||
export const makeAlbumsAPI = (
|
||||
derive: () => DerivedRecords,
|
||||
saves: SavePathLookup,
|
||||
content?: PhotoContent,
|
||||
): AlbumsAPI => ({
|
||||
list: (): Album[] => {
|
||||
const records = derive();
|
||||
return [...records.albums.values()]
|
||||
.sort(byNewestAlbum)
|
||||
.map((rec) => new Album(rec, records, content));
|
||||
.map((rec) => new Album(rec, records, saves, content));
|
||||
},
|
||||
byID: ({ collectionID }): Album | undefined => {
|
||||
const records = derive();
|
||||
const rec = records.albums.get(collectionID);
|
||||
return rec ? new Album(rec, records, content) : undefined;
|
||||
return rec ? new Album(rec, records, saves, content) : undefined;
|
||||
},
|
||||
byName: ({ albumName }): Album | undefined => {
|
||||
const records = derive();
|
||||
@@ -273,17 +298,20 @@ export const makeAlbumsAPI = (
|
||||
const match = [...records.albums.values()]
|
||||
.sort(byNewestAlbum)
|
||||
.find((rec) => rec.name === albumName);
|
||||
return match ? new Album(match, records, content) : undefined;
|
||||
return match ? new Album(match, records, saves, content) : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
export const makePhotosAPI = (
|
||||
derive: () => DerivedRecords,
|
||||
saves: SavePathLookup,
|
||||
content?: PhotoContent,
|
||||
): PhotosAPI => ({
|
||||
byID: ({ fileID }): Photo | undefined => {
|
||||
const rec = derive().photos.get(fileID);
|
||||
return rec ? new Photo(rec, content) : undefined;
|
||||
const records = derive();
|
||||
const rec = records.photos.get(fileID);
|
||||
const file = records.files.get(fileID);
|
||||
return rec && file ? new Photo(rec, file, saves, content) : undefined;
|
||||
},
|
||||
records: ({ fileIDs }): PhotoRecord[] => {
|
||||
const { photos } = derive();
|
||||
|
||||
Reference in New Issue
Block a user