Save originals at photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.fileID.ext; add photo.download() (closes #143)
Each original's save path is now YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext> under the library's download directory, dated by takenAt in the machine's time zone. The directory defaults to photos/ in the working directory, resolved once at open. savePath is always a string, with or without a content cache, and isLocal is true only when the original is there. photo.download() puts the original at its save path: copied from the cache when the cache holds it, fetched straight there otherwise. lib.backup() uses the same code for each file, writes each file's JSON beside it, and links collections/ to the save paths. The backup has no originals/ folder. Model: opus-5-5
This commit is contained in:
@@ -528,25 +528,36 @@ the smallest does not.
|
|||||||
|
|
||||||
```
|
```
|
||||||
<dir>/
|
<dir>/
|
||||||
originals/
|
YYYY/YYYY-MM/YYYY-MM-DD/
|
||||||
<fileID>.<ext> actual file content (one per unique file,
|
YYYY-MM-DD.<fileID>.<ext> actual file content, at its save path (one
|
||||||
two for a live photo: see below)
|
per unique file, two for a live photo: see
|
||||||
<fileID>.json the file's basic metadata fields quak
|
below)
|
||||||
keeps, and its private and public magic
|
YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak
|
||||||
metadata
|
keeps, and its private and public magic
|
||||||
<fileID>.livephoto.json which of a live photo's two files is which
|
metadata
|
||||||
|
YYYY-MM-DD.<fileID>.livephoto.json
|
||||||
|
which of a live photo's two files is which
|
||||||
collections/
|
collections/
|
||||||
<name>/
|
<name>/
|
||||||
<title> -> ../../originals/<fileID>.<ext> (symlink)
|
<title> -> ../../YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>
|
||||||
<name>.json collection metadata + file list
|
(symlink)
|
||||||
failures.json files that failed and have not yet succeeded
|
<name>.json collection metadata + file list
|
||||||
|
failures.json files that failed and have not yet succeeded
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each original is saved at its save path, the same path `photo.savePath` gives
|
||||||
|
and `photo.download()` writes (see Read surface below). The date is the photo's
|
||||||
|
`takenAt` (the date set in Ente if it was edited, else its creation time) in the
|
||||||
|
time zone of the machine running quak. The extension is the one in the file's
|
||||||
|
name as uploaded, case kept, or `.bin` when it has none or it holds anything but
|
||||||
|
letters and digits. When the date or the time zone changes, the next run saves
|
||||||
|
the original at its new path and leaves the old copy where it is.
|
||||||
|
|
||||||
`failures.json` records each failed file with the kind of failure, how many
|
`failures.json` records each failed file with the kind of failure, how many
|
||||||
times it has been tried and when it was last tried. A file leaves it once it
|
times it has been tried and when it was last tried. A file leaves it once it
|
||||||
succeeds, or once it is no longer in the library or in the backup's scope. The
|
succeeds, or once it is no longer in the library or in the backup's scope. The
|
||||||
library's `lib.backup({ includeThumbnails: true })` also writes
|
library's `lib.backup({ includeThumbnails: true })` also writes
|
||||||
`thumbnails/<fileID>.jpg` beside `originals/`; `quak backup` does not.
|
`thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not.
|
||||||
|
|
||||||
A collection's directory and JSON are named after the collection, and a symlink
|
A collection's directory and JSON are named after the collection, and a symlink
|
||||||
after the file's title, both with unsafe characters replaced. When two
|
after the file's title, both with unsafe characters replaced. When two
|
||||||
@@ -558,22 +569,21 @@ name stays the same from run to run until such a clash appears or goes away.
|
|||||||
|
|
||||||
A live photo, which Ente stores as one ZIP of its image and its video, is stored
|
A live photo, which Ente stores as one ZIP of its image and its video, is stored
|
||||||
as those two files, which a photo viewer can open: each is
|
as those two files, which a photo viewer can open: each is
|
||||||
`originals/<fileID>.<ext>` with the extension it has inside the ZIP (for example
|
`YYYY-MM-DD.<fileID>.<ext>` with the extension it has inside the ZIP (for
|
||||||
`12345.heic` and `12345.mov`), and `<fileID>.livephoto.json` names the two. The
|
example `2026-03-01.12345.heic` and `2026-03-01.12345.mov`), and
|
||||||
live photo counts as stored only when both files are present and not empty. Its
|
`YYYY-MM-DD.<fileID>.livephoto.json` names the two. The live photo counts as
|
||||||
album folder links both, each named after the title with that file's extension
|
stored only when both files are present and not empty. Its album folder links
|
||||||
(`IMG_0001.heic` and `IMG_0001.mov`). A live photo that an earlier version of
|
both, each named after the title with that file's extension (`IMG_0001.heic` and
|
||||||
quak stored as the ZIP, under the image's name, is replaced by its two files on
|
`IMG_0001.mov`).
|
||||||
the next run, and the ZIP and its link are removed.
|
|
||||||
|
|
||||||
Each run removes the symlinks into `originals/` that no longer belong in their
|
Each run removes the symlinks into the date folders that no longer belong in
|
||||||
collection's directory, and the directories (and JSON) of collections that were
|
their collection's directory, and the directories (and JSON) of collections that
|
||||||
deleted or renamed. Nothing else in `collections/` is touched: a file or a
|
were deleted or renamed. Nothing else in `collections/` is touched: a file or a
|
||||||
symlink you put there stays, and a directory that still holds one after its
|
symlink you put there stays, and a directory that still holds one after its
|
||||||
symlinks are removed stays too, with its JSON.
|
symlinks are removed stays too, with its JSON.
|
||||||
|
|
||||||
Each file is downloaded exactly once regardless of how many collections it
|
Each file is downloaded exactly once regardless of how many collections it
|
||||||
appears in, and written once: straight into `originals/`, with no copy left in
|
appears in, and written once: straight to its save path, with no copy left in
|
||||||
the cache. An original the cache already held is copied from there instead. On
|
the cache. An original the cache already held is copied from there instead. On
|
||||||
subsequent runs, existing originals are skipped. If a download fails, the error
|
subsequent runs, existing originals are skipped. If a download fails, the error
|
||||||
is logged and the backup continues with the next file. The exit code is non-zero
|
is logged and the backup continues with the next file. The exit code is non-zero
|
||||||
@@ -586,14 +596,15 @@ Each original is written to a temporary file in the same directory, synced to
|
|||||||
disk, and renamed into place, so an original is either complete or absent, even
|
disk, and renamed into place, so an original is either complete or absent, even
|
||||||
after a power cut. A downloaded original's temporary file is named
|
after a power cut. A downloaded original's temporary file is named
|
||||||
`.quak-<pid>-<random>.tmp`, one copied from the cache
|
`.quak-<pid>-<random>.tmp`, one copied from the cache
|
||||||
`.quak-backup-<fileID>.<ext>-<pid>-<random>.tmp`. A run that is killed can leave
|
`.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A run that is
|
||||||
one of these temporary files behind; the next backup deletes those whose process
|
killed can leave one of these temporary files behind; the next backup deletes
|
||||||
is no longer running. The content cache uses the same scheme, and opening a
|
those whose process is no longer running. The content cache uses the same
|
||||||
library deletes the temporary files in the cache whose process is no longer
|
scheme, and opening a library deletes the temporary files in the cache whose
|
||||||
running, so a download another process has in progress in the same cache is left
|
process is no longer running, so a download another process has in progress in
|
||||||
alone. The rename replaces whatever was at the destination rather than writing
|
the same cache is left alone. The rename replaces whatever was at the
|
||||||
through it: a symlink there is replaced, not followed, and the new file has the
|
destination rather than writing through it: a symlink there is replaced, not
|
||||||
temporary file's permissions, not those of the file it replaced.
|
followed, and the new file has the temporary file's permissions, not those of
|
||||||
|
the file it replaced.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
@@ -635,21 +646,24 @@ background, so an unreachable server does not block opening.
|
|||||||
|
|
||||||
`LibraryOptions`:
|
`LibraryOptions`:
|
||||||
|
|
||||||
| Option | Default | Meaning |
|
| Option | Default | Meaning |
|
||||||
| ------------------------ | -------------------------------------------------- | --------------------------------------------------------------------- |
|
| ------------------------ | -------------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| `client` | required | the account client (a `Client`, or any `LibraryClient`) |
|
| `client` | required | the account client (a `Client`, or any `LibraryClient`) |
|
||||||
| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live |
|
| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live |
|
||||||
| `downloadDirectory` | none | backup destination; an original already stored there counts as cached |
|
| `downloadDirectory` | `photos` in the working directory | root of the save paths; an original stored there counts as cached |
|
||||||
| `refreshIntervalSeconds` | `3` | background refresh cadence |
|
| `refreshIntervalSeconds` | `3` | background refresh cadence |
|
||||||
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
|
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
|
||||||
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
|
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
|
||||||
| `precacheOriginalsDays` | `7` | length in days of that latest window |
|
| `precacheOriginalsDays` | `7` | length in days of that latest window |
|
||||||
| `cacheOriginalsMaxBytes` | 100 GiB | size limit on the originals cache; pinned originals can exceed it |
|
| `cacheOriginalsMaxBytes` | 100 GiB | size limit on the originals cache; pinned originals can exceed it |
|
||||||
| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down |
|
| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down |
|
||||||
| `isOriginalPinned` | none | extra predicate for originals that must never be evicted |
|
| `isOriginalPinned` | none | extra predicate for originals that must never be evicted |
|
||||||
| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) |
|
| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) |
|
||||||
| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) |
|
| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) |
|
||||||
| `contentSource` | the client's own | override the byte source (mainly for tests) |
|
| `contentSource` | the client's own | override the byte source (mainly for tests) |
|
||||||
|
|
||||||
|
The default `downloadDirectory` is resolved against the working directory once,
|
||||||
|
when the library opens; `lib.downloadDirectory` holds the result.
|
||||||
|
|
||||||
Concurrency is set through `pools`: construct
|
Concurrency is set through `pools`: construct
|
||||||
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
||||||
@@ -702,20 +716,26 @@ and `originalPath`, and `photo.year`, the local-time year of `takenAt`, all as
|
|||||||
synchronous getters read from RAM; `photo.record()` → `PhotoRecord`. Two more
|
synchronous getters read from RAM; `photo.record()` → `PhotoRecord`. Two more
|
||||||
synchronous getters look at the disk and never touch the network:
|
synchronous getters look at the disk and never touch the network:
|
||||||
|
|
||||||
- `photo.savePath` → `string | undefined` — where `lib.backup()` writes the
|
- `photo.savePath` → `string` — where `photo.download()` and `lib.backup()` put
|
||||||
original, `originals/<fileID>.<ext>` under the `downloadDirectory` the library
|
the original, whether or not it is there yet:
|
||||||
was opened with, whether or not it is there yet: for a live photo already
|
`YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's
|
||||||
stored, its image. For a live photo not yet stored, it carries the title's
|
`downloadDirectory` (see Backup layout above for the date and the extension).
|
||||||
extension, and the backup may store the image under a different one, found
|
For a live photo already stored, its image. For a live photo not yet stored,
|
||||||
inside the live photo. `undefined` when the library has no `downloadDirectory`
|
it carries the title's extension, and the image may be stored under a
|
||||||
or no content source.
|
different one, found inside the live photo. It needs no content source.
|
||||||
- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`.
|
- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. A
|
||||||
|
copy only in the cache does not count.
|
||||||
|
|
||||||
Four async methods may download:
|
Five async methods may download:
|
||||||
|
|
||||||
- `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the
|
- `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the
|
||||||
full-resolution file. For a live photo, `path` and `bytes` are its image's and
|
full-resolution file. For a live photo, `path` and `bytes` are its image's and
|
||||||
`videoPath` is its video.
|
`videoPath` is its video.
|
||||||
|
- `await photo.download()` → `{ path, bytes, videoPath? }`, as `original()` —
|
||||||
|
puts the original at `savePath`, creating its folders. When it is already
|
||||||
|
there, nothing is written. When the cache holds it, it is copied from the
|
||||||
|
cache; otherwise it is fetched straight to `savePath`, with no copy left in
|
||||||
|
the cache. Afterwards `isLocal` is true.
|
||||||
- `await photo.thumbnail(opts?)` → `{ path, bytes }`.
|
- `await photo.thumbnail(opts?)` → `{ path, bytes }`.
|
||||||
- `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read
|
- `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read
|
||||||
through `original()`; for a live photo, its image's.
|
through `original()`; for a live photo, its image's.
|
||||||
@@ -731,10 +751,11 @@ Four async methods may download:
|
|||||||
|
|
||||||
They serve from the on-disk content cache when the bytes are present and
|
They serve from the on-disk content cache when the bytes are present and
|
||||||
otherwise fetch through the pools; `original()`, `content()` and `exif()` also
|
otherwise fetch through the pools; `original()`, `content()` and `exif()` also
|
||||||
serve an original the backup has already stored. `opts.onProgress` reports
|
serve an original already stored at its save path. `opts.onProgress` reports
|
||||||
per-file progress. They throw when the library was opened without a content
|
per-file progress. They throw when the library was opened without a content
|
||||||
source. An original that `content()` or `exif()` downloads lands in the cache,
|
source. An original that `original()`, `content()` or `exif()` downloads lands
|
||||||
which does not make `isLocal` true; only `lib.backup()` does.
|
in the cache, which does not make `isLocal` true; only `download()` and
|
||||||
|
`lib.backup()` do.
|
||||||
|
|
||||||
Lower-level accessors that return decrypted model objects (which hold key
|
Lower-level accessors that return decrypted model objects (which hold key
|
||||||
material) are also available: `listCollections()`, `getCollection(id)`,
|
material) are also available: `listCollections()`, `getCollection(id)`,
|
||||||
@@ -777,15 +798,15 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
|
|||||||
default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes
|
default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes
|
||||||
a query vector the caller produced elsewhere.
|
a query vector the caller produced elsewhere.
|
||||||
- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as
|
- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as
|
||||||
`fresh()` does, fetches every in-scope original not already in the backup
|
`fresh()` does, puts every in-scope original not already at its save path
|
||||||
(and, with `includeThumbnails`, thumbnails) through the content cache, and
|
there as `photo.download()` does (and, with `includeThumbnails`, fetches
|
||||||
rebuilds the on-disk backup tree with a durable failure ledger. A fetched
|
thumbnails) through the content cache, and rebuilds the on-disk backup tree
|
||||||
original is written straight into the backup's `originals/` and not into the
|
with a durable failure ledger. A fetched original is written straight to its
|
||||||
cache, which then counts it as present; one the cache already held is copied
|
save path and not into the cache, which then counts it as present; one the
|
||||||
from there. `BackupOptions`: `downloadDirectory` (falls back to the one
|
cache already held is copied from there. `BackupOptions`: `downloadDirectory`
|
||||||
`open()` was given), `includeOriginals` (default `true`), `includeThumbnails`
|
(falls back to the library's), `includeOriginals` (default `true`),
|
||||||
(default `false`), `onlyAlbumNames`, and `onProgress`. See Backup layout above
|
`includeThumbnails` (default `false`), `onlyAlbumNames`, and `onProgress`. See
|
||||||
for the tree it writes.
|
Backup layout above for the tree it writes.
|
||||||
|
|
||||||
### Request pools
|
### Request pools
|
||||||
|
|
||||||
@@ -816,7 +837,7 @@ When `metadata.json` belongs to a different account than the client's,
|
|||||||
originals and thumbnails are kept; they are reached only through the files the
|
originals and thumbnails are kept; they are reached only through the files the
|
||||||
current account's records name.
|
current account's records name.
|
||||||
|
|
||||||
A live photo's original is cached as in the backup: its image and its video,
|
A live photo's original is cached as at its save path: its image and its video,
|
||||||
each `originals/<fileID>.<ext>` with its own extension, and
|
each `originals/<fileID>.<ext>` with its own extension, and
|
||||||
`originals/<fileID>.livephoto.json` naming them; the two are evicted together. A
|
`originals/<fileID>.livephoto.json` naming them; the two are evicted together. A
|
||||||
live photo that an earlier version cached as its ZIP is not served: the library
|
live photo that an earlier version cached as its ZIP is not served: the library
|
||||||
@@ -840,7 +861,7 @@ from a very old client, is stored unchecked.
|
|||||||
- `src/library/index.ts`: `Library`, `LibraryOptions`, `LibraryStatus`,
|
- `src/library/index.ts`: `Library`, `LibraryOptions`, `LibraryStatus`,
|
||||||
`LibraryClient`, `RefreshEvent`
|
`LibraryClient`, `RefreshEvent`
|
||||||
- `src/library/read.ts`: `Album`, `Photo`, `AlbumsAPI`, `PhotosAPI`,
|
- `src/library/read.ts`: `Album`, `Photo`, `AlbumsAPI`, `PhotosAPI`,
|
||||||
`TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy`
|
`TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy`, `SavePathLookup`
|
||||||
- `src/library/content.ts`: `ContentResult`, `ContentOptions`, `ThumbnailsAPI`,
|
- `src/library/content.ts`: `ContentResult`, `ContentOptions`, `ThumbnailsAPI`,
|
||||||
`EnsureOptions`, `EnsureResult`, `ContentSource`
|
`EnsureOptions`, `EnsureResult`, `ContentSource`
|
||||||
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
|
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ declares one.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-01: Each original's save path is
|
||||||
|
`YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's
|
||||||
|
download directory, which defaults to `photos` in the working directory (issue
|
||||||
|
143). The date is the photo's `takenAt` in the machine's time zone.
|
||||||
|
`photo.savePath` is always a string, with or without a content cache, and
|
||||||
|
`isLocal` is true only when the original is at its save path.
|
||||||
|
`photo.download()` puts the original there, copied from the cache when the
|
||||||
|
cache holds it and fetched otherwise. `lib.backup()` does the same for each
|
||||||
|
file, writes each file's JSON beside its original, and links `collections/` to
|
||||||
|
the save paths; the backup has no `originals/` folder.
|
||||||
|
|
||||||
- 2026-10-01: `photo.exif()` and `backup-metadata --exif` read EXIF from
|
- 2026-10-01: `photo.exif()` and `backup-metadata --exif` read EXIF from
|
||||||
HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and
|
HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and
|
||||||
the other image formats `exifreader` reads (issue 145). `exifreader` replaces
|
the other image formats `exifreader` reads (issue 145). `exifreader` replaces
|
||||||
|
|||||||
+75
-117
@@ -2,29 +2,30 @@
|
|||||||
//
|
//
|
||||||
// `lib.backup()` waits for a completed refresh of the library (a failed one
|
// `lib.backup()` waits for a completed refresh of the library (a failed one
|
||||||
// fails the backup before any file is touched), then, for every file in scope,
|
// fails the backup before any file is touched), then, for every file in scope,
|
||||||
// gets its original bytes onto disk under `downloadDirectory` and rebuilds the
|
// puts its original at its save path under `downloadDirectory`, as
|
||||||
// derived views (per-file sidecars, per-collection symlink trees,
|
// `Photo.download()` does, and rebuilds the derived views (per-file sidecars,
|
||||||
// per-collection JSON) from the model. The on-disk layout is the historical
|
// per-collection symlink trees, per-collection JSON) from the model. The
|
||||||
// one:
|
// on-disk layout:
|
||||||
//
|
//
|
||||||
// <downloadDirectory>/
|
// <downloadDirectory>/
|
||||||
// originals/<fileID>.<ext> the decrypted bytes
|
// YYYY/YYYY-MM/YYYY-MM-DD/
|
||||||
// originals/<fileID>.json per-file metadata sidecar
|
// YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path)
|
||||||
// collections/<name>/<title> symlink into ../../originals
|
// YYYY-MM-DD.<fileID>.json per-file metadata sidecar
|
||||||
// collections/<name>.json per-collection metadata
|
// collections/<name>/<title> symlink to the original
|
||||||
// failures.json durable ledger of unresolved failures
|
// collections/<name>.json per-collection metadata
|
||||||
|
// failures.json durable ledger of unresolved failures
|
||||||
//
|
//
|
||||||
// A live photo's original is its image and its video, `<fileID>.<ext>` each
|
// A live photo's original is its image and its video, each with its own
|
||||||
// with its own extension, and `originals/<fileID>.livephoto.json` naming them;
|
// extension, beside `YYYY-MM-DD.<fileID>.livephoto.json` naming them; its album
|
||||||
// its album folders link both.
|
// folders link both.
|
||||||
//
|
//
|
||||||
// Crash-safety rests on two properties. Bytes are present-means-complete: an
|
// Crash-safety rests on two properties. Bytes are present-means-complete: an
|
||||||
// original appears under `originals/` only via the content layer's atomic
|
// original appears at its save path only via the content layer's atomic
|
||||||
// temp-then-rename, so a file that exists is whole and is never re-fetched — an
|
// temp-then-rename, so a file that exists is whole and is never re-fetched — an
|
||||||
// interrupted run resumes by listing the directory. The derived views hold no
|
// interrupted run resumes by looking at the save paths. The derived views hold
|
||||||
// unique state, so they are rebuilt every run; that repairs stale sidecars and
|
// no unique state, so they are rebuilt every run; that repairs stale sidecars
|
||||||
// missing or broken symlinks left by an earlier crash. A rebuild also removes
|
// and missing or broken symlinks left by an earlier crash. A rebuild also
|
||||||
// the symlinks into originals/ that no longer belong to an album, and the
|
// removes the symlinks to originals that no longer belong to an album, and the
|
||||||
// directories of albums that no longer exist.
|
// directories of albums that no longer exist.
|
||||||
//
|
//
|
||||||
// Resilience (issue #8): no per-file condition aborts the run. A failed
|
// Resilience (issue #8): no per-file condition aborts the run. A failed
|
||||||
@@ -48,24 +49,24 @@ import {
|
|||||||
symlinkSync,
|
symlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { copyFile, rename, rm } from "node:fs/promises";
|
import { dirname, extname, join, relative, resolve } from "node:path";
|
||||||
import { basename, dirname, extname, join, relative } from "node:path";
|
|
||||||
|
|
||||||
import { fsyncPath, removeLeftoverTempFiles } from "./download/index.js";
|
import { removeLeftoverTempFiles } from "./download/index.js";
|
||||||
import { sanitizeFileName, withExtension } from "./filename.js";
|
import { sanitizeFileName, withExtension } from "./filename.js";
|
||||||
import {
|
import {
|
||||||
nameInOriginals,
|
copyAtomic,
|
||||||
storedOriginal,
|
placeOriginal,
|
||||||
writeLivePhotoJSON,
|
savePath,
|
||||||
|
storedAtSavePath,
|
||||||
} from "./library/content.js";
|
} from "./library/content.js";
|
||||||
import type { Collection, EnteFile } from "./model/types.js";
|
import type { Collection, EnteFile } from "./model/types.js";
|
||||||
|
|
||||||
export type ProgressCallback = (message: string) => void;
|
export type ProgressCallback = (message: string) => void;
|
||||||
|
|
||||||
export interface BackupOptions {
|
export interface BackupOptions {
|
||||||
// Where the backup tree lives. Required: with none, `backup()` throws
|
// Where the backup tree lives. `lib.backup()` defaults it to the library's
|
||||||
// before any network traffic. A library opened with a `downloadDirectory`
|
// download directory; `runBackup` with none throws before any network
|
||||||
// supplies the default.
|
// traffic.
|
||||||
downloadDirectory?: string;
|
downloadDirectory?: string;
|
||||||
// Fetch and store full-resolution originals. Default true.
|
// Fetch and store full-resolution originals. Default true.
|
||||||
includeOriginals?: boolean;
|
includeOriginals?: boolean;
|
||||||
@@ -89,7 +90,7 @@ export interface BackupResult {
|
|||||||
totalFiles: number;
|
totalFiles: number;
|
||||||
// Originals fetched (or copied from the cache) this run.
|
// Originals fetched (or copied from the cache) this run.
|
||||||
downloaded: number;
|
downloaded: number;
|
||||||
// Originals already present and left untouched.
|
// Originals already at their save path and left untouched.
|
||||||
skipped: number;
|
skipped: number;
|
||||||
// Files with an unresolved failure after this run (the ledger size); the
|
// Files with an unresolved failure after this run (the ledger size); the
|
||||||
// CLI exits non-zero while this is above zero. A file can be both
|
// CLI exits non-zero while this is above zero. A file can be both
|
||||||
@@ -107,8 +108,8 @@ export interface BackupLibrary {
|
|||||||
listFiles(collectionID: number): EnteFile[];
|
listFiles(collectionID: number): EnteFile[];
|
||||||
// Get an original's bytes onto disk through the content cache/pools,
|
// Get an original's bytes onto disk through the content cache/pools,
|
||||||
// returning where they landed: `destination` when they were fetched now,
|
// returning where they landed: `destination` when they were fetched now,
|
||||||
// otherwise wherever they already were (the cache, or a prior backup). A
|
// otherwise wherever they already were (the cache, or the library's save
|
||||||
// live photo lands as its image and its video, fetched now beside
|
// path). A live photo lands as its image and its video, fetched now beside
|
||||||
// `destination`.
|
// `destination`.
|
||||||
original(
|
original(
|
||||||
fileID: number,
|
fileID: number,
|
||||||
@@ -168,58 +169,6 @@ const classify = (err: unknown): FailureClass => {
|
|||||||
const errorMessage = (err: unknown): string =>
|
const errorMessage = (err: unknown): string =>
|
||||||
err instanceof Error ? err.message : String(err);
|
err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
|
|
||||||
// `dest` appears only once it is whole ("present means complete"). As in the
|
|
||||||
// download writer, the temp file is fsynced before the rename and the directory
|
|
||||||
// after it, so a power cut cannot leave a correctly named but short original.
|
|
||||||
// The temp name carries this process's ID so a later run can tell a leftover
|
|
||||||
// from a copy still in progress (see `removeLeftoverTempFiles`).
|
|
||||||
const copyAtomic = async (src: string, dest: string): Promise<void> => {
|
|
||||||
if (src === dest) return;
|
|
||||||
const tmp = join(
|
|
||||||
dirname(dest),
|
|
||||||
`.quak-backup-${basename(dest)}-${process.pid}-${Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.slice(2)}.tmp`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await copyFile(src, tmp);
|
|
||||||
await fsyncPath(tmp);
|
|
||||||
// `rename` replaces the destination's directory entry: an existing
|
|
||||||
// symlink at `dest` is replaced, not followed, and the new file has
|
|
||||||
// the temp file's permissions (copied from `src`).
|
|
||||||
await rename(tmp, dest);
|
|
||||||
await fsyncPath(dirname(dest));
|
|
||||||
} finally {
|
|
||||||
await rm(tmp, { force: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Put an original the library returned at `dest` in originals/, where a fresh
|
|
||||||
// fetch already wrote it. A live photo's image and video go beside `dest`: when
|
|
||||||
// they came from the cache they are copied, after removing whatever was at
|
|
||||||
// `dest` (an earlier version's ZIP of the two). Then the JSON file naming them
|
|
||||||
// is written, which is what makes the live photo count as stored.
|
|
||||||
const placeOriginal = async (
|
|
||||||
file: EnteFile,
|
|
||||||
dest: string,
|
|
||||||
got: { path: string; videoPath?: string },
|
|
||||||
): Promise<void> => {
|
|
||||||
if (got.videoPath === undefined) {
|
|
||||||
await copyAtomic(got.path, dest);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const originalsDir = dirname(dest);
|
|
||||||
const path = join(originalsDir, basename(got.path));
|
|
||||||
const videoPath = join(originalsDir, basename(got.videoPath));
|
|
||||||
if (got.path !== path) {
|
|
||||||
await rm(dest, { force: true });
|
|
||||||
await copyAtomic(got.path, path);
|
|
||||||
await copyAtomic(got.videoPath, videoPath);
|
|
||||||
}
|
|
||||||
await writeLivePhotoJSON(originalsDir, file.id, { path, videoPath });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or
|
// Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or
|
||||||
// non-symlink entry. Throws on failure (a directory in the way, no permission)
|
// non-symlink entry. Throws on failure (a directory in the way, no permission)
|
||||||
// so the caller records it and moves on rather than aborting the run.
|
// so the caller records it and moves on rather than aborting the run.
|
||||||
@@ -291,36 +240,38 @@ const linksFor = (
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove the symlinks in the album directory `dir` that point into
|
// Whether the entry at `path` is a symlink a backup to `root` made: one to an
|
||||||
// `originalsDir` and are not named in `keep`. Nothing else in the directory
|
// original in a `YYYY/YYYY-MM/YYYY-MM-DD/` folder of `root`.
|
||||||
// is touched: anything else there was put there by the user.
|
const linksToOriginal = (path: string, root: string): boolean => {
|
||||||
|
if (!lstatSync(path).isSymbolicLink()) return false;
|
||||||
|
const target = relative(root, resolve(dirname(path), readlinkSync(path)));
|
||||||
|
return /^\d{4}\/\d{4}-\d\d\/\d{4}-\d\d-\d\d\/[^/]+$/.test(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove the symlinks in the album directory `dir` that point to an original
|
||||||
|
// in `root` and are not named in `keep`. Nothing else in the directory is
|
||||||
|
// touched: anything else there was put there by the user.
|
||||||
const removeStaleLinks = (
|
const removeStaleLinks = (
|
||||||
dir: string,
|
dir: string,
|
||||||
keep: Set<string>,
|
keep: Set<string>,
|
||||||
originalsDir: string,
|
root: string,
|
||||||
): void => {
|
): void => {
|
||||||
const target = relative(dir, originalsDir);
|
|
||||||
for (const name of readdirSync(dir)) {
|
for (const name of readdirSync(dir)) {
|
||||||
if (keep.has(name)) continue;
|
if (keep.has(name)) continue;
|
||||||
const path = join(dir, name);
|
const path = join(dir, name);
|
||||||
if (
|
if (linksToOriginal(path, root)) rmSync(path);
|
||||||
lstatSync(path).isSymbolicLink() &&
|
|
||||||
dirname(readlinkSync(path)) === target
|
|
||||||
) {
|
|
||||||
rmSync(path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove the directories under `collectionsDir` that an earlier run wrote for
|
// Remove the directories under `collectionsDir` that an earlier run wrote for
|
||||||
// an album that is gone or renamed: a directory not named in `current` with a
|
// an album that is gone or renamed: a directory not named in `current` with a
|
||||||
// `<name>.json` beside it holding an album ID, which is what a run writes. Its
|
// `<name>.json` beside it holding an album ID, which is what a run writes. Its
|
||||||
// symlinks into originals/ are removed; if that leaves it empty, it and its
|
// symlinks to originals in `root` are removed; if that leaves it empty, it and
|
||||||
// JSON are deleted, otherwise both stay for what the user put there.
|
// its JSON are deleted, otherwise both stay for what the user put there.
|
||||||
const removeStaleAlbumDirs = (
|
const removeStaleAlbumDirs = (
|
||||||
collectionsDir: string,
|
collectionsDir: string,
|
||||||
current: Set<string>,
|
current: Set<string>,
|
||||||
originalsDir: string,
|
root: string,
|
||||||
): void => {
|
): void => {
|
||||||
for (const entry of readdirSync(collectionsDir, { withFileTypes: true })) {
|
for (const entry of readdirSync(collectionsDir, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory() || current.has(entry.name)) continue;
|
if (!entry.isDirectory() || current.has(entry.name)) continue;
|
||||||
@@ -334,7 +285,7 @@ const removeStaleAlbumDirs = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const dir = join(collectionsDir, entry.name);
|
const dir = join(collectionsDir, entry.name);
|
||||||
removeStaleLinks(dir, new Set(), originalsDir);
|
removeStaleLinks(dir, new Set(), root);
|
||||||
if (readdirSync(dir).length > 0) continue;
|
if (readdirSync(dir).length > 0) continue;
|
||||||
rmdirSync(dir);
|
rmdirSync(dir);
|
||||||
rmSync(jsonPath);
|
rmSync(jsonPath);
|
||||||
@@ -402,13 +353,10 @@ export const runBackup = async (
|
|||||||
log("Refreshing library...");
|
log("Refreshing library...");
|
||||||
await lib.refresh();
|
await lib.refresh();
|
||||||
|
|
||||||
const originalsDir = join(downloadDirectory, "originals");
|
|
||||||
const collectionsDir = join(downloadDirectory, "collections");
|
const collectionsDir = join(downloadDirectory, "collections");
|
||||||
const thumbnailsDir = join(downloadDirectory, "thumbnails");
|
const thumbnailsDir = join(downloadDirectory, "thumbnails");
|
||||||
mkdirSync(originalsDir, { recursive: true });
|
|
||||||
mkdirSync(collectionsDir, { recursive: true });
|
mkdirSync(collectionsDir, { recursive: true });
|
||||||
if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true });
|
if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true });
|
||||||
removeLeftoverTempFiles(originalsDir);
|
|
||||||
removeLeftoverTempFiles(thumbnailsDir);
|
removeLeftoverTempFiles(thumbnailsDir);
|
||||||
|
|
||||||
const ledgerPath = join(downloadDirectory, "failures.json");
|
const ledgerPath = join(downloadDirectory, "failures.json");
|
||||||
@@ -465,25 +413,30 @@ export const runBackup = async (
|
|||||||
failedThisRun.add(file.id);
|
failedThisRun.add(file.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Phase 1: get the bytes. Fetch each pending original (and optional
|
// Phase 1: get the bytes. Put each pending original at its save path
|
||||||
// thumbnail) through the content cache/pools and place it under the backup
|
// through the content cache/pools, as `Photo.download()` does, and fetch
|
||||||
// tree; a present file is left as is.
|
// the optional thumbnails; a present file is left as is.
|
||||||
if (includeOriginals) {
|
if (includeOriginals) {
|
||||||
|
// A killed run may have left temp files in the save path folders.
|
||||||
|
const saveDirs = new Set(
|
||||||
|
[...distinct.values()].map((f) =>
|
||||||
|
dirname(savePath(downloadDirectory, f)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const dir of saveDirs) removeLeftoverTempFiles(dir);
|
||||||
|
|
||||||
for (const [fileID, file] of distinct) {
|
for (const [fileID, file] of distinct) {
|
||||||
if (storedOriginal(originalsDir, file) !== undefined) {
|
if (storedAtSavePath(downloadDirectory, file) !== undefined) {
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const dest = join(originalsDir, nameInOriginals(file));
|
|
||||||
try {
|
try {
|
||||||
log(`Fetching original ${file.metadata.title} (${fileID})...`);
|
log(`Fetching original ${file.metadata.title} (${fileID})...`);
|
||||||
// A fetched original is written straight to `dest` (a live
|
// A fetched original is written straight to its save path (a
|
||||||
// photo beside it); only one that was already cached elsewhere
|
// live photo beside it); only one that was already cached
|
||||||
// is copied.
|
// elsewhere is copied.
|
||||||
await placeOriginal(
|
await placeOriginal(downloadDirectory, file, (dest) =>
|
||||||
file,
|
lib.original(fileID, dest),
|
||||||
dest,
|
|
||||||
await lib.original(fileID, dest),
|
|
||||||
);
|
);
|
||||||
downloaded++;
|
downloaded++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -519,9 +472,10 @@ export const runBackup = async (
|
|||||||
// Phase 2: rebuild the derived views from the model. Sidecars first, for
|
// Phase 2: rebuild the derived views from the model. Sidecars first, for
|
||||||
// every present original (this repairs stale ones).
|
// every present original (this repairs stale ones).
|
||||||
if (includeOriginals) {
|
if (includeOriginals) {
|
||||||
for (const [fileID, file] of distinct) {
|
for (const file of distinct.values()) {
|
||||||
if (storedOriginal(originalsDir, file) !== undefined) {
|
if (storedAtSavePath(downloadDirectory, file) !== undefined) {
|
||||||
writeSidecar(join(originalsDir, `${fileID}.json`), file);
|
const path = savePath(downloadDirectory, file);
|
||||||
|
writeSidecar(withExtension(path, ".json"), file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -543,7 +497,11 @@ export const runBackup = async (
|
|||||||
allCollections.map((c, i) => [c.id, dirNames[i]!]),
|
allCollections.map((c, i) => [c.id, dirNames[i]!]),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
removeStaleAlbumDirs(collectionsDir, new Set(dirNames), originalsDir);
|
removeStaleAlbumDirs(
|
||||||
|
collectionsDir,
|
||||||
|
new Set(dirNames),
|
||||||
|
downloadDirectory,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`FAILED removing old album directories: ${errorMessage(err)}`);
|
log(`FAILED removing old album directories: ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
@@ -555,11 +513,11 @@ export const runBackup = async (
|
|||||||
|
|
||||||
const files = filesByCollection.get(c.id) ?? [];
|
const files = filesByCollection.get(c.id) ?? [];
|
||||||
const links = files.flatMap((f) =>
|
const links = files.flatMap((f) =>
|
||||||
linksFor(f, storedOriginal(originalsDir, f)),
|
linksFor(f, storedAtSavePath(downloadDirectory, f)),
|
||||||
);
|
);
|
||||||
const linkNames = uniqueNames(links, true);
|
const linkNames = uniqueNames(links, true);
|
||||||
try {
|
try {
|
||||||
removeStaleLinks(colDir, new Set(linkNames), originalsDir);
|
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`);
|
log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export {
|
|||||||
type PhotoFilter,
|
type PhotoFilter,
|
||||||
type TimelineGroup,
|
type TimelineGroup,
|
||||||
type GroupBy,
|
type GroupBy,
|
||||||
|
type SavePathLookup,
|
||||||
type ContentSource,
|
type ContentSource,
|
||||||
type ContentResult,
|
type ContentResult,
|
||||||
type ContentEvent,
|
type ContentEvent,
|
||||||
|
|||||||
+150
-68
@@ -23,6 +23,11 @@
|
|||||||
// original with no recorded hash is stored unchecked, as the upstream client
|
// original with no recorded hash is stored unchecked, as the upstream client
|
||||||
// does; thumbnails have none. On top of that this module refuses to record a
|
// does; thumbnails have none. On top of that this module refuses to record a
|
||||||
// stored file that came out empty.
|
// stored file that came out empty.
|
||||||
|
//
|
||||||
|
// It also names each original's save path under the download directory
|
||||||
|
// (`savePath`), where `Photo.download()` and `lib.backup()` put it. A copy in
|
||||||
|
// the cache does not count as saved there, but is copied there rather than
|
||||||
|
// fetched again.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
closeSync,
|
closeSync,
|
||||||
@@ -34,8 +39,10 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import {
|
import {
|
||||||
chmod,
|
chmod,
|
||||||
|
copyFile,
|
||||||
mkdir,
|
mkdir,
|
||||||
readdir,
|
readdir,
|
||||||
|
rename,
|
||||||
rm,
|
rm,
|
||||||
stat,
|
stat,
|
||||||
statfs,
|
statfs,
|
||||||
@@ -47,13 +54,15 @@ import type { ApiClient } from "../api/client.js";
|
|||||||
import {
|
import {
|
||||||
downloadFile,
|
downloadFile,
|
||||||
downloadThumbnail,
|
downloadThumbnail,
|
||||||
|
fsyncPath,
|
||||||
type ProgressCallback,
|
type ProgressCallback,
|
||||||
removeLeftoverTempFiles,
|
removeLeftoverTempFiles,
|
||||||
writeAtomic,
|
writeAtomic,
|
||||||
} from "../download/index.js";
|
} from "../download/index.js";
|
||||||
import { safeExtension } from "../filename.js";
|
import { safeExtension, withExtension } from "../filename.js";
|
||||||
import type { EnteFile } from "../model/types.js";
|
import type { EnteFile } from "../model/types.js";
|
||||||
import type { Priority, RequestPools } from "./pools.js";
|
import type { Priority, RequestPools } from "./pools.js";
|
||||||
|
import { takenAtOf } from "./records.js";
|
||||||
|
|
||||||
const DIR_MODE = 0o700;
|
const DIR_MODE = 0o700;
|
||||||
const FILE_MODE = 0o600;
|
const FILE_MODE = 0o600;
|
||||||
@@ -108,11 +117,8 @@ export interface ContentOptions {
|
|||||||
export interface PhotoContent {
|
export interface PhotoContent {
|
||||||
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
||||||
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
||||||
// Where a backup stores the original, whether or not it is there yet. For
|
// Put the original at its save path and return it there.
|
||||||
// a live photo not yet stored, it carries the title's extension, and the
|
download(fileID: number): Promise<ContentResult>;
|
||||||
// backup may store the image under a different one.
|
|
||||||
savePath(fileID: number): string | undefined;
|
|
||||||
isLocal(fileID: number): boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnsureResult {
|
export interface EnsureResult {
|
||||||
@@ -199,10 +205,10 @@ export interface ContentCacheOptions {
|
|||||||
pools: RequestPools;
|
pools: RequestPools;
|
||||||
source: ContentSource;
|
source: ContentSource;
|
||||||
cacheDirectory: string;
|
cacheDirectory: string;
|
||||||
// The backup destination (issue-level `downloadDirectory`). An original
|
// The root of the save paths. An original already stored at its save path
|
||||||
// already stored there by a backup counts as present, so the cache serves
|
// counts as present, so the cache serves it rather than fetching a second
|
||||||
// it rather than fetching a second copy.
|
// copy.
|
||||||
downloadDirectory?: string;
|
downloadDirectory: string;
|
||||||
// Resolve any membership of a file; every membership shares the underlying
|
// Resolve any membership of a file; every membership shares the underlying
|
||||||
// content key, so any one decrypts the same bytes.
|
// content key, so any one decrypts the same bytes.
|
||||||
getFile: (fileID: number) => EnteFile | undefined;
|
getFile: (fileID: number) => EnteFile | undefined;
|
||||||
@@ -229,11 +235,27 @@ class AbortDrop extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The name of a file's original in originals/: `<fileID><ext>`, the extension
|
// The name of a file's original in the cache's originals/: `<fileID><ext>`, the
|
||||||
// taken from the title (or `.bin`). A backup names its originals the same way.
|
// extension taken from the title (or `.bin`).
|
||||||
export const nameInOriginals = (file: EnteFile): string =>
|
export const nameInOriginals = (file: EnteFile): string =>
|
||||||
`${file.id}${safeExtension(file.metadata.title)}`;
|
`${file.id}${safeExtension(file.metadata.title)}`;
|
||||||
|
|
||||||
|
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
// Where the original of `file` is saved under `root`:
|
||||||
|
// `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`, dated by the photo's
|
||||||
|
// `takenAt` in this machine's time zone, with the extension taken from the
|
||||||
|
// title (or `.bin`). A live photo is stored as its image and its video beside
|
||||||
|
// this path, each with the extension found inside the live photo.
|
||||||
|
export const savePath = (root: string, file: EnteFile): string => {
|
||||||
|
const taken = new Date(takenAtOf(file));
|
||||||
|
const year = String(taken.getFullYear());
|
||||||
|
const month = `${year}-${pad(taken.getMonth() + 1)}`;
|
||||||
|
const day = `${month}-${pad(taken.getDate())}`;
|
||||||
|
const ext = safeExtension(file.metadata.title);
|
||||||
|
return join(root, year, month, day, `${day}.${file.id}${ext}`);
|
||||||
|
};
|
||||||
|
|
||||||
// The fileID a cache filename encodes, or undefined when the name is not one
|
// The fileID a cache filename encodes, or undefined when the name is not one
|
||||||
// the cache writes (`<digits><ext>`).
|
// the cache writes (`<digits><ext>`).
|
||||||
const fileIDFromName = (name: string): number | undefined => {
|
const fileIDFromName = (name: string): number | undefined => {
|
||||||
@@ -279,28 +301,29 @@ const isZip = (path: string): boolean => {
|
|||||||
|
|
||||||
// A live photo's image and video are named with the extensions from inside its
|
// A live photo's image and video are named with the extensions from inside its
|
||||||
// ZIP, so their names alone do not say which is which. Wherever the cache or a
|
// ZIP, so their names alone do not say which is which. Wherever the cache or a
|
||||||
// backup stores one, a JSON file of this name beside them names both.
|
// save path stores one, a JSON file of this name beside them names both.
|
||||||
const livePhotoJSONName = (fileID: number): string =>
|
// `name` is the original's name without its extension: `<fileID>` in the
|
||||||
`${fileID}.livephoto.json`;
|
// cache, `YYYY-MM-DD.<fileID>` at the save path.
|
||||||
|
const livePhotoJSONName = (name: string): string => `${name}.livephoto.json`;
|
||||||
|
|
||||||
// The image and video that the live photo's JSON file in `dir` names, or
|
// The image and video that the live photo's JSON file in `dir` names, or
|
||||||
// undefined when there is none. Only names of the form the cache writes are
|
// undefined when there is none. Only `name` with an extension is taken, so the
|
||||||
// taken, so the file cannot point outside `dir`.
|
// file cannot point outside `dir`.
|
||||||
const readLivePhotoJSON = (
|
const readLivePhotoJSON = (
|
||||||
dir: string,
|
dir: string,
|
||||||
fileID: number,
|
name: string,
|
||||||
): { path: string; videoPath: string } | undefined => {
|
): { path: string; videoPath: string } | undefined => {
|
||||||
const valid = (name: unknown): name is string =>
|
const valid = (part: unknown): part is string =>
|
||||||
typeof name === "string" && name === `${fileID}${safeExtension(name)}`;
|
typeof part === "string" && part === `${name}${safeExtension(part)}`;
|
||||||
try {
|
try {
|
||||||
const { image, video } = JSON.parse(
|
const { image, video } = JSON.parse(
|
||||||
readFileSync(join(dir, livePhotoJSONName(fileID)), "utf-8"),
|
readFileSync(join(dir, livePhotoJSONName(name)), "utf-8"),
|
||||||
);
|
);
|
||||||
if (valid(image) && valid(video)) {
|
if (valid(image) && valid(video)) {
|
||||||
return { path: join(dir, image), videoPath: join(dir, video) };
|
return { path: join(dir, image), videoPath: join(dir, video) };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No such file, or not one the cache wrote.
|
// No such file, or not one quak wrote.
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
@@ -308,11 +331,11 @@ const readLivePhotoJSON = (
|
|||||||
// Write the JSON file naming a live photo's image and video, both in `dir`.
|
// Write the JSON file naming a live photo's image and video, both in `dir`.
|
||||||
export const writeLivePhotoJSON = (
|
export const writeLivePhotoJSON = (
|
||||||
dir: string,
|
dir: string,
|
||||||
fileID: number,
|
name: string,
|
||||||
stored: { path: string; videoPath: string },
|
stored: { path: string; videoPath: string },
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
writeAtomic(
|
writeAtomic(
|
||||||
join(dir, livePhotoJSONName(fileID)),
|
join(dir, livePhotoJSONName(name)),
|
||||||
new TextEncoder().encode(
|
new TextEncoder().encode(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
image: basename(stored.path),
|
image: basename(stored.path),
|
||||||
@@ -321,17 +344,19 @@ export const writeLivePhotoJSON = (
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The original of `file` as the cache or a backup stored it in `dir`, when all
|
// The original of `file` as stored in `dir` under `name` (without its
|
||||||
// of it is there: `<fileID><ext>`, or a live photo's image and video.
|
// extension), when all of it is there: `<name><ext>`, or a live photo's image
|
||||||
|
// and video.
|
||||||
export const storedOriginal = (
|
export const storedOriginal = (
|
||||||
dir: string,
|
dir: string,
|
||||||
|
name: string,
|
||||||
file: EnteFile,
|
file: EnteFile,
|
||||||
): { path: string; videoPath?: string } | undefined => {
|
): { path: string; videoPath?: string } | undefined => {
|
||||||
if (file.metadata.fileType !== "livePhoto") {
|
if (file.metadata.fileType !== "livePhoto") {
|
||||||
const path = join(dir, nameInOriginals(file));
|
const path = join(dir, `${name}${safeExtension(file.metadata.title)}`);
|
||||||
return hasContent(path) ? { path } : undefined;
|
return hasContent(path) ? { path } : undefined;
|
||||||
}
|
}
|
||||||
const stored = readLivePhotoJSON(dir, file.id);
|
const stored = readLivePhotoJSON(dir, name);
|
||||||
return stored !== undefined &&
|
return stored !== undefined &&
|
||||||
hasContent(stored.path) &&
|
hasContent(stored.path) &&
|
||||||
hasContent(stored.videoPath)
|
hasContent(stored.videoPath)
|
||||||
@@ -339,10 +364,79 @@ export const storedOriginal = (
|
|||||||
: undefined;
|
: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The original of `file` as stored at its save path under `root`, when all of
|
||||||
|
// it is there.
|
||||||
|
export const storedAtSavePath = (
|
||||||
|
root: string,
|
||||||
|
file: EnteFile,
|
||||||
|
): { path: string; videoPath?: string } | undefined => {
|
||||||
|
const path = savePath(root, file);
|
||||||
|
return storedOriginal(dirname(path), basename(path, extname(path)), file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
|
||||||
|
// `dest` appears only once it is whole ("present means complete"). As in the
|
||||||
|
// download writer, the temp file is fsynced before the rename and the directory
|
||||||
|
// after it, so a power cut cannot leave a correctly named but short original.
|
||||||
|
// The temp name carries this process's ID so a later run can tell a leftover
|
||||||
|
// from a copy still in progress (see `removeLeftoverTempFiles`).
|
||||||
|
export const copyAtomic = async (src: string, dest: string): Promise<void> => {
|
||||||
|
if (src === dest) return;
|
||||||
|
const tmp = join(
|
||||||
|
dirname(dest),
|
||||||
|
`.quak-backup-${basename(dest)}-${process.pid}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2)}.tmp`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await copyFile(src, tmp);
|
||||||
|
await fsyncPath(tmp);
|
||||||
|
// `rename` replaces the destination's directory entry: an existing
|
||||||
|
// symlink at `dest` is replaced, not followed, and the new file has
|
||||||
|
// the temp file's permissions (copied from `src`).
|
||||||
|
await rename(tmp, dest);
|
||||||
|
await fsyncPath(dirname(dest));
|
||||||
|
} finally {
|
||||||
|
await rm(tmp, { force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Put the original of `file` at its save path under `root`, creating its
|
||||||
|
// folders. `get` is given the save path and returns where the original is: a
|
||||||
|
// fetch writes it there, and a copy the cache holds is copied there. A live
|
||||||
|
// photo's image and video go beside the save path, each with its own
|
||||||
|
// extension: when they came from the cache they are copied, after removing
|
||||||
|
// whatever was at the save path (an earlier version's ZIP of the two). Then
|
||||||
|
// the JSON file naming them is written, which is what makes the live photo
|
||||||
|
// count as stored.
|
||||||
|
export const placeOriginal = async (
|
||||||
|
root: string,
|
||||||
|
file: EnteFile,
|
||||||
|
get: (dest: string) => Promise<{ path: string; videoPath?: string }>,
|
||||||
|
): Promise<{ path: string; videoPath?: string }> => {
|
||||||
|
const dest = savePath(root, file);
|
||||||
|
await mkdir(dirname(dest), { recursive: true });
|
||||||
|
const got = await get(dest);
|
||||||
|
if (got.videoPath === undefined) {
|
||||||
|
await copyAtomic(got.path, dest);
|
||||||
|
return { path: dest };
|
||||||
|
}
|
||||||
|
const path = withExtension(dest, extname(got.path));
|
||||||
|
const videoPath = withExtension(dest, extname(got.videoPath));
|
||||||
|
if (got.path !== path) {
|
||||||
|
await rm(dest, { force: true });
|
||||||
|
await copyAtomic(got.path, path);
|
||||||
|
await copyAtomic(got.videoPath, videoPath);
|
||||||
|
}
|
||||||
|
const name = basename(dest, extname(dest));
|
||||||
|
await writeLivePhotoJSON(dirname(dest), name, { path, videoPath });
|
||||||
|
return { path, videoPath };
|
||||||
|
};
|
||||||
|
|
||||||
export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
||||||
private readonly pools: RequestPools;
|
private readonly pools: RequestPools;
|
||||||
private readonly source: ContentSource;
|
private readonly source: ContentSource;
|
||||||
private readonly downloadDirectory?: string;
|
private readonly downloadDirectory: string;
|
||||||
private readonly getFile: (fileID: number) => EnteFile | undefined;
|
private readonly getFile: (fileID: number) => EnteFile | undefined;
|
||||||
private readonly originalsDir: string;
|
private readonly originalsDir: string;
|
||||||
private readonly thumbnailsDir: string;
|
private readonly thumbnailsDir: string;
|
||||||
@@ -440,32 +534,23 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
|
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where a backup to the download directory stores the file's original,
|
// Put the original at its save path under the download directory and
|
||||||
// whether or not it is there yet: for a live photo already stored, its
|
// return it there. One already stored there is returned as it is; one the
|
||||||
// image. For a live photo not yet stored, it carries the title's
|
// cache holds is copied from it; any other is fetched straight to the
|
||||||
// extension, and the backup may store the image under a different one.
|
// save path, with no copy left in the cache.
|
||||||
// Undefined with no download directory.
|
async download(fileID: number): Promise<ContentResult> {
|
||||||
savePath(fileID: number): string | undefined {
|
|
||||||
const file = this.getFile(fileID);
|
const file = this.getFile(fileID);
|
||||||
if (this.downloadDirectory === undefined || file === undefined)
|
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
|
||||||
return undefined;
|
const root = this.downloadDirectory;
|
||||||
const dir = join(this.downloadDirectory, "originals");
|
const saved =
|
||||||
return (
|
storedAtSavePath(root, file) ??
|
||||||
storedOriginal(dir, file)?.path ?? join(dir, nameInOriginals(file))
|
(await placeOriginal(root, file, (dest) =>
|
||||||
);
|
this.backupOriginal(fileID, dest),
|
||||||
|
));
|
||||||
|
return { ...saved, bytes: fileSize(saved.path) ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether the whole original is in the download directory, as a backup
|
// Get an original for a save path. One not present anywhere is written
|
||||||
// stores it. A copy only in the cache does not count.
|
|
||||||
isLocal(fileID: number): boolean {
|
|
||||||
const file = this.getFile(fileID);
|
|
||||||
if (this.downloadDirectory === undefined || file === undefined)
|
|
||||||
return false;
|
|
||||||
const dir = join(this.downloadDirectory, "originals");
|
|
||||||
return storedOriginal(dir, file) !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get an original for a backup. One not present anywhere is written
|
|
||||||
// straight to `destination` and recorded there, so no second copy lands
|
// straight to `destination` and recorded there, so no second copy lands
|
||||||
// in the cache; one already present is returned where it is.
|
// in the cache; one already present is returned where it is.
|
||||||
async backupOriginal(
|
async backupOriginal(
|
||||||
@@ -635,12 +720,9 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
known.delete(fileID);
|
known.delete(fileID);
|
||||||
}
|
}
|
||||||
|
|
||||||
// An original a backup already stored counts as present.
|
// An original already stored at its save path counts as present.
|
||||||
if (kind === "original" && this.downloadDirectory !== undefined) {
|
if (kind === "original") {
|
||||||
const stored = storedOriginal(
|
const stored = storedAtSavePath(this.downloadDirectory, file);
|
||||||
join(this.downloadDirectory, "originals"),
|
|
||||||
file,
|
|
||||||
);
|
|
||||||
if (stored !== undefined) {
|
if (stored !== undefined) {
|
||||||
this.originals.set(fileID, stored);
|
this.originals.set(fileID, stored);
|
||||||
return {
|
return {
|
||||||
@@ -676,7 +758,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
? this.beginOriginalWrite(fileID)
|
? this.beginOriginalWrite(fileID)
|
||||||
: null;
|
: null;
|
||||||
try {
|
try {
|
||||||
const stored = await this.download(
|
const stored = await this.fetchInto(
|
||||||
file,
|
file,
|
||||||
dest,
|
dest,
|
||||||
kind,
|
kind,
|
||||||
@@ -691,12 +773,12 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A backup records its own live photos.
|
// `placeOriginal` records a live photo it saves.
|
||||||
if (
|
if (
|
||||||
stored.videoPath !== undefined &&
|
stored.videoPath !== undefined &&
|
||||||
opts?.destination === undefined
|
opts?.destination === undefined
|
||||||
) {
|
) {
|
||||||
await writeLivePhotoJSON(dir, fileID, {
|
await writeLivePhotoJSON(dir, String(fileID), {
|
||||||
path: stored.path,
|
path: stored.path,
|
||||||
videoPath: stored.videoPath,
|
videoPath: stored.videoPath,
|
||||||
});
|
});
|
||||||
@@ -719,7 +801,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
|
|
||||||
// Fetch into `destination`, returning where the bytes landed: there, or
|
// Fetch into `destination`, returning where the bytes landed: there, or
|
||||||
// for a live photo, its image and video beside it.
|
// for a live photo, its image and video beside it.
|
||||||
private async download(
|
private async fetchInto(
|
||||||
file: EnteFile,
|
file: EnteFile,
|
||||||
destination: string,
|
destination: string,
|
||||||
kind: Kind,
|
kind: Kind,
|
||||||
@@ -744,10 +826,10 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
await utimes(path, now, now).catch(() => undefined);
|
await utimes(path, now, now).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every stored original that lives under `originalsDir` (a backup-directory
|
// Every stored original that lives under `originalsDir` (a save-path hit
|
||||||
// hit recorded in the map is excluded), with its size and mtime; a live
|
// recorded in the map is excluded), with its size and mtime; a live
|
||||||
// photo's size includes its video. Entries whose file has vanished are
|
// photo's size includes its video. Entries whose file has vanished are
|
||||||
// dropped from the map. Backups and thumbnails are never counted.
|
// dropped from the map. Save paths and thumbnails are never counted.
|
||||||
private async measureOriginals(): Promise<{
|
private async measureOriginals(): Promise<{
|
||||||
entries: {
|
entries: {
|
||||||
fileID: number;
|
fileID: number;
|
||||||
@@ -853,7 +935,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
await rm(
|
await rm(
|
||||||
join(
|
join(
|
||||||
this.originalsDir,
|
this.originalsDir,
|
||||||
livePhotoJSONName(e.fileID),
|
livePhotoJSONName(String(e.fileID)),
|
||||||
),
|
),
|
||||||
{ force: true },
|
{ force: true },
|
||||||
);
|
);
|
||||||
@@ -907,8 +989,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
// earlier version stored under the image's name, and is removed.
|
// earlier version stored under the image's name, and is removed.
|
||||||
// Any other is left alone: another process may have just stored
|
// Any other is left alone: another process may have just stored
|
||||||
// it and not yet written the JSON file.
|
// it and not yet written the JSON file.
|
||||||
const livePhoto = names.has(livePhotoJSONName(id))
|
const livePhoto = names.has(livePhotoJSONName(String(id)))
|
||||||
? readLivePhotoJSON(dir, id)
|
? readLivePhotoJSON(dir, String(id))
|
||||||
: undefined;
|
: undefined;
|
||||||
if (livePhoto !== undefined) {
|
if (livePhoto !== undefined) {
|
||||||
into.set(id, livePhoto);
|
into.set(id, livePhoto);
|
||||||
|
|||||||
+42
-25
@@ -27,7 +27,7 @@
|
|||||||
// never masked by a subsequent empty refresh.
|
// never masked by a subsequent empty refresh.
|
||||||
|
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import envPaths from "env-paths";
|
import envPaths from "env-paths";
|
||||||
|
|
||||||
import { MetadataStore } from "./store.js";
|
import { MetadataStore } from "./store.js";
|
||||||
@@ -49,9 +49,12 @@ import {
|
|||||||
type PhotosAPI,
|
type PhotosAPI,
|
||||||
type TimelineAPI,
|
type TimelineAPI,
|
||||||
type FreshReads,
|
type FreshReads,
|
||||||
|
type SavePathLookup,
|
||||||
} from "./read.js";
|
} from "./read.js";
|
||||||
import {
|
import {
|
||||||
ContentCache,
|
ContentCache,
|
||||||
|
savePath,
|
||||||
|
storedAtSavePath,
|
||||||
type ContentSource,
|
type ContentSource,
|
||||||
type ThumbnailsAPI,
|
type ThumbnailsAPI,
|
||||||
type EnsureOptions,
|
type EnsureOptions,
|
||||||
@@ -70,6 +73,7 @@ export {
|
|||||||
type PhotoFilter,
|
type PhotoFilter,
|
||||||
type TimelineGroup,
|
type TimelineGroup,
|
||||||
type GroupBy,
|
type GroupBy,
|
||||||
|
type SavePathLookup,
|
||||||
} from "./read.js";
|
} from "./read.js";
|
||||||
export {
|
export {
|
||||||
type ContentSource,
|
type ContentSource,
|
||||||
@@ -169,8 +173,10 @@ export interface LibraryOptions {
|
|||||||
// Where `metadata.json` lives. Defaults to the env-paths cache directory
|
// Where `metadata.json` lives. Defaults to the env-paths cache directory
|
||||||
// plus the user id, so each account has its own cache.
|
// plus the user id, so each account has its own cache.
|
||||||
cacheDirectory?: string;
|
cacheDirectory?: string;
|
||||||
// Persistent backup destination. The refresh loop does not use it; the
|
// The root of every photo's save path, where `Photo.download()` and
|
||||||
// content cache treats an original already stored there as present.
|
// `lib.backup()` put originals. Defaults to `photos` in the working
|
||||||
|
// directory at open. The content cache treats an original already stored
|
||||||
|
// at its save path as present.
|
||||||
downloadDirectory?: string;
|
downloadDirectory?: string;
|
||||||
refreshIntervalSeconds?: number;
|
refreshIntervalSeconds?: number;
|
||||||
onProgress?: RefreshProgressCallback;
|
onProgress?: RefreshProgressCallback;
|
||||||
@@ -234,7 +240,7 @@ export interface LibraryStatus {
|
|||||||
|
|
||||||
export class Library {
|
export class Library {
|
||||||
readonly cacheDirectory: string;
|
readonly cacheDirectory: string;
|
||||||
readonly downloadDirectory?: string;
|
readonly downloadDirectory: string;
|
||||||
|
|
||||||
// The in-process read surface (issue #44). Each namespace answers
|
// The in-process read surface (issue #44). Each namespace answers
|
||||||
// synchronously from the live record projection; no read touches the
|
// synchronously from the live record projection; no read touches the
|
||||||
@@ -296,7 +302,7 @@ export class Library {
|
|||||||
store: MetadataStore;
|
store: MetadataStore;
|
||||||
userID: number;
|
userID: number;
|
||||||
cacheDirectory: string;
|
cacheDirectory: string;
|
||||||
downloadDirectory?: string;
|
downloadDirectory: string;
|
||||||
intervalMs: number;
|
intervalMs: number;
|
||||||
onProgress?: RefreshProgressCallback;
|
onProgress?: RefreshProgressCallback;
|
||||||
pools: RequestPools;
|
pools: RequestPools;
|
||||||
@@ -320,8 +326,25 @@ export class Library {
|
|||||||
// The read namespaces derive fresh from the store on each call, so they
|
// The read namespaces derive fresh from the store on each call, so they
|
||||||
// always reflect the latest refresh.
|
// always reflect the latest refresh.
|
||||||
const derive = (): DerivedRecords => this.deriveNow();
|
const derive = (): DerivedRecords => this.deriveNow();
|
||||||
this.albums = makeAlbumsAPI(derive, this.cache);
|
const root = this.downloadDirectory;
|
||||||
this.photos = makePhotosAPI(derive, this.cache);
|
const saves: SavePathLookup = {
|
||||||
|
savePath: (fileID) => {
|
||||||
|
const file = this.store.getFileByID(fileID);
|
||||||
|
if (!file) throw new Error(`library: unknown file ${fileID}`);
|
||||||
|
return (
|
||||||
|
storedAtSavePath(root, file)?.path ?? savePath(root, file)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
isLocal: (fileID) => {
|
||||||
|
const file = this.store.getFileByID(fileID);
|
||||||
|
return (
|
||||||
|
file !== undefined &&
|
||||||
|
storedAtSavePath(root, file) !== undefined
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.albums = makeAlbumsAPI(derive, saves, this.cache);
|
||||||
|
this.photos = makePhotosAPI(derive, saves, this.cache);
|
||||||
this.timeline = makeTimelineAPI(derive);
|
this.timeline = makeTimelineAPI(derive);
|
||||||
this.thumbnails = {
|
this.thumbnails = {
|
||||||
ensure: (opts: EnsureOptions): Promise<EnsureResult[]> => {
|
ensure: (opts: EnsureOptions): Promise<EnsureResult[]> => {
|
||||||
@@ -350,6 +373,7 @@ export class Library {
|
|||||||
const { userID } = opts.client.whoami();
|
const { userID } = opts.client.whoami();
|
||||||
const cacheDirectory =
|
const cacheDirectory =
|
||||||
opts.cacheDirectory ?? defaultCacheDirectory(userID);
|
opts.cacheDirectory ?? defaultCacheDirectory(userID);
|
||||||
|
const downloadDirectory = opts.downloadDirectory ?? resolve("photos");
|
||||||
const metadataPath = join(cacheDirectory, "metadata.json");
|
const metadataPath = join(cacheDirectory, "metadata.json");
|
||||||
let store = await MetadataStore.load(metadataPath);
|
let store = await MetadataStore.load(metadataPath);
|
||||||
// A cache directory given explicitly can hold another account's cache.
|
// A cache directory given explicitly can hold another account's cache.
|
||||||
@@ -404,7 +428,7 @@ export class Library {
|
|||||||
pools,
|
pools,
|
||||||
source,
|
source,
|
||||||
cacheDirectory,
|
cacheDirectory,
|
||||||
downloadDirectory: opts.downloadDirectory,
|
downloadDirectory,
|
||||||
getFile: (fileID) => store.getFileByID(fileID),
|
getFile: (fileID) => store.getFileByID(fileID),
|
||||||
cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes,
|
cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes,
|
||||||
freeBelowBytes: opts.freeBelowBytes,
|
freeBelowBytes: opts.freeBelowBytes,
|
||||||
@@ -426,7 +450,7 @@ export class Library {
|
|||||||
store,
|
store,
|
||||||
userID,
|
userID,
|
||||||
cacheDirectory,
|
cacheDirectory,
|
||||||
downloadDirectory: opts.downloadDirectory,
|
downloadDirectory,
|
||||||
intervalMs,
|
intervalMs,
|
||||||
onProgress: opts.onProgress,
|
onProgress: opts.onProgress,
|
||||||
pools,
|
pools,
|
||||||
@@ -543,27 +567,20 @@ export class Library {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back up every in-scope file to `downloadDirectory` in the historical
|
// Back up every in-scope file to `opts.downloadDirectory`, or else the
|
||||||
// on-disk layout, with a durable failure ledger (issue #51). Waits for a
|
// library's, each original at its save path, with a durable failure
|
||||||
// completed refresh first, as `fresh()` does, joining one already running,
|
// ledger (issue #51). Waits for a completed refresh first, as `fresh()`
|
||||||
// and rejects before touching any file when it fails. Then fetches pending
|
// does, joining one already running, and rejects before touching any file
|
||||||
// originals (and optional thumbnails) through the content cache and pools,
|
// when it fails. Then puts pending originals at their save paths as
|
||||||
// and rebuilds the derived symlink/JSON views from the model. Throws before
|
// `Photo.download()` does (and optional thumbnails) through the content
|
||||||
// any network work when no download directory is available or no content
|
// cache and pools, and rebuilds the derived symlink/JSON views from the
|
||||||
// cache backs the originals it must fetch.
|
// model. Throws before any network work when no content cache backs the
|
||||||
|
// originals it must fetch.
|
||||||
backup(opts?: BackupOptions): Promise<BackupResult> {
|
backup(opts?: BackupOptions): Promise<BackupResult> {
|
||||||
const downloadDirectory =
|
const downloadDirectory =
|
||||||
opts?.downloadDirectory ?? this.downloadDirectory;
|
opts?.downloadDirectory ?? this.downloadDirectory;
|
||||||
const includeOriginals = opts?.includeOriginals ?? true;
|
const includeOriginals = opts?.includeOriginals ?? true;
|
||||||
const includeThumbnails = opts?.includeThumbnails ?? false;
|
const includeThumbnails = opts?.includeThumbnails ?? false;
|
||||||
if (!downloadDirectory) {
|
|
||||||
return Promise.reject(
|
|
||||||
new Error(
|
|
||||||
"backup requires a downloadDirectory (pass one to " +
|
|
||||||
"backup() or open the library with one)",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ((includeOriginals || includeThumbnails) && !this.cache) {
|
if ((includeOriginals || includeThumbnails) && !this.cache) {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
new Error(
|
new Error(
|
||||||
|
|||||||
+40
-20
@@ -12,10 +12,11 @@
|
|||||||
// plain records are the serializable surface, and `record()` returns one.
|
// plain records are the serializable surface, and `record()` returns one.
|
||||||
//
|
//
|
||||||
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
|
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
|
||||||
// `content()` and `exif()` go through the on-disk content cache (issue #46),
|
// `download()`, `content()` and `exif()` go through the on-disk content cache
|
||||||
// and are the one place in this module that may touch the network. A library
|
// (issue #46), and are the one place in this module that may touch the
|
||||||
// opened without a content source leaves that cache absent, and those methods
|
// network. A library opened without a content source leaves that cache absent,
|
||||||
// then throw. `savePath` and `isLocal` look only at the disk.
|
// and those methods then throw. `savePath` and `isLocal` look only at the disk
|
||||||
|
// and need no cache.
|
||||||
|
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
@@ -24,6 +25,13 @@ import type { CollectionType, FileType } from "../model/types.js";
|
|||||||
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
||||||
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.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(fileID: number): string;
|
||||||
|
isLocal(fileID: number): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
||||||
// deterministically — the same order the record projection uses.
|
// deterministically — the same order the record projection uses.
|
||||||
const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
|
const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
|
||||||
@@ -39,6 +47,7 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
|||||||
export class Photo {
|
export class Photo {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly rec: PhotoRecord,
|
private readonly rec: PhotoRecord,
|
||||||
|
private readonly saves: SavePathLookup,
|
||||||
private readonly cache?: PhotoContent,
|
private readonly cache?: PhotoContent,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -89,20 +98,20 @@ export class Photo {
|
|||||||
return this.rec.isHidden;
|
return this.rec.isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where `lib.backup()` stores the original in the library's download
|
// Where `download()` and `lib.backup()` put the original under the
|
||||||
// directory, whether or not it is there yet: for a live photo already
|
// library's download directory, whether or not it is there yet:
|
||||||
// stored, its image. For a live photo not yet stored, it carries the
|
// `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`. For a live photo
|
||||||
// title's extension, and the backup may store the image under a different
|
// already stored, its image. For a live photo not yet stored, it carries
|
||||||
// one. Undefined when the library has no download directory or no content
|
// the title's extension, and the image may be stored under a different
|
||||||
// cache.
|
// one.
|
||||||
get savePath(): string | undefined {
|
get savePath(): string {
|
||||||
return this.cache?.savePath(this.rec.fileID);
|
return this.saves.savePath(this.rec.fileID);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether the whole original is at `savePath`. A copy only in the cache
|
// Whether the whole original is at `savePath`. A copy only in the cache
|
||||||
// does not count.
|
// does not count.
|
||||||
get isLocal(): boolean {
|
get isLocal(): boolean {
|
||||||
return this.cache?.isLocal(this.rec.fileID) ?? false;
|
return this.saves.isLocal(this.rec.fileID);
|
||||||
}
|
}
|
||||||
|
|
||||||
record(): PhotoRecord {
|
record(): PhotoRecord {
|
||||||
@@ -111,12 +120,20 @@ export class Photo {
|
|||||||
|
|
||||||
// Fetch and cache the full-resolution original, returning its on-disk path
|
// 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
|
// 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)
|
// `videoPath`. Served from the cache (or the save path) when already
|
||||||
// when already present, otherwise fetched through the content pool.
|
// present, otherwise fetched through the content pool.
|
||||||
async original(opts?: ContentOptions): Promise<ContentResult> {
|
async original(opts?: ContentOptions): Promise<ContentResult> {
|
||||||
return this.cacheOrThrow().original(this.rec.fileID, opts);
|
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.rec.fileID);
|
||||||
|
}
|
||||||
|
|
||||||
// As `original`, for the thumbnail, through the thumbnail pool.
|
// As `original`, for the thumbnail, through the thumbnail pool.
|
||||||
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
||||||
return this.cacheOrThrow().thumbnail(this.rec.fileID, opts);
|
return this.cacheOrThrow().thumbnail(this.rec.fileID, opts);
|
||||||
@@ -156,6 +173,7 @@ export class Album {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly rec: AlbumRecord,
|
private readonly rec: AlbumRecord,
|
||||||
private readonly records: DerivedRecords,
|
private readonly records: DerivedRecords,
|
||||||
|
private readonly saves: SavePathLookup,
|
||||||
private readonly content?: PhotoContent,
|
private readonly content?: PhotoContent,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -190,7 +208,7 @@ export class Album {
|
|||||||
const out: Photo[] = [];
|
const out: Photo[] = [];
|
||||||
for (const id of this.rec.fileIDs) {
|
for (const id of this.rec.fileIDs) {
|
||||||
const p = this.records.photos.get(id);
|
const p = this.records.photos.get(id);
|
||||||
if (p) out.push(new Photo(p, this.content));
|
if (p) out.push(new Photo(p, this.saves, this.content));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -253,18 +271,19 @@ export interface FreshReads {
|
|||||||
|
|
||||||
export const makeAlbumsAPI = (
|
export const makeAlbumsAPI = (
|
||||||
derive: () => DerivedRecords,
|
derive: () => DerivedRecords,
|
||||||
|
saves: SavePathLookup,
|
||||||
content?: PhotoContent,
|
content?: PhotoContent,
|
||||||
): AlbumsAPI => ({
|
): AlbumsAPI => ({
|
||||||
list: (): Album[] => {
|
list: (): Album[] => {
|
||||||
const records = derive();
|
const records = derive();
|
||||||
return [...records.albums.values()]
|
return [...records.albums.values()]
|
||||||
.sort(byNewestAlbum)
|
.sort(byNewestAlbum)
|
||||||
.map((rec) => new Album(rec, records, content));
|
.map((rec) => new Album(rec, records, saves, content));
|
||||||
},
|
},
|
||||||
byID: ({ collectionID }): Album | undefined => {
|
byID: ({ collectionID }): Album | undefined => {
|
||||||
const records = derive();
|
const records = derive();
|
||||||
const rec = records.albums.get(collectionID);
|
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 => {
|
byName: ({ albumName }): Album | undefined => {
|
||||||
const records = derive();
|
const records = derive();
|
||||||
@@ -273,17 +292,18 @@ export const makeAlbumsAPI = (
|
|||||||
const match = [...records.albums.values()]
|
const match = [...records.albums.values()]
|
||||||
.sort(byNewestAlbum)
|
.sort(byNewestAlbum)
|
||||||
.find((rec) => rec.name === albumName);
|
.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 = (
|
export const makePhotosAPI = (
|
||||||
derive: () => DerivedRecords,
|
derive: () => DerivedRecords,
|
||||||
|
saves: SavePathLookup,
|
||||||
content?: PhotoContent,
|
content?: PhotoContent,
|
||||||
): PhotosAPI => ({
|
): PhotosAPI => ({
|
||||||
byID: ({ fileID }): Photo | undefined => {
|
byID: ({ fileID }): Photo | undefined => {
|
||||||
const rec = derive().photos.get(fileID);
|
const rec = derive().photos.get(fileID);
|
||||||
return rec ? new Photo(rec, content) : undefined;
|
return rec ? new Photo(rec, saves, content) : undefined;
|
||||||
},
|
},
|
||||||
records: ({ fileIDs }): PhotoRecord[] => {
|
records: ({ fileIDs }): PhotoRecord[] => {
|
||||||
const { photos } = derive();
|
const { photos } = derive();
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ const microsToMillis = (micros: number): number => Math.floor(micros / 1000);
|
|||||||
const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number =>
|
const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number =>
|
||||||
b.takenAt - a.takenAt || b.fileID - a.fileID;
|
b.takenAt - a.takenAt || b.fileID - a.fileID;
|
||||||
|
|
||||||
|
// A photo's `takenAt` in milliseconds: `pubMagicMetadata.editedTime` when the
|
||||||
|
// user edited the date, else basic-metadata `creationTime`.
|
||||||
|
export const takenAtOf = (file: EnteFile): number =>
|
||||||
|
microsToMillis(
|
||||||
|
asNumber(file.pubMagicMetadata?.editedTime) ??
|
||||||
|
file.metadata.creationTime,
|
||||||
|
);
|
||||||
|
|
||||||
// Build one PhotoRecord from every membership of a file. The memberships share
|
// Build one PhotoRecord from every membership of a file. The memberships share
|
||||||
// the same underlying file, so metadata is read from a single representative
|
// the same underlying file, so metadata is read from a single representative
|
||||||
// (the most recently synced, lowest collection id to break ties); `albumIDs`
|
// (the most recently synced, lowest collection id to break ties); `albumIDs`
|
||||||
@@ -123,14 +131,13 @@ const toPhotoRecord = (
|
|||||||
const pub = rep.pubMagicMetadata ?? {};
|
const pub = rep.pubMagicMetadata ?? {};
|
||||||
const priv = rep.magicMetadata ?? {};
|
const priv = rep.magicMetadata ?? {};
|
||||||
|
|
||||||
const takenAtMicros = asNumber(pub.editedTime) ?? rep.metadata.creationTime;
|
|
||||||
const visibility = asNumber(priv.visibility);
|
const visibility = asNumber(priv.visibility);
|
||||||
|
|
||||||
const record: PhotoRecord = {
|
const record: PhotoRecord = {
|
||||||
fileID,
|
fileID,
|
||||||
albumIDs,
|
albumIDs,
|
||||||
title: asString(pub.editedName) ?? rep.metadata.title,
|
title: asString(pub.editedName) ?? rep.metadata.title,
|
||||||
takenAt: microsToMillis(takenAtMicros),
|
takenAt: takenAtOf(rep),
|
||||||
modifiedAt: microsToMillis(rep.metadata.modificationTime),
|
modifiedAt: microsToMillis(rep.metadata.modificationTime),
|
||||||
fileType: rep.metadata.fileType,
|
fileType: rep.metadata.fileType,
|
||||||
isArchived: visibility === VISIBILITY_ARCHIVED,
|
isArchived: visibility === VISIBILITY_ARCHIVED,
|
||||||
|
|||||||
+115
-100
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for the `quak backup` logic, now built on the library API (issue #51).
|
* Tests for the `quak backup` logic, now built on the library API (issue #51).
|
||||||
*
|
*
|
||||||
* `lib.backup({ downloadDirectory })` refreshes the library, fetches each
|
* `lib.backup({ downloadDirectory })` refreshes the library, puts each pending
|
||||||
* pending file's original through the content cache/pools, and materialises the
|
* file's original at its save path through the content cache/pools, and
|
||||||
* unchanged on-disk layout:
|
* materialises the on-disk layout:
|
||||||
*
|
*
|
||||||
* <downloadDirectory>/
|
* <downloadDirectory>/
|
||||||
* originals/
|
* YYYY/YYYY-MM/YYYY-MM-DD/
|
||||||
* <fileID>.<ext> the decrypted bytes ("present means complete")
|
* YYYY-MM-DD.<fileID>.<ext> the decrypted bytes ("present means complete")
|
||||||
* <fileID>.json per-file metadata sidecar (rebuilt each run)
|
* YYYY-MM-DD.<fileID>.json per-file metadata sidecar (rebuilt each run)
|
||||||
* collections/
|
* collections/
|
||||||
* <name>/<title> symlink into ../originals (rebuilt each run)
|
* <name>/<title> symlink to the original (rebuilt each run)
|
||||||
* <name>.json per-collection metadata (rebuilt each run)
|
* <name>.json per-collection metadata (rebuilt each run)
|
||||||
* failures.json durable ledger of unresolved failures
|
* failures.json durable ledger of unresolved failures
|
||||||
*
|
*
|
||||||
@@ -94,6 +94,18 @@ const USER_ID = 42;
|
|||||||
// Decrypted-byte length each stub original writes, keyed by fileID.
|
// Decrypted-byte length each stub original writes, keyed by fileID.
|
||||||
const SIZE_BY_ID: Record<number, number> = { 100: 3000, 101: 2000, 200: 1500 };
|
const SIZE_BY_ID: Record<number, number> = { 100: 3000, 101: 2000, 200: 1500 };
|
||||||
|
|
||||||
|
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
|
||||||
|
// stores times, so the machine's time zone cannot move it to another day. Its
|
||||||
|
// original is saved in the folder `DAY`, named `2026-03-01.<fileID>.<ext>`.
|
||||||
|
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
|
||||||
|
const DAY = join("2026", "2026-03", "2026-03-01");
|
||||||
|
|
||||||
|
// Where the backup in `outDir` saves `name` (`<fileID>.<ext>`), and the target
|
||||||
|
// of an album folder's symlink to it.
|
||||||
|
const saved = (outDir: string, name: string): string =>
|
||||||
|
join(outDir, DAY, `2026-03-01.${name}`);
|
||||||
|
const linkTo = (name: string): string => `../../${DAY}/2026-03-01.${name}`;
|
||||||
|
|
||||||
const collection = (id: number, name: string): Collection => ({
|
const collection = (id: number, name: string): Collection => ({
|
||||||
id,
|
id,
|
||||||
ownerID: USER_ID,
|
ownerID: USER_ID,
|
||||||
@@ -112,7 +124,7 @@ const file = (id: number, collectionID: number, title: string): EnteFile => ({
|
|||||||
metadata: {
|
metadata: {
|
||||||
title,
|
title,
|
||||||
fileType: "image",
|
fileType: "image",
|
||||||
creationTime: 1,
|
creationTime: TAKEN,
|
||||||
modificationTime: 1,
|
modificationTime: 1,
|
||||||
},
|
},
|
||||||
file: { decryptionHeader: "aGVhZGVy" },
|
file: { decryptionHeader: "aGVhZGVy" },
|
||||||
@@ -253,7 +265,11 @@ describe("lib.backup", () => {
|
|||||||
it("throws before any network when no downloadDirectory is given", async () => {
|
it("throws before any network when no downloadDirectory is given", async () => {
|
||||||
const source = stubSource();
|
const source = stubSource();
|
||||||
const lib = await openLibrary(source);
|
const lib = await openLibrary(source);
|
||||||
await expect(lib.backup()).rejects.toThrow(/downloadDirectory/i);
|
// The library always has a download directory, so only an empty one
|
||||||
|
// given to backup() reaches runBackup as none.
|
||||||
|
await expect(lib.backup({ downloadDirectory: "" })).rejects.toThrow(
|
||||||
|
/downloadDirectory/i,
|
||||||
|
);
|
||||||
expect(source.originalCalls).toBe(0);
|
expect(source.originalCalls).toBe(0);
|
||||||
lib.close();
|
lib.close();
|
||||||
});
|
});
|
||||||
@@ -271,28 +287,22 @@ describe("lib.backup", () => {
|
|||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
expect(result.errors).toEqual([]);
|
expect(result.errors).toEqual([]);
|
||||||
|
|
||||||
// Originals under originals/<fileID>.<ext>.
|
// Originals at YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>.
|
||||||
expect(readFileSync(join(outDir, "originals", "100.jpg")).length).toBe(
|
expect(readFileSync(saved(outDir, "100.jpg")).length).toBe(3000);
|
||||||
3000,
|
expect(readFileSync(saved(outDir, "101.jpg")).length).toBe(2000);
|
||||||
);
|
expect(readFileSync(saved(outDir, "200.png")).length).toBe(1500);
|
||||||
expect(readFileSync(join(outDir, "originals", "101.jpg")).length).toBe(
|
|
||||||
2000,
|
|
||||||
);
|
|
||||||
expect(readFileSync(join(outDir, "originals", "200.png")).length).toBe(
|
|
||||||
1500,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Per-file metadata sidecar.
|
// Per-file metadata sidecar.
|
||||||
const sidecar = JSON.parse(
|
const sidecar = JSON.parse(
|
||||||
readFileSync(join(outDir, "originals", "100.json"), "utf-8"),
|
readFileSync(saved(outDir, "100.json"), "utf-8"),
|
||||||
);
|
);
|
||||||
expect(sidecar.id).toBe(100);
|
expect(sidecar.id).toBe(100);
|
||||||
expect(sidecar.metadata.title).toBe("beach.jpg");
|
expect(sidecar.metadata.title).toBe("beach.jpg");
|
||||||
|
|
||||||
// Collection dirs contain symlinks into ../originals.
|
// Collection dirs contain symlinks into the date folders.
|
||||||
const beach = join(outDir, "collections", "Vacation", "beach.jpg");
|
const beach = join(outDir, "collections", "Vacation", "beach.jpg");
|
||||||
expect(lstatSync(beach).isSymbolicLink()).toBe(true);
|
expect(lstatSync(beach).isSymbolicLink()).toBe(true);
|
||||||
expect(readlinkSync(beach)).toContain("originals");
|
expect(readlinkSync(beach)).toContain(DAY);
|
||||||
expect(readFileSync(beach).length).toBe(3000);
|
expect(readFileSync(beach).length).toBe(3000);
|
||||||
|
|
||||||
// Per-collection metadata JSON.
|
// Per-collection metadata JSON.
|
||||||
@@ -316,7 +326,7 @@ describe("lib.backup", () => {
|
|||||||
|
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
// The title has no usable extension, so the original is `.bin`.
|
// The title has no usable extension, so the original is `.bin`.
|
||||||
expect(existsSync(join(outDir, "originals", "300.bin"))).toBe(true);
|
expect(existsSync(saved(outDir, "300.bin"))).toBe(true);
|
||||||
const link = join(
|
const link = join(
|
||||||
outDir,
|
outDir,
|
||||||
"collections",
|
"collections",
|
||||||
@@ -366,9 +376,9 @@ describe("lib.backup", () => {
|
|||||||
expect(result.errors[0]!.title).toBe("sunset.jpg");
|
expect(result.errors[0]!.title).toBe("sunset.jpg");
|
||||||
|
|
||||||
// The two good files are on disk; the failed one is not.
|
// The two good files are on disk; the failed one is not.
|
||||||
expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true);
|
expect(existsSync(saved(outDir, "100.jpg"))).toBe(true);
|
||||||
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
|
expect(existsSync(saved(outDir, "200.png"))).toBe(true);
|
||||||
expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(false);
|
expect(existsSync(saved(outDir, "101.jpg"))).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")),
|
existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -403,7 +413,7 @@ describe("lib.backup", () => {
|
|||||||
const r3 = await lib.backup({ downloadDirectory: outDir });
|
const r3 = await lib.backup({ downloadDirectory: outDir });
|
||||||
expect(r3.failed).toBe(0);
|
expect(r3.failed).toBe(0);
|
||||||
expect(r3.skipped).toBe(2);
|
expect(r3.skipped).toBe(2);
|
||||||
expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(true);
|
expect(existsSync(saved(outDir, "101.jpg"))).toBe(true);
|
||||||
// A ledger with no remaining failures is removed.
|
// A ledger with no remaining failures is removed.
|
||||||
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
|
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
|
||||||
lib.close();
|
lib.close();
|
||||||
@@ -423,8 +433,8 @@ describe("lib.backup", () => {
|
|||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
// Every original still downloads despite the symlink failure.
|
// Every original still downloads despite the symlink failure.
|
||||||
expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true);
|
expect(existsSync(saved(outDir, "100.jpg"))).toBe(true);
|
||||||
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
|
expect(existsSync(saved(outDir, "200.png"))).toBe(true);
|
||||||
// The other symlinks are still built.
|
// The other symlinks are still built.
|
||||||
expect(
|
expect(
|
||||||
lstatSync(
|
lstatSync(
|
||||||
@@ -454,7 +464,7 @@ describe("lib.backup", () => {
|
|||||||
await lib.backup({ downloadDirectory: outDir });
|
await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
// Corrupt a sidecar and delete a symlink between runs.
|
// Corrupt a sidecar and delete a symlink between runs.
|
||||||
writeFileSync(join(outDir, "originals", "100.json"), "not json");
|
writeFileSync(saved(outDir, "100.json"), "not json");
|
||||||
rmSync(join(outDir, "collections", "Vacation", "beach.jpg"));
|
rmSync(join(outDir, "collections", "Vacation", "beach.jpg"));
|
||||||
|
|
||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
@@ -462,7 +472,7 @@ describe("lib.backup", () => {
|
|||||||
|
|
||||||
// The derived views are repaired from the model.
|
// The derived views are repaired from the model.
|
||||||
const sidecar = JSON.parse(
|
const sidecar = JSON.parse(
|
||||||
readFileSync(join(outDir, "originals", "100.json"), "utf-8"),
|
readFileSync(saved(outDir, "100.json"), "utf-8"),
|
||||||
);
|
);
|
||||||
expect(sidecar.metadata.title).toBe("beach.jpg");
|
expect(sidecar.metadata.title).toBe("beach.jpg");
|
||||||
expect(
|
expect(
|
||||||
@@ -485,8 +495,8 @@ describe("lib.backup", () => {
|
|||||||
|
|
||||||
expect(result.totalFiles).toBe(1);
|
expect(result.totalFiles).toBe(1);
|
||||||
expect(result.downloaded).toBe(1);
|
expect(result.downloaded).toBe(1);
|
||||||
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
|
expect(existsSync(saved(outDir, "200.png"))).toBe(true);
|
||||||
expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(false);
|
expect(existsSync(saved(outDir, "100.jpg"))).toBe(false);
|
||||||
expect(existsSync(join(outDir, "collections", "Work.json"))).toBe(true);
|
expect(existsSync(join(outDir, "collections", "Work.json"))).toBe(true);
|
||||||
expect(existsSync(join(outDir, "collections", "Vacation.json"))).toBe(
|
expect(existsSync(join(outDir, "collections", "Vacation.json"))).toBe(
|
||||||
false,
|
false,
|
||||||
@@ -530,7 +540,7 @@ describe("lib.backup", () => {
|
|||||||
|
|
||||||
expect(result.totalFiles).toBe(1);
|
expect(result.totalFiles).toBe(1);
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
|
expect(existsSync(saved(outDir, "200.png"))).toBe(true);
|
||||||
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
|
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
|
||||||
lib.close();
|
lib.close();
|
||||||
});
|
});
|
||||||
@@ -572,7 +582,7 @@ describe("lib.backup", () => {
|
|||||||
it("fetches each original once and writes it only into the backup", async () => {
|
it("fetches each original once and writes it only into the backup", async () => {
|
||||||
// A backup of a 500 GB account must write 500 GB, not a copy in the
|
// A backup of a 500 GB account must write 500 GB, not a copy in the
|
||||||
// cache as well: an original fetched for the backup goes straight
|
// cache as well: an original fetched for the backup goes straight
|
||||||
// into its originals/, and the cache records it there.
|
// to its save path, and the cache records it there.
|
||||||
const source = stubSource();
|
const source = stubSource();
|
||||||
const lib = await openLibrary(source);
|
const lib = await openLibrary(source);
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
@@ -582,14 +592,18 @@ describe("lib.backup", () => {
|
|||||||
expect(result.downloaded).toBe(3);
|
expect(result.downloaded).toBe(3);
|
||||||
expect(source.originalCalls).toBe(3);
|
expect(source.originalCalls).toBe(3);
|
||||||
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
|
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
|
||||||
const stored = readdirSync(join(outDir, "originals")).filter(
|
const stored = readdirSync(join(outDir, DAY)).filter(
|
||||||
(name) => !name.endsWith(".json"),
|
(name) => !name.endsWith(".json"),
|
||||||
);
|
);
|
||||||
expect(stored.sort()).toEqual(["100.jpg", "101.jpg", "200.png"]);
|
expect(stored.sort()).toEqual([
|
||||||
|
"2026-03-01.100.jpg",
|
||||||
|
"2026-03-01.101.jpg",
|
||||||
|
"2026-03-01.200.png",
|
||||||
|
]);
|
||||||
// The cache counts the backup's copy as present: reading the
|
// The cache counts the backup's copy as present: reading the
|
||||||
// original afterwards fetches nothing and answers with that copy.
|
// original afterwards fetches nothing and answers with that copy.
|
||||||
const read = await lib.photos.byID({ fileID: 100 })!.original();
|
const read = await lib.photos.byID({ fileID: 100 })!.original();
|
||||||
expect(read.path).toBe(join(outDir, "originals", "100.jpg"));
|
expect(read.path).toBe(saved(outDir, "100.jpg"));
|
||||||
expect(source.originalCalls).toBe(3);
|
expect(source.originalCalls).toBe(3);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
@@ -597,8 +611,8 @@ describe("lib.backup", () => {
|
|||||||
it("fsyncs an original copied from the cache before the rename and its directory after", async () => {
|
it("fsyncs an original copied from the cache before the rename and its directory after", async () => {
|
||||||
const lib = await openLibrary(stubSource());
|
const lib = await openLibrary(stubSource());
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
const originals = join(outDir, "originals");
|
const day = join(outDir, DAY);
|
||||||
const dest = join(originals, "100.jpg");
|
const dest = saved(outDir, "100.jpg");
|
||||||
// Only an original already in the cache is copied into the backup;
|
// Only an original already in the cache is copied into the backup;
|
||||||
// one fetched for the backup is written there by the download writer.
|
// one fetched for the backup is written there by the download writer.
|
||||||
await lib.photos.byID({ fileID: 100 })!.original();
|
await lib.photos.byID({ fileID: 100 })!.original();
|
||||||
@@ -609,29 +623,29 @@ describe("lib.backup", () => {
|
|||||||
const at = fsEvents.indexOf(`rename:${dest}`);
|
const at = fsEvents.indexOf(`rename:${dest}`);
|
||||||
expect(at).toBeGreaterThan(0);
|
expect(at).toBeGreaterThan(0);
|
||||||
expect(fsEvents[at - 1]).toMatch(
|
expect(fsEvents[at - 1]).toMatch(
|
||||||
/^sync:.*\/\.quak-backup-100\.jpg-\d+-[0-9a-z]*\.tmp$/,
|
/^sync:.*\/\.quak-backup-2026-03-01\.100\.jpg-\d+-[0-9a-z]*\.tmp$/,
|
||||||
);
|
);
|
||||||
expect(fsEvents[at + 1]).toBe(`sync:${originals}`);
|
expect(fsEvents[at + 1]).toBe(`sync:${day}`);
|
||||||
lib.close();
|
lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes temp files left by a killed backup but not those of one still running", async () => {
|
it("removes temp files left by a killed backup but not those of one still running", async () => {
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
const originals = join(outDir, "originals");
|
const day = join(outDir, DAY);
|
||||||
mkdirSync(originals, { recursive: true });
|
mkdirSync(day, { recursive: true });
|
||||||
// A child that has already exited: its process ID is not running.
|
// A child that has already exited: its process ID is not running.
|
||||||
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
|
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
|
||||||
const leftover = `.quak-backup-100.jpg-${exitedPID}-abc123.tmp`;
|
const leftover = `.quak-backup-2026-03-01.100.jpg-${exitedPID}-abc123.tmp`;
|
||||||
// This test's own process stands in for a backup running at the same
|
// This test's own process stands in for a backup running at the same
|
||||||
// time.
|
// time.
|
||||||
const inProgress = `.quak-backup-101.jpg-${process.pid}-def456.tmp`;
|
const inProgress = `.quak-backup-2026-03-01.101.jpg-${process.pid}-def456.tmp`;
|
||||||
writeFileSync(join(originals, leftover), "partial");
|
writeFileSync(join(day, leftover), "partial");
|
||||||
writeFileSync(join(originals, inProgress), "partial");
|
writeFileSync(join(day, inProgress), "partial");
|
||||||
const lib = await openLibrary(stubSource());
|
const lib = await openLibrary(stubSource());
|
||||||
|
|
||||||
await lib.backup({ downloadDirectory: outDir });
|
await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
const names = readdirSync(originals);
|
const names = readdirSync(day);
|
||||||
expect(names).not.toContain(leftover);
|
expect(names).not.toContain(leftover);
|
||||||
expect(names).toContain(inProgress);
|
expect(names).toContain(inProgress);
|
||||||
lib.close();
|
lib.close();
|
||||||
@@ -709,7 +723,7 @@ describe("the refresh before a backup", () => {
|
|||||||
const result = await backup;
|
const result = await backup;
|
||||||
|
|
||||||
expect(result.totalFiles).toBe(4);
|
expect(result.totalFiles).toBe(4);
|
||||||
expect(existsSync(join(outDir, "originals", "300.jpg"))).toBe(true);
|
expect(existsSync(saved(outDir, "300.jpg"))).toBe(true);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -728,7 +742,7 @@ describe("the refresh before a backup", () => {
|
|||||||
|
|
||||||
expect(source.originalCalls).toBe(0);
|
expect(source.originalCalls).toBe(0);
|
||||||
expect(readFileSync(ledgerPath, "utf-8")).toBe(ledgerBefore);
|
expect(readFileSync(ledgerPath, "utf-8")).toBe(ledgerBefore);
|
||||||
expect(existsSync(join(outDir, "originals"))).toBe(false);
|
expect(existsSync(join(outDir, DAY))).toBe(false);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -819,13 +833,13 @@ describe("backup album folders", () => {
|
|||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Trip (10)/",
|
"Trip (10)/",
|
||||||
"Trip (10)/IMG_0001 (1).JPG -> ../../originals/1.JPG",
|
`Trip (10)/IMG_0001 (1).JPG -> ${linkTo("1.JPG")}`,
|
||||||
"Trip (10)/IMG_0001 (2).JPG -> ../../originals/2.JPG",
|
`Trip (10)/IMG_0001 (2).JPG -> ${linkTo("2.JPG")}`,
|
||||||
"Trip (10)/img_0001 (4).jpg -> ../../originals/4.jpg",
|
`Trip (10)/img_0001 (4).jpg -> ${linkTo("4.jpg")}`,
|
||||||
"Trip (10)/other.jpg -> ../../originals/3.jpg",
|
`Trip (10)/other.jpg -> ${linkTo("3.jpg")}`,
|
||||||
"Trip (10).json",
|
"Trip (10).json",
|
||||||
"Trip (11)/",
|
"Trip (11)/",
|
||||||
"Trip (11)/other.jpg -> ../../originals/3.jpg",
|
`Trip (11)/other.jpg -> ${linkTo("3.jpg")}`,
|
||||||
"Trip (11).json",
|
"Trip (11).json",
|
||||||
]);
|
]);
|
||||||
expect(albumID(outDir, "Trip (10).json")).toBe(10);
|
expect(albumID(outDir, "Trip (10).json")).toBe(10);
|
||||||
@@ -858,14 +872,14 @@ describe("backup album folders", () => {
|
|||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Trip (10)/",
|
"Trip (10)/",
|
||||||
"Trip (10)/IMG (6) (5).JPG -> ../../originals/5.JPG",
|
`Trip (10)/IMG (6) (5).JPG -> ${linkTo("5.JPG")}`,
|
||||||
"Trip (10)/IMG (6).JPG -> ../../originals/6.JPG",
|
`Trip (10)/IMG (6).JPG -> ${linkTo("6.JPG")}`,
|
||||||
"Trip (10)/IMG (7).JPG -> ../../originals/7.JPG",
|
`Trip (10)/IMG (7).JPG -> ${linkTo("7.JPG")}`,
|
||||||
"Trip (10).json",
|
"Trip (10).json",
|
||||||
"Trip (11)/",
|
"Trip (11)/",
|
||||||
"Trip (11)/a.jpg -> ../../originals/8.jpg",
|
`Trip (11)/a.jpg -> ${linkTo("8.jpg")}`,
|
||||||
"Trip (11) (12)/",
|
"Trip (11) (12)/",
|
||||||
"Trip (11) (12)/b.jpg -> ../../originals/9.jpg",
|
`Trip (11) (12)/b.jpg -> ${linkTo("9.jpg")}`,
|
||||||
"Trip (11) (12).json",
|
"Trip (11) (12).json",
|
||||||
"Trip (11).json",
|
"Trip (11).json",
|
||||||
]);
|
]);
|
||||||
@@ -931,13 +945,13 @@ describe("backup album folders", () => {
|
|||||||
expect(scoped.failed).toBe(0);
|
expect(scoped.failed).toBe(0);
|
||||||
expect(before).toEqual([
|
expect(before).toEqual([
|
||||||
"Trip (10)/",
|
"Trip (10)/",
|
||||||
"Trip (10)/a.jpg -> ../../originals/1.jpg",
|
`Trip (10)/a.jpg -> ${linkTo("1.jpg")}`,
|
||||||
"Trip (10).json",
|
"Trip (10).json",
|
||||||
"Work/",
|
"Work/",
|
||||||
"Work/c.jpg -> ../../originals/3.jpg",
|
`Work/c.jpg -> ${linkTo("3.jpg")}`,
|
||||||
"Work.json",
|
"Work.json",
|
||||||
"trip (11)/",
|
"trip (11)/",
|
||||||
"trip (11)/b.jpg -> ../../originals/2.jpg",
|
`trip (11)/b.jpg -> ${linkTo("2.jpg")}`,
|
||||||
"trip (11).json",
|
"trip (11).json",
|
||||||
]);
|
]);
|
||||||
expect(tree(outDir)).toEqual(before);
|
expect(tree(outDir)).toEqual(before);
|
||||||
@@ -990,13 +1004,13 @@ describe("backup album folders", () => {
|
|||||||
"Mine/",
|
"Mine/",
|
||||||
"Mine/keep.txt",
|
"Mine/keep.txt",
|
||||||
"Office/",
|
"Office/",
|
||||||
"Office/a.jpg -> ../../originals/5.jpg",
|
`Office/a.jpg -> ${linkTo("5.jpg")}`,
|
||||||
"Office.json",
|
"Office.json",
|
||||||
"Trip/",
|
"Trip/",
|
||||||
"Trip/IMG_0001.JPG -> ../../originals/1.JPG",
|
`Trip/IMG_0001.JPG -> ${linkTo("1.JPG")}`,
|
||||||
"Trip/mine -> ../elsewhere",
|
"Trip/mine -> ../elsewhere",
|
||||||
"Trip/notes.txt",
|
"Trip/notes.txt",
|
||||||
"Trip/other.jpg -> ../../originals/3.jpg",
|
`Trip/other.jpg -> ${linkTo("3.jpg")}`,
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
"Work/",
|
"Work/",
|
||||||
"Work/keep.txt",
|
"Work/keep.txt",
|
||||||
@@ -1005,7 +1019,7 @@ describe("backup album folders", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// One album backed up, then a folder the user made beside it holding a
|
// One album backed up, then a folder the user made beside it holding a
|
||||||
// symlink into originals/, with `json` (if given) as its sibling JSON.
|
// symlink to an original, with `json` (if given) as its sibling JSON.
|
||||||
const backupWithUserFolder = async (
|
const backupWithUserFolder = async (
|
||||||
json: string | undefined,
|
json: string | undefined,
|
||||||
): Promise<{ outDir: string; failed: number }> => {
|
): Promise<{ outDir: string; failed: number }> => {
|
||||||
@@ -1019,10 +1033,7 @@ describe("backup album folders", () => {
|
|||||||
await runBackup(lib, { downloadDirectory: outDir });
|
await runBackup(lib, { downloadDirectory: outDir });
|
||||||
const collectionsDir = join(outDir, "collections");
|
const collectionsDir = join(outDir, "collections");
|
||||||
mkdirSync(join(collectionsDir, "Mine"));
|
mkdirSync(join(collectionsDir, "Mine"));
|
||||||
symlinkSync(
|
symlinkSync(linkTo("1.jpg"), join(collectionsDir, "Mine", "a.jpg"));
|
||||||
"../../originals/1.jpg",
|
|
||||||
join(collectionsDir, "Mine", "a.jpg"),
|
|
||||||
);
|
|
||||||
if (json !== undefined) {
|
if (json !== undefined) {
|
||||||
writeFileSync(join(collectionsDir, "Mine.json"), json);
|
writeFileSync(join(collectionsDir, "Mine.json"), json);
|
||||||
}
|
}
|
||||||
@@ -1036,9 +1047,9 @@ describe("backup album folders", () => {
|
|||||||
expect(failed).toBe(0);
|
expect(failed).toBe(0);
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Mine/",
|
"Mine/",
|
||||||
"Mine/a.jpg -> ../../originals/1.jpg",
|
`Mine/a.jpg -> ${linkTo("1.jpg")}`,
|
||||||
"Trip/",
|
"Trip/",
|
||||||
"Trip/a.jpg -> ../../originals/1.jpg",
|
`Trip/a.jpg -> ${linkTo("1.jpg")}`,
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1050,10 +1061,10 @@ describe("backup album folders", () => {
|
|||||||
expect(failed).toBe(0);
|
expect(failed).toBe(0);
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Mine/",
|
"Mine/",
|
||||||
"Mine/a.jpg -> ../../originals/1.jpg",
|
`Mine/a.jpg -> ${linkTo("1.jpg")}`,
|
||||||
"Mine.json",
|
"Mine.json",
|
||||||
"Trip/",
|
"Trip/",
|
||||||
"Trip/a.jpg -> ../../originals/1.jpg",
|
`Trip/a.jpg -> ${linkTo("1.jpg")}`,
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
@@ -1090,20 +1101,25 @@ describe("backup of live photos", () => {
|
|||||||
// What an earlier version stored for live photo 500: the ZIP under the
|
// What an earlier version stored for live photo 500: the ZIP under the
|
||||||
// image's name, and its link.
|
// image's name, and its link.
|
||||||
const earlierZIP = (outDir: string): void => {
|
const earlierZIP = (outDir: string): void => {
|
||||||
mkdirSync(join(outDir, "originals"), { recursive: true });
|
mkdirSync(join(outDir, DAY), { recursive: true });
|
||||||
mkdirSync(join(outDir, "collections", "Trip"), { recursive: true });
|
mkdirSync(join(outDir, "collections", "Trip"), { recursive: true });
|
||||||
writeFileSync(join(outDir, "originals", "500.HEIC"), livePhotoZip());
|
writeFileSync(saved(outDir, "500.HEIC"), livePhotoZip());
|
||||||
symlinkSync(
|
symlinkSync(
|
||||||
"../../originals/500.HEIC",
|
linkTo("500.HEIC"),
|
||||||
join(outDir, "collections", "Trip", "IMG_0500.HEIC"),
|
join(outDir, "collections", "Trip", "IMG_0500.HEIC"),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stored = ["500.heic", "500.json", "500.livephoto.json", "500.mov"];
|
const stored = [
|
||||||
|
"2026-03-01.500.heic",
|
||||||
|
"2026-03-01.500.json",
|
||||||
|
"2026-03-01.500.livephoto.json",
|
||||||
|
"2026-03-01.500.mov",
|
||||||
|
];
|
||||||
const linked = [
|
const linked = [
|
||||||
"Trip/",
|
"Trip/",
|
||||||
"Trip/IMG_0500.heic -> ../../originals/500.heic",
|
`Trip/IMG_0500.heic -> ${linkTo("500.heic")}`,
|
||||||
"Trip/IMG_0500.mov -> ../../originals/500.mov",
|
`Trip/IMG_0500.mov -> ${linkTo("500.mov")}`,
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1113,16 +1129,15 @@ describe("backup of live photos", () => {
|
|||||||
);
|
);
|
||||||
const lib = await open([live], new Map([[500, body]]));
|
const lib = await open([live], new Map([[500, body]]));
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
const originals = join(outDir, "originals");
|
|
||||||
|
|
||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
||||||
expect(readdirSync(originals).sort()).toEqual(stored);
|
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
|
||||||
expect(readFileSync(join(originals, "500.heic"))).toEqual(
|
expect(readFileSync(saved(outDir, "500.heic"))).toEqual(
|
||||||
Buffer.from(IMAGE),
|
Buffer.from(IMAGE),
|
||||||
);
|
);
|
||||||
expect(readFileSync(join(originals, "500.mov"))).toEqual(
|
expect(readFileSync(saved(outDir, "500.mov"))).toEqual(
|
||||||
Buffer.from(VIDEO),
|
Buffer.from(VIDEO),
|
||||||
);
|
);
|
||||||
expect(tree(outDir)).toEqual(linked);
|
expect(tree(outDir)).toEqual(linked);
|
||||||
@@ -1149,10 +1164,10 @@ describe("backup of live photos", () => {
|
|||||||
|
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Trip/",
|
"Trip/",
|
||||||
"Trip/IMG_0001 (500).heic -> ../../originals/500.heic",
|
`Trip/IMG_0001 (500).heic -> ${linkTo("500.heic")}`,
|
||||||
"Trip/IMG_0001 (500).mov -> ../../originals/500.mov",
|
`Trip/IMG_0001 (500).mov -> ${linkTo("500.mov")}`,
|
||||||
"Trip/IMG_0001 (501).heic -> ../../originals/501.heic",
|
`Trip/IMG_0001 (501).heic -> ${linkTo("501.heic")}`,
|
||||||
"Trip/IMG_0001 (501).mov -> ../../originals/501.mov",
|
`Trip/IMG_0001 (501).mov -> ${linkTo("501.mov")}`,
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
]);
|
]);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
@@ -1169,7 +1184,7 @@ describe("backup of live photos", () => {
|
|||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
||||||
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
|
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
|
||||||
expect(tree(outDir)).toEqual(linked);
|
expect(tree(outDir)).toEqual(linked);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
@@ -1189,10 +1204,10 @@ describe("backup of live photos", () => {
|
|||||||
expect(result).toMatchObject({ downloaded: 0, failed: 1 });
|
expect(result).toMatchObject({ downloaded: 0, failed: 1 });
|
||||||
expect(result.errors.map((e) => e.fileID)).toEqual([500]);
|
expect(result.errors.map((e) => e.fileID)).toEqual([500]);
|
||||||
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
|
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
|
||||||
expect(readdirSync(join(outDir, "originals"))).toEqual(["500.HEIC"]);
|
expect(readdirSync(join(outDir, DAY))).toEqual(["2026-03-01.500.HEIC"]);
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Trip/",
|
"Trip/",
|
||||||
"Trip/IMG_0500.HEIC -> ../../originals/500.HEIC",
|
`Trip/IMG_0500.HEIC -> ${linkTo("500.HEIC")}`,
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
]);
|
]);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
@@ -1209,8 +1224,8 @@ describe("backup of live photos", () => {
|
|||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
||||||
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
|
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
|
||||||
expect(readFileSync(join(outDir, "originals", "500.mov"))).toEqual(
|
expect(readFileSync(saved(outDir, "500.mov"))).toEqual(
|
||||||
Buffer.from(VIDEO),
|
Buffer.from(VIDEO),
|
||||||
);
|
);
|
||||||
expect(tree(outDir)).toEqual(linked);
|
expect(tree(outDir)).toEqual(linked);
|
||||||
@@ -1231,7 +1246,7 @@ describe("backup of live photos", () => {
|
|||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
||||||
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
|
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
|
||||||
expect(tree(outDir)).toEqual(linked);
|
expect(tree(outDir)).toEqual(linked);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
@@ -1245,7 +1260,7 @@ describe("backup of live photos", () => {
|
|||||||
const lib = await open([live], new Map([[500, body]]));
|
const lib = await open([live], new Map([[500, body]]));
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
await lib.backup({ downloadDirectory: outDir });
|
await lib.backup({ downloadDirectory: outDir });
|
||||||
const video = join(outDir, "originals", "500.mov");
|
const video = saved(outDir, "500.mov");
|
||||||
if (state === "missing") rmSync(video);
|
if (state === "missing") rmSync(video);
|
||||||
else writeFileSync(video, "");
|
else writeFileSync(video, "");
|
||||||
|
|
||||||
@@ -1271,7 +1286,7 @@ describe("backup of live photos", () => {
|
|||||||
const lib = await open([live], new Map([[500, body]]));
|
const lib = await open([live], new Map([[500, body]]));
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
await lib.backup({ downloadDirectory: outDir });
|
await lib.backup({ downloadDirectory: outDir });
|
||||||
const image = join(outDir, "originals", "500.heic");
|
const image = saved(outDir, "500.heic");
|
||||||
if (state === "missing") rmSync(image);
|
if (state === "missing") rmSync(image);
|
||||||
else writeFileSync(image, "");
|
else writeFileSync(image, "");
|
||||||
|
|
||||||
@@ -1310,8 +1325,8 @@ describe("backup of live photos", () => {
|
|||||||
const read = await reader.photos.byID({ fileID: 500 })!.original();
|
const read = await reader.photos.byID({ fileID: 500 })!.original();
|
||||||
|
|
||||||
expect(read).toEqual({
|
expect(read).toEqual({
|
||||||
path: join(outDir, "originals", "500.heic"),
|
path: saved(outDir, "500.heic"),
|
||||||
videoPath: join(outDir, "originals", "500.mov"),
|
videoPath: saved(outDir, "500.mov"),
|
||||||
bytes: IMAGE.length,
|
bytes: IMAGE.length,
|
||||||
});
|
});
|
||||||
await reader.close();
|
await reader.close();
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ describe("backup", () => {
|
|||||||
expect(code).toBe(1);
|
expect(code).toBe(1);
|
||||||
expect(runText).toBe("quak: HTTP 401 from server\n");
|
expect(runText).toBe("quak: HTTP 401 from server\n");
|
||||||
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
|
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
|
||||||
expect(existsSync(join(dir, "originals"))).toBe(false);
|
expect(existsSync(dir)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ const buildCache = (args: {
|
|||||||
pools: new RequestPools(),
|
pools: new RequestPools(),
|
||||||
source,
|
source,
|
||||||
cacheDirectory: cacheDir,
|
cacheDirectory: cacheDir,
|
||||||
|
downloadDirectory: join(root, "photos"),
|
||||||
getFile: (id) => byID.get(id),
|
getFile: (id) => byID.get(id),
|
||||||
statfs: args.statfs,
|
statfs: args.statfs,
|
||||||
cacheOriginalsMaxBytes: args.cacheOriginalsMaxBytes,
|
cacheOriginalsMaxBytes: args.cacheOriginalsMaxBytes,
|
||||||
|
|||||||
@@ -6,16 +6,18 @@
|
|||||||
* `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and
|
* `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and
|
||||||
* a cached path shows up on the projected record. A library opened without a
|
* a cached path shows up on the projected record. A library opened without a
|
||||||
* content source leaves those methods throwing rather than silently doing
|
* content source leaves those methods throwing rather than silently doing
|
||||||
* nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `content()` and
|
* nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `download()`,
|
||||||
* `exif()`.
|
* `content()` and `exif()`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
|
statSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -38,6 +40,12 @@ import {
|
|||||||
|
|
||||||
const USER_ID = 7;
|
const USER_ID = 7;
|
||||||
|
|
||||||
|
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
|
||||||
|
// stores times, so the machine's time zone cannot move it to another day; it
|
||||||
|
// is saved in the folder `DAY`.
|
||||||
|
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
|
||||||
|
const DAY = join("2026", "2026-03", "2026-03-01");
|
||||||
|
|
||||||
const collection = (id: number): Collection => ({
|
const collection = (id: number): Collection => ({
|
||||||
id,
|
id,
|
||||||
ownerID: USER_ID,
|
ownerID: USER_ID,
|
||||||
@@ -56,7 +64,7 @@ const file = (id: number, collectionID: number): EnteFile => ({
|
|||||||
metadata: {
|
metadata: {
|
||||||
title: `file-${id}.jpg`,
|
title: `file-${id}.jpg`,
|
||||||
fileType: "image",
|
fileType: "image",
|
||||||
creationTime: 1,
|
creationTime: TAKEN,
|
||||||
modificationTime: 1,
|
modificationTime: 1,
|
||||||
},
|
},
|
||||||
file: { decryptionHeader: "aGVhZGVy" },
|
file: { decryptionHeader: "aGVhZGVy" },
|
||||||
@@ -224,6 +232,9 @@ describe("Library content wiring", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
lib.photos.byID({ fileID: 1 })!.thumbnail(),
|
lib.photos.byID({ fileID: 1 })!.thumbnail(),
|
||||||
).rejects.toThrow(/content cache/i);
|
).rejects.toThrow(/content cache/i);
|
||||||
|
await expect(
|
||||||
|
lib.photos.byID({ fileID: 1 })!.download(),
|
||||||
|
).rejects.toThrow(/content cache/i);
|
||||||
await expect(
|
await expect(
|
||||||
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
|
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
|
||||||
).rejects.toThrow(/content cache/i);
|
).rejects.toThrow(/content cache/i);
|
||||||
@@ -341,7 +352,7 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
|||||||
it("names where a backup writes the original, which is local once the backup has written it", async () => {
|
it("names where a backup writes the original, which is local once the backup has written it", async () => {
|
||||||
const lib = await open();
|
const lib = await open();
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
const savePath = join(root, "backup", "originals", "1.jpg");
|
const savePath = join(root, "backup", DAY, "2026-03-01.1.jpg");
|
||||||
expect(photo.savePath).toBe(savePath);
|
expect(photo.savePath).toBe(savePath);
|
||||||
expect(photo.isLocal).toBe(false);
|
expect(photo.isLocal).toBe(false);
|
||||||
|
|
||||||
@@ -359,23 +370,30 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
|||||||
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
|
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(existsSync(photo.savePath!)).toBe(false);
|
expect(existsSync(photo.savePath)).toBe(false);
|
||||||
expect(photo.isLocal).toBe(false);
|
expect(photo.isLocal).toBe(false);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has no save path and is not local without a download directory", async () => {
|
it("saves under photos/ in the working directory at open without a download directory", async () => {
|
||||||
|
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
||||||
const lib = await open({ downloadDirectory: undefined });
|
const lib = await open({ downloadDirectory: undefined });
|
||||||
|
cwd.mockRestore();
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
expect(photo.savePath).toBeUndefined();
|
expect(lib.downloadDirectory).toBe(join(root, "photos"));
|
||||||
|
expect(photo.savePath).toBe(
|
||||||
|
join(root, "photos", DAY, "2026-03-01.1.jpg"),
|
||||||
|
);
|
||||||
expect(photo.isLocal).toBe(false);
|
expect(photo.isLocal).toBe(false);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has no save path and is not local without a content source", async () => {
|
it("has a save path, and is not local, without a content source", async () => {
|
||||||
const lib = await open({ contentSource: undefined });
|
const lib = await open({ contentSource: undefined });
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
expect(photo.savePath).toBeUndefined();
|
expect(photo.savePath).toBe(
|
||||||
|
join(root, "backup", DAY, "2026-03-01.1.jpg"),
|
||||||
|
);
|
||||||
expect(photo.isLocal).toBe(false);
|
expect(photo.isLocal).toBe(false);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
@@ -387,18 +405,104 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
|||||||
contentSource: cdnSource(new Map([[1, body]])),
|
contentSource: cdnSource(new Map([[1, body]])),
|
||||||
});
|
});
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
const originals = join(root, "backup", "originals");
|
const day = join(root, "backup", DAY);
|
||||||
// Until then the name comes from the title, file-1.jpg; the backup
|
// Until then the name comes from the title, file-1.jpg; the backup
|
||||||
// stores the image with the extension found inside the live photo.
|
// stores the image with the extension found inside the live photo.
|
||||||
expect(photo.savePath).toBe(join(originals, "1.jpg"));
|
expect(photo.savePath).toBe(join(day, "2026-03-01.1.jpg"));
|
||||||
|
|
||||||
await lib.backup();
|
await lib.backup();
|
||||||
expect(photo.savePath).toBe(join(originals, "1.heic"));
|
expect(photo.savePath).toBe(join(day, "2026-03-01.1.heic"));
|
||||||
expect(photo.isLocal).toBe(true);
|
expect(photo.isLocal).toBe(true);
|
||||||
expect(await photo.content()).toEqual(Buffer.from(IMAGE));
|
expect(await photo.content()).toEqual(Buffer.from(IMAGE));
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("downloads an original the cache holds by copying it, without fetching it again", async () => {
|
||||||
|
const source = stubSource();
|
||||||
|
const lib = await open({ contentSource: source });
|
||||||
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
|
await photo.original();
|
||||||
|
expect(source.originalCalls()).toBe(1);
|
||||||
|
expect(photo.isLocal).toBe(false);
|
||||||
|
|
||||||
|
const saved = await photo.download();
|
||||||
|
|
||||||
|
expect(source.originalCalls()).toBe(1);
|
||||||
|
expect(saved).toEqual({
|
||||||
|
path: photo.savePath,
|
||||||
|
bytes: "orig-bytes".length,
|
||||||
|
});
|
||||||
|
expect(readFileSync(photo.savePath, "utf-8")).toBe("orig-bytes");
|
||||||
|
expect(photo.isLocal).toBe(true);
|
||||||
|
// The cache keeps its own copy.
|
||||||
|
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downloads an original the cache does not hold straight to its save path", async () => {
|
||||||
|
const source = stubSource();
|
||||||
|
const lib = await open({ contentSource: source });
|
||||||
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
|
|
||||||
|
const saved = await photo.download();
|
||||||
|
|
||||||
|
expect(source.originalCalls()).toBe(1);
|
||||||
|
expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg"));
|
||||||
|
expect(readFileSync(saved.path, "utf-8")).toBe("orig-bytes");
|
||||||
|
expect(photo.isLocal).toBe(true);
|
||||||
|
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when download() finds the original already at its save path", async () => {
|
||||||
|
const source = stubSource();
|
||||||
|
const lib = await open({ contentSource: source });
|
||||||
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
|
const first = await photo.download();
|
||||||
|
const written = statSync(first.path).ino;
|
||||||
|
|
||||||
|
const second = await photo.download();
|
||||||
|
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(source.originalCalls()).toBe(1);
|
||||||
|
expect(statSync(second.path).ino).toBe(written);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downloads a live photo the cache holds as its image, its video and the JSON file naming them", async () => {
|
||||||
|
const { file: live, body } = await asLivePhoto(file(1, 1));
|
||||||
|
const lib = await open({
|
||||||
|
client: new FilesClient([live]),
|
||||||
|
contentSource: cdnSource(new Map([[1, body]])),
|
||||||
|
});
|
||||||
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
|
await photo.original();
|
||||||
|
const day = join(root, "backup", DAY);
|
||||||
|
|
||||||
|
const saved = await photo.download();
|
||||||
|
|
||||||
|
expect(saved).toEqual({
|
||||||
|
path: join(day, "2026-03-01.1.heic"),
|
||||||
|
videoPath: join(day, "2026-03-01.1.mov"),
|
||||||
|
bytes: IMAGE.length,
|
||||||
|
});
|
||||||
|
expect(readdirSync(day).sort()).toEqual([
|
||||||
|
"2026-03-01.1.heic",
|
||||||
|
"2026-03-01.1.livephoto.json",
|
||||||
|
"2026-03-01.1.mov",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
JSON.parse(
|
||||||
|
readFileSync(join(day, "2026-03-01.1.livephoto.json"), "utf-8"),
|
||||||
|
),
|
||||||
|
).toEqual({ image: "2026-03-01.1.heic", video: "2026-03-01.1.mov" });
|
||||||
|
expect(photo.savePath).toBe(saved.path);
|
||||||
|
expect(photo.isLocal).toBe(true);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("reads the common EXIF fields of a JPEG original", async () => {
|
it("reads the common EXIF fields of a JPEG original", async () => {
|
||||||
const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) });
|
const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) });
|
||||||
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({
|
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
* 1. **Fetch once, then serve from disk.** The first `original`/`thumbnail`
|
* 1. **Fetch once, then serve from disk.** The first `original`/`thumbnail`
|
||||||
* fetches through the request pool and stores the bytes; the next finds the
|
* fetches through the request pool and stores the bytes; the next finds the
|
||||||
* file present and returns its path with a single `skipped` event and no
|
* file present and returns its path with a single `skipped` event and no
|
||||||
* network. A file already sitting in the backup `downloadDirectory` counts
|
* network. A file already stored at its save path under the
|
||||||
* as present too.
|
* `downloadDirectory` counts as present too.
|
||||||
* 2. **Present-means-complete.** Content appears only by the streaming atomic
|
* 2. **Present-means-complete.** Content appears only by the streaming atomic
|
||||||
* writer's rename, so a file that exists is whole. The directory listing
|
* writer's rename, so a file that exists is whole. The directory listing
|
||||||
* taken at `open()` is the record of what is cached, and the orphan temp
|
* taken at `open()` is the record of what is cached, and the orphan temp
|
||||||
@@ -41,6 +41,7 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ContentCache,
|
ContentCache,
|
||||||
|
savePath,
|
||||||
type ContentSource,
|
type ContentSource,
|
||||||
type EnsureEvent,
|
type EnsureEvent,
|
||||||
} from "../../src/library/content.js";
|
} from "../../src/library/content.js";
|
||||||
@@ -152,12 +153,48 @@ const buildCache = (
|
|||||||
pools: args.pools ?? new RequestPools(),
|
pools: args.pools ?? new RequestPools(),
|
||||||
source,
|
source,
|
||||||
cacheDirectory: cacheDir,
|
cacheDirectory: cacheDir,
|
||||||
downloadDirectory: args.downloadDirectory,
|
downloadDirectory: args.downloadDirectory ?? join(root, "photos"),
|
||||||
getFile: (id) => byID.get(id),
|
getFile: (id) => byID.get(id),
|
||||||
});
|
});
|
||||||
return { cache, source };
|
return { cache, source };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Microseconds, as Ente stores times, for noon local time on a day, so the
|
||||||
|
// machine's time zone cannot move the photo to another day.
|
||||||
|
const noon = (year: number, month: number, day: number): number =>
|
||||||
|
new Date(year, month - 1, day, 12).getTime() * 1000;
|
||||||
|
|
||||||
|
describe("savePath", () => {
|
||||||
|
it("files an original by year, month and day under the root", () => {
|
||||||
|
const f = file(12345, "IMG_0001.HEIC");
|
||||||
|
f.metadata.creationTime = noon(2026, 3, 1);
|
||||||
|
|
||||||
|
expect(savePath("/photos", f)).toBe(
|
||||||
|
"/photos/2026/2026-03/2026-03-01/2026-03-01.12345.HEIC",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dates an original by the date the user set, when there is one", () => {
|
||||||
|
const f = file(7, "a.jpg");
|
||||||
|
f.metadata.creationTime = noon(2026, 3, 1);
|
||||||
|
f.pubMagicMetadata = { editedTime: noon(1999, 12, 31) };
|
||||||
|
|
||||||
|
expect(savePath("/photos", f)).toBe(
|
||||||
|
"/photos/1999/1999-12/1999-12-31/1999-12-31.7.jpg",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the extension from the title it was uploaded with, not a new name", () => {
|
||||||
|
const f = file(8, "upload");
|
||||||
|
f.metadata.creationTime = noon(2026, 3, 1);
|
||||||
|
f.pubMagicMetadata = { editedName: "renamed.png" };
|
||||||
|
|
||||||
|
expect(savePath("/photos", f)).toBe(
|
||||||
|
"/photos/2026/2026-03/2026-03-01/2026-03-01.8.bin",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ContentCache.open", () => {
|
describe("ContentCache.open", () => {
|
||||||
it("creates the cache directories with 0700 permissions", async () => {
|
it("creates the cache directories with 0700 permissions", async () => {
|
||||||
const { cache } = buildCache();
|
const { cache } = buildCache();
|
||||||
@@ -274,11 +311,17 @@ describe("ContentCache.original / thumbnail", () => {
|
|||||||
|
|
||||||
it("serves a file already present in the download directory without fetching", async () => {
|
it("serves a file already present in the download directory without fetching", async () => {
|
||||||
const downloadDirectory = join(root, "backup");
|
const downloadDirectory = join(root, "backup");
|
||||||
mkdirSync(join(downloadDirectory, "originals"), { recursive: true });
|
const day = join(downloadDirectory, "2026", "2026-03", "2026-03-01");
|
||||||
const backupPath = join(downloadDirectory, "originals", "1.jpg");
|
mkdirSync(day, { recursive: true });
|
||||||
|
const backupPath = join(day, "2026-03-01.1.jpg");
|
||||||
writeFileSync(backupPath, "from-backup");
|
writeFileSync(backupPath, "from-backup");
|
||||||
|
const f = file(1);
|
||||||
|
f.metadata.creationTime = noon(2026, 3, 1);
|
||||||
|
|
||||||
const { cache, source } = buildCache({ downloadDirectory });
|
const { cache, source } = buildCache({
|
||||||
|
downloadDirectory,
|
||||||
|
files: [f],
|
||||||
|
});
|
||||||
await cache.open();
|
await cache.open();
|
||||||
|
|
||||||
const events: EnsureEvent["status"][] = [];
|
const events: EnsureEvent["status"][] = [];
|
||||||
@@ -649,6 +692,7 @@ describe("ContentCache live photos", () => {
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
cacheDirectory: cacheDir,
|
cacheDirectory: cacheDir,
|
||||||
|
downloadDirectory: join(root, "photos"),
|
||||||
getFile: (id) => [a.file, b.file].find((f) => f.id === id),
|
getFile: (id) => [a.file, b.file].find((f) => f.id === id),
|
||||||
// Room for one live photo, on a disk with plenty free.
|
// Room for one live photo, on a disk with plenty free.
|
||||||
cacheOriginalsMaxBytes: size,
|
cacheOriginalsMaxBytes: size,
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ describe("Precache eviction integration", () => {
|
|||||||
pools: new RequestPools(),
|
pools: new RequestPools(),
|
||||||
source,
|
source,
|
||||||
cacheDirectory: cacheDir,
|
cacheDirectory: cacheDir,
|
||||||
|
downloadDirectory: join(root, "photos"),
|
||||||
getFile: (id) => byID.get(id),
|
getFile: (id) => byID.get(id),
|
||||||
statfs,
|
statfs,
|
||||||
cacheOriginalsMaxBytes: 25, // holds two 10-byte originals
|
cacheOriginalsMaxBytes: 25, // holds two 10-byte originals
|
||||||
@@ -348,6 +349,7 @@ describe("Precache preemption", () => {
|
|||||||
pools: new RequestPools({ contentConcurrency: 1 }),
|
pools: new RequestPools({ contentConcurrency: 1 }),
|
||||||
source,
|
source,
|
||||||
cacheDirectory: join(root, "cache"),
|
cacheDirectory: join(root, "cache"),
|
||||||
|
downloadDirectory: join(root, "photos"),
|
||||||
getFile: (id) => byID.get(id),
|
getFile: (id) => byID.get(id),
|
||||||
statfs: async () => ({ bsize: 1, bavail: 1_000_000_000 }),
|
statfs: async () => ({ bsize: 1, bavail: 1_000_000_000 }),
|
||||||
freeBelowBytes: 0,
|
freeBelowBytes: 0,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
makeAlbumsAPI,
|
makeAlbumsAPI,
|
||||||
makePhotosAPI,
|
makePhotosAPI,
|
||||||
makeTimelineAPI,
|
makeTimelineAPI,
|
||||||
|
type SavePathLookup,
|
||||||
type TimelineGroup,
|
type TimelineGroup,
|
||||||
} from "../../src/library/read.js";
|
} from "../../src/library/read.js";
|
||||||
import { Library } from "../../src/library/index.js";
|
import { Library } from "../../src/library/index.js";
|
||||||
@@ -101,12 +102,19 @@ const file = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build the three API objects over one fixed projection, the way `Library`
|
// Build the three API objects over one fixed projection, the way `Library`
|
||||||
// wires them over its live store.
|
// wires them over its live store. Save paths are covered in
|
||||||
|
// content-library.test.ts; these tests never ask for one.
|
||||||
const apis = (records: DerivedRecords) => {
|
const apis = (records: DerivedRecords) => {
|
||||||
const derive = () => records;
|
const derive = () => records;
|
||||||
|
const saves: SavePathLookup = {
|
||||||
|
savePath: () => {
|
||||||
|
throw new Error("no save paths in these tests");
|
||||||
|
},
|
||||||
|
isLocal: () => false,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
albums: makeAlbumsAPI(derive),
|
albums: makeAlbumsAPI(derive, saves),
|
||||||
photos: makePhotosAPI(derive),
|
photos: makePhotosAPI(derive, saves),
|
||||||
timeline: makeTimelineAPI(derive),
|
timeline: makeTimelineAPI(derive),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user