Compare commits
1
Commits
next
..
dbc7338f50
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbc7338f50 |
@@ -38,47 +38,34 @@ yarn quak get 67890 --out ./photo.jpg
|
|||||||
yarn quak backup ./my-backup
|
yarn quak backup ./my-backup
|
||||||
```
|
```
|
||||||
|
|
||||||
For library use, the primary surface is the cache-backed `Library`:
|
For library use:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { Client, Library } from "quak";
|
import { Client } from "quak";
|
||||||
|
|
||||||
// Log in once; the client satisfies the library's client interface.
|
|
||||||
const client = await Client.login({
|
const client = await Client.login({
|
||||||
email: "you@example.com",
|
email: "you@example.com",
|
||||||
password: "your-password",
|
password: "your-password",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Open a cache-backed library. On an empty cache this awaits one server
|
for (const c of await client.listCollections()) {
|
||||||
// refresh; on an existing cache it returns immediately and refreshes in the
|
console.log(c.id, c.name);
|
||||||
// background every `refreshIntervalSeconds` (default 3).
|
const files = await client.listFiles(c.id, c.key);
|
||||||
const lib = await Library.open({ client });
|
for (const f of files) {
|
||||||
|
console.log(` ${f.metadata.title} [${f.metadata.fileType}]`);
|
||||||
// Default reads answer synchronously from the local cache — no network.
|
|
||||||
for (const album of lib.albums.list()) {
|
|
||||||
console.log(album.collectionID, album.name);
|
|
||||||
for (const photo of album.photos.list()) {
|
|
||||||
console.log(` ${photo.title} [${photo.fileType}]`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh reads await a server round-trip and answer with current state.
|
// Download a file
|
||||||
const { albums } = await lib.fresh();
|
const files = await client.listFiles(collectionID, collectionKey);
|
||||||
console.log(`${albums.list().length} albums as of now`);
|
await client.downloadFile(files[0], "./photo.jpg");
|
||||||
|
|
||||||
// Fetch (and cache) one photo's full-resolution bytes.
|
// Serialize session for later (consumer handles persistence)
|
||||||
const photo = lib.photos.byID({ fileID: 12345 });
|
const snapshot = client.toJSON();
|
||||||
if (photo) {
|
// ... later:
|
||||||
const { path } = await photo.original();
|
const restored = Client.fromJSON(snapshot);
|
||||||
console.log(`original at ${path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lib.close();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The lower-level `Client` (login, session serialization, and the raw
|
|
||||||
enumeration/download calls) is exported too and documented under Design below.
|
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|
||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
@@ -442,22 +429,18 @@ quak files --collection <id> [--json] list files in a collection
|
|||||||
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
||||||
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
|
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
|
||||||
quak backup <dir> [--json] full incremental backup
|
quak backup <dir> [--json] full incremental backup
|
||||||
quak backup-metadata <dir> [--exif] dump all decrypted metadata as JSON
|
|
||||||
quak helper list-missing-thumbnails [--json] find files with missing thumbnails
|
quak helper list-missing-thumbnails [--json] find files with missing thumbnails
|
||||||
quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbnails
|
quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbnails
|
||||||
```
|
```
|
||||||
|
|
||||||
Every command runs on the same cache-backed library. The read commands —
|
Every command reads through the local library cache: the first run fetches the
|
||||||
`collections`, `files`, `get`, and `get-thumb` — force a fresh server round-trip
|
account's metadata from the server, and later runs serve from the cache and
|
||||||
before they answer, so they report current account state rather than whatever
|
refresh in the background. `--cache-dir` overrides where that cache lives;
|
||||||
the cache last held. `--cache-dir` overrides where the cache lives; without it
|
without it each account gets its own directory under the per-user cache path.
|
||||||
each account gets its own directory under the per-user cache path.
|
|
||||||
|
|
||||||
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is
|
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is
|
||||||
accepted for backward compatibility but ignored. `backup-metadata --exif` (alias
|
accepted for backward compatibility but ignored. All listing and backup commands
|
||||||
`--all`) additionally downloads each file to extract full EXIF/IPTC/XMP
|
support `--json` for machine-readable output.
|
||||||
metadata. The listing and backup commands support `--json` for machine-readable
|
|
||||||
output.
|
|
||||||
|
|
||||||
`helper fix-missing-thumbnails` regenerates thumbnails for baseline JPEG images
|
`helper fix-missing-thumbnails` regenerates thumbnails for baseline JPEG images
|
||||||
only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG
|
only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG
|
||||||
@@ -489,7 +472,7 @@ code is non-zero if any files failed.
|
|||||||
|
|
||||||
- [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
- [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
||||||
errors
|
errors
|
||||||
- [x] Update the API reference section below to match the current implementation
|
- [ ] Update the API reference section below to match the current implementation
|
||||||
- [x] `make docker` green
|
- [x] `make docker` green
|
||||||
- [ ] Tag `v1.0.0`
|
- [ ] Tag `v1.0.0`
|
||||||
|
|
||||||
@@ -503,179 +486,25 @@ Future (desktop client, separate repo):
|
|||||||
|
|
||||||
## API reference
|
## API reference
|
||||||
|
|
||||||
The library's primary surface is the cache-backed `Library`; the lower-level
|
The API reference section below is from an earlier draft and does not fully
|
||||||
`Client` sits underneath it and is covered by the Design sections above. The
|
reflect the current implementation. The authoritative API documentation is in
|
||||||
test suite is the canonical, executable documentation — `test/library/` and
|
the test files, particularly `test/client/usage.test.ts` which is a literate
|
||||||
`test/client/usage.test.ts` walk every operation, and `yarn test` verifies them.
|
tutorial walking through every operation. Run `yarn test` to verify the examples
|
||||||
|
are correct.
|
||||||
|
|
||||||
### Opening a library
|
The key types and their actual signatures can be found in:
|
||||||
|
|
||||||
`Library.open(options)` loads the on-disk cache, starts the background refresh
|
|
||||||
loop, and resolves to a `Library`. On an empty cache it awaits the first refresh
|
|
||||||
so it never opens onto empty data; on an existing cache it returns immediately
|
|
||||||
and refreshes in the background, so an unreachable server does not block
|
|
||||||
opening.
|
|
||||||
|
|
||||||
`LibraryOptions`:
|
|
||||||
|
|
||||||
| Option | Default | Meaning |
|
|
||||||
| ------------------------ | --------------------------- | --------------------------------------------------------------------- |
|
|
||||||
| `client` | required | the account client (a `Client`, or any `LibraryClient`) |
|
|
||||||
| `cacheDirectory` | `<XDG cache>/quak/<userID>` | where `metadata.json` and the content cache live |
|
|
||||||
| `downloadDirectory` | none | backup destination; an original already stored there counts as cached |
|
|
||||||
| `refreshIntervalSeconds` | `3` | background refresh cadence |
|
|
||||||
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
|
|
||||||
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
|
|
||||||
| `precacheOriginalsDays` | `7` | length in days of that latest window |
|
|
||||||
| `cacheOriginalsMaxBytes` | 100 GiB | hard ceiling on the originals cache |
|
|
||||||
| `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 |
|
|
||||||
| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) |
|
|
||||||
| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) |
|
|
||||||
| `contentSource` | the client's own | override the byte source (mainly for tests) |
|
|
||||||
|
|
||||||
Concurrency is set through `pools`: construct
|
|
||||||
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
|
||||||
and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
|
|
||||||
|
|
||||||
`lib.status()` returns a `LibraryStatus` (collection/file counts, last
|
|
||||||
refresh/ML times and errors, originals usage and effective limit, precache
|
|
||||||
progress, and `closed`). `lib.close()` stops the background timer; it is
|
|
||||||
idempotent, and an in-flight refresh is left to finish.
|
|
||||||
|
|
||||||
### Default reads vs. fresh reads
|
|
||||||
|
|
||||||
Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer
|
|
||||||
synchronously from the last refreshed copy held in RAM and never touch the
|
|
||||||
network. The background timer refreshes that copy every
|
|
||||||
`refreshIntervalSeconds`, so a default read is immediate but may be up to one
|
|
||||||
interval stale.
|
|
||||||
|
|
||||||
`await lib.fresh()` forces a refresh, waits for it to complete and persist, and
|
|
||||||
returns the same `{ albums, photos, timeline }` namespaces — now guaranteed to
|
|
||||||
reflect a completed server round-trip. Concurrent `fresh()` calls coalesce onto
|
|
||||||
one refresh, and a refresh that fails rejects the caller (default reads stay
|
|
||||||
silent and keep serving the last good copy). The CLI's read commands use fresh
|
|
||||||
reads (issue https://git.eeqj.de/sneak/quak/issues/75).
|
|
||||||
|
|
||||||
### Read surface
|
|
||||||
|
|
||||||
- `lib.albums.list()` → `Album[]`, newest-updated first.
|
|
||||||
`lib.albums.byID({ collectionID })` and `byName({ albumName })` →
|
|
||||||
`Album | undefined`.
|
|
||||||
- `lib.photos.byID({ fileID })` → `Photo | undefined`.
|
|
||||||
`lib.photos.records({ fileIDs })` → `PhotoRecord[]` in the requested order,
|
|
||||||
each id once, unknown ids dropped.
|
|
||||||
- `lib.timeline.groups({ groupBy, filter? })` → `TimelineGroup[]`, grouped by
|
|
||||||
`"day" | "week" | "month"` (keys `YYYY-MM-DD`, ISO `YYYY-Www`, `YYYY-MM`),
|
|
||||||
newest group first. A `PhotoFilter` combines `albumID`, `text`
|
|
||||||
(title/caption/album-name substring), `fileTypes`, `hasLocation`, and
|
|
||||||
`includeArchived`; hidden photos are always excluded.
|
|
||||||
|
|
||||||
An `Album` exposes its record fields and `album.photos.list()` → `Photo[]`
|
|
||||||
(newest first). A `Photo` exposes its record fields, `photo.record()` →
|
|
||||||
`PhotoRecord`, and two content methods:
|
|
||||||
|
|
||||||
- `await photo.original(opts?)` → `{ path, bytes }` — the full-resolution file.
|
|
||||||
- `await photo.thumbnail(opts?)` → `{ path, bytes }`.
|
|
||||||
|
|
||||||
Both serve from the on-disk content cache when the bytes are present and
|
|
||||||
otherwise fetch through the pools; `opts.onProgress` reports per-file progress.
|
|
||||||
They throw when the library was opened without a content source.
|
|
||||||
|
|
||||||
Lower-level accessors that return decrypted model objects (which hold key
|
|
||||||
material) are also available: `listCollections()`, `getCollection(id)`,
|
|
||||||
`listFiles(collectionID)`, `getFile(collectionID, fileID)`, and
|
|
||||||
`getFileByID(fileID)`.
|
|
||||||
|
|
||||||
### Records and change notifications
|
|
||||||
|
|
||||||
The GUI-facing records hold no key material and no binary, so they survive
|
|
||||||
`structuredClone`/JSON across the Electron IPC boundary:
|
|
||||||
|
|
||||||
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` (milliseconds),
|
|
||||||
`fileType`, optional `caption` / `width` / `height` / `latitude` /
|
|
||||||
`longitude`, `isArchived`, `isHidden`, and `thumbnailPath` / `originalPath`
|
|
||||||
once the bytes are cached.
|
|
||||||
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
|
|
||||||
`fileIDs` (newest first).
|
|
||||||
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
|
|
||||||
|
|
||||||
`lib.snapshot()` returns a `LibrarySnapshot` (albums newest-updated first,
|
|
||||||
photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
|
|
||||||
(`albumsChanged`, `photosChanged`, `fileIDsRemoved`, `albumIDsRemoved`,
|
|
||||||
`refreshedAt`) whenever a refresh alters the projection, and returns
|
|
||||||
`{ unsubscribe }`; a refresh that changes nothing delivers nothing.
|
|
||||||
|
|
||||||
### Thumbnails, ML search, and backup
|
|
||||||
|
|
||||||
- `lib.thumbnails.ensure({ fileIDs, priority, signal?, onProgress? })`
|
|
||||||
prefetches thumbnails through the thumbnail pool, deduped by fileID, returning
|
|
||||||
one `EnsureResult` (`{ fileID, path?, error? }`) per file. `priority` is
|
|
||||||
`"visible" | "ahead" | "background"`; only `"visible"` preempts background
|
|
||||||
work.
|
|
||||||
- `lib.mldata` searches the CLIP index built from Ente's per-file ML data:
|
|
||||||
`forFile({ fileID })` → `Promise<MLData | undefined>` (the whole stored
|
|
||||||
payload — face boxes, landmarks, embedding — read from disk on demand);
|
|
||||||
`similar({ fileID, limit? })` and `searchByEmbedding({ embedding, limit? })` →
|
|
||||||
`SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first,
|
|
||||||
default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes
|
|
||||||
a query vector the caller produced elsewhere.
|
|
||||||
- `await lib.backup(opts?)` → `BackupResult`. It refreshes, fetches every
|
|
||||||
in-scope original (and, with `includeThumbnails`, thumbnails) through the
|
|
||||||
content cache, and rebuilds the on-disk backup tree with a durable failure
|
|
||||||
ledger. `BackupOptions`: `downloadDirectory` (falls back to the one `open()`
|
|
||||||
was given), `includeOriginals` (default `true`), `includeThumbnails` (default
|
|
||||||
`false`), `onlyAlbumNames`, and `onProgress`. See Backup layout above for the
|
|
||||||
tree it writes.
|
|
||||||
|
|
||||||
### Request pools
|
|
||||||
|
|
||||||
`RequestPools` holds three independent bounded pools — metadata (10), content
|
|
||||||
(5), thumbnails (25) — because Ente meters these traffic classes differently.
|
|
||||||
Each pool orders on-demand work ahead of background/precache work and dedups
|
|
||||||
in-flight fetches by key, and an idle pool never lends its slots to a busy one.
|
|
||||||
|
|
||||||
### On-disk cache layout
|
|
||||||
|
|
||||||
Under `cacheDirectory`:
|
|
||||||
|
|
||||||
```
|
|
||||||
<cacheDirectory>/
|
|
||||||
metadata.json decrypted account state + refresh cursor
|
|
||||||
originals/<fileID>.<ext> cached full-resolution files
|
|
||||||
thumbnails/<fileID>.jpg cached thumbnails
|
|
||||||
mldata/
|
|
||||||
<fileID>.json one decrypted ML payload per file
|
|
||||||
clip.f32, clip.json the packed CLIP index and its id list
|
|
||||||
fetched.json per-file fetch bookkeeping
|
|
||||||
```
|
|
||||||
|
|
||||||
A stored file appears only via an atomic temp-then-rename, so its presence means
|
|
||||||
it is complete. The design also calls for a content-hash comparison against
|
|
||||||
`FileMetadata.hash` on each fetched original; that check is deferred (issue
|
|
||||||
https://git.eeqj.de/sneak/quak/issues/68) because the exact hash construction
|
|
||||||
cannot yet be confirmed against the repo's fixtures.
|
|
||||||
|
|
||||||
### Key types by source file
|
|
||||||
|
|
||||||
- `src/library/index.ts`: `Library`, `LibraryOptions`, `LibraryStatus`,
|
|
||||||
`LibraryClient`, `RefreshEvent`
|
|
||||||
- `src/library/read.ts`: `Album`, `Photo`, `AlbumsAPI`, `PhotosAPI`,
|
|
||||||
`TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy`
|
|
||||||
- `src/library/content.ts`: `ContentResult`, `ContentOptions`, `ThumbnailsAPI`,
|
|
||||||
`EnsureOptions`, `EnsureResult`, `ContentSource`
|
|
||||||
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
|
|
||||||
`LibraryChange`
|
|
||||||
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
|
|
||||||
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
|
|
||||||
- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
|
|
||||||
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
|
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
|
||||||
- `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `StreamOptions`
|
- `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `ApiError`,
|
||||||
|
`StreamOptions`
|
||||||
- `src/errors.ts`: `ApiError`, `TruncatedStreamError`
|
- `src/errors.ts`: `ApiError`, `TruncatedStreamError`
|
||||||
- `src/retry.ts`: `withRetry`, `isRetryable`, `isSafeToReplay`, `RetryOptions`
|
- `src/retry.ts`: `withRetry`, `isRetryable`, `isSafeToReplay`, `RetryOptions`
|
||||||
- `src/model/types.ts`: `Collection`, `EnteFile`, `FileMetadata`, `FileType`,
|
- `src/auth/types.ts`: `KeyAttributes`, `SRPAttributes`,
|
||||||
`CollectionType`, `RawCollection`, `RawEnteFile`
|
`AuthorizationResponse`, `LoginChallenge`
|
||||||
|
- `src/model/types.ts`: `Collection`, `EnteFile`, `FileMetadata`, `FileBlob`,
|
||||||
|
`RawCollection`, `RawEnteFile`, `RawMagicMetadata`
|
||||||
|
- `src/download/index.ts`: `DownloadResult`
|
||||||
|
- `src/backup.ts`: `BackupResult`, `BackupError`
|
||||||
- `src/thumbnails.ts`: `MissingThumbnailInfo`, `ThumbnailFixResult`
|
- `src/thumbnails.ts`: `MissingThumbnailInfo`, `ThumbnailFixResult`
|
||||||
|
|
||||||
## Source attribution
|
## Source attribution
|
||||||
|
|||||||
@@ -14,19 +14,10 @@ pre-1.0
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Tag v1.0.0.
|
Update the README API reference section to match the current implementation.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-09-22: Rewrote the README API reference (and the Getting Started / usage
|
|
||||||
snippets) to match the shipped cache/API library on `next` (issue 53, issue
|
|
||||||
13). Documented `Library.open` and its options, the default-read vs `fresh()`
|
|
||||||
distinction (and that the CLI's read commands are fresh), the record types and
|
|
||||||
`snapshot()`/`subscribe()`, the `albums`/`photos`/`timeline` read surface,
|
|
||||||
`Photo` content methods, `thumbnails.ensure`, the `mldata` search surface,
|
|
||||||
`backup()`, the three request pools (10/5/25), and the on-disk cache layout;
|
|
||||||
noted the deferred content-hash integrity check (issue 68). Docs-only; no code
|
|
||||||
changed.
|
|
||||||
- 2026-09-22: Added resumable, deletion-aware enumeration to `Client` (issue 38,
|
- 2026-09-22: Added resumable, deletion-aware enumeration to `Client` (issue 38,
|
||||||
closes issue 7). `collectionsSince`/`filesSince` take a starting cursor,
|
closes issue 7). `collectionsSince`/`filesSince` take a starting cursor,
|
||||||
decrypt live records, surface tombstoned ids in a separate `deleted` list (a
|
decrypt live records, surface tombstoned ids in a separate `deleted` list (a
|
||||||
@@ -131,6 +122,7 @@ Tag v1.0.0.
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
|
- Tag v1.0.0.
|
||||||
- Future desktop client, separate repo:
|
- Future desktop client, separate repo:
|
||||||
- Electron app skeleton consuming this library.
|
- Electron app skeleton consuming this library.
|
||||||
- Local SQLite cache keyed on (collectionID, fileID, updationTime).
|
- Local SQLite cache keyed on (collectionID, fileID, updationTime).
|
||||||
|
|||||||
Reference in New Issue
Block a user