Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ No composition file to update — each class self-instantiates its deps. `main.t
### 1. Middleware Pipeline (server.ts)

```typescript
app.use(bodyParser.json()); // Parse JSON
app.use(jsonBody); // Parse JSON, 100 KB (POST /v1/library/status parses its own 5 MB body)
app.use(compress()); // Gzip compression
app.use(helmet()); // Security headers
app.use(authMiddleware); // JWT validation → sets req.user
Expand Down Expand Up @@ -384,8 +384,9 @@ All routes require auth + an active subscription (`checkSubscription`); most als
| POST / GET | `/upload/parts` | Presign part URLs (≤32 per request) / list the parts S3 holds, to resume |
| POST | `/upload/complete` | Complete from S3's own part list and set `synced=true` (and `downloaded` on a media-server book's external resources) — the only confirmation a multipart upload gets; safe to retry |
| POST | `/upload/abort` | Abort; succeeds when the upload or row is already gone |
| GET | `/keys` | Synced identifiers |
| GET | `/keys` | Synced keys (**deprecated**: still served for shipped builds' first-sync / tier-change pass; new clients use `/status`) |
| POST | `/uuids` | Match client uuids to rows |
| POST | `/status` | The missing-items pass: of the client's uuids (the whole library, one body, parsed by the route with a 5 MB limit after `checkSubscription` and `requireCloudData`; `jsonBody` keeps every other route at 100 KB), which no row has, active or deleted (`unknown`: register them), and which are active books with no file in S3 (`unsynced`: upload them by uuid). Contract in `docs/multipart-uploads.md` |

### Storage Routes (`/v1/storage`)

Expand Down Expand Up @@ -502,11 +503,11 @@ async DoSomething(): Promise<Result | null> {
}
```

**Exception — reads whose empty result clients treat as authoritative.** `LibraryService.getLibrary`
and `getLastItemPlayed` throw `LibraryLookupError` when a DB read fails instead of returning `null`
or `[]`: sync clients reconcile deletions (items, server links) against a listing, so a failed read
must never look like an empty library. DB classes still return `null` on error; the service turns
that `null` into the throw. `GET /` and `GET /last_played` map it to a 500 the clients retry — with
**Exception — reads whose empty result clients treat as authoritative.** `LibraryService.getLibrary`,
`getLastItemPlayed` and `getItemsStatus` throw `LibraryLookupError` when a DB read fails instead of returning `null`
or `[]`: sync clients reconcile deletions (items, server links) against a listing, and register whatever the status
read calls unknown, so a failed read must never look like an empty library. DB classes still return `null` on error; the service turns
that `null` into the throw. `GET /`, `GET /last_played` and `POST /status` map it to a 500 the clients retry — with
one deliberate exception: on the root listing the resume item is best-effort, so the controller logs
a `getLastItemPlayed` failure and omits the `lastItemPlayed` key (absent = unavailable this time,
`null` = nothing played yet) rather than failing a listing that has already succeeded. Use the same
Expand Down
49 changes: 46 additions & 3 deletions docs/multipart-uploads.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ when S3 rejected the PUT, leaving rows that claim to be backed up with nothing i
book is left alone — Hardcover has no file, and its `sync_status` is the client's own marker.
- **`synced` means the file is in S3, on every tier.** `PUT /` creates rows unsynced, and `POST /` ignores
`synced:true` for a book with no object. So `GET /keys`, which lists synced rows, is "books whose file is in S3":
a LITE account's books are left out of it on purpose, because LITE never uploads a file. Clients use `/keys` only
for the one-off "upload what the server is missing" pass (iOS: an install's first sync; Android: a tier change),
and should run that pass only on PRO.
a LITE account's books are left out of it on purpose, because LITE never uploads a file. **`/keys` is
deprecated**: shipped builds compare their local paths against it in a one-off "upload what the server is missing"
pass (iOS: an install's first sync; Android: a tier change), and it stays served for them. A path that is stale on
that device (the item was moved or renamed on another one) reads as missing, and re-uploading it there moves the
item back: `PUT /` treats a known uuid at a new key as a move. New clients use the
[missing-items pass](#the-missing-items-pass), which asks by uuid.
- **One open upload per book.** `start` aborts whatever is still open for the book before opening a new one. Uploading
the same book from two devices at once is unsupported: each device's `start` would cancel the other's upload. That
is deliberate — a book's file is uploaded by the device it was imported on, and every other device downloads it.
Expand Down Expand Up @@ -73,3 +76,43 @@ and a 400 `RequestTimeout` or any 5xx means retry the part.
`complete` is safe to repeat. If the upload is gone but the object exists (a previous attempt succeeded and its
response was lost), it answers success and makes sure the row is synced. `start` does the same: if the object already
exists, it answers `exists` instead of opening a second upload.

## The missing-items pass

`POST /v1/library/status` answers, for the uuids in a client's local library, what the server lacks. It exists for an
account that signs in over a library built while signed out, whose sync lapsed and came back, or that moved from LITE
to PRO: items added while sync was off (and uploads the lapse cleared from the queue) never reached the server, and
books registered on LITE have no file.
Open to PRO and LITE.

| Body | Success |
|---|---|
| `{ uuids: [uuid…] }`: every item in the local library (books, folders, bound books), in one request | `{ unknown: [uuid…], unsynced: [uuid…] }` |

- `unknown`: no row has the uuid, active or deleted. First send those items through `POST /uuids`
(`{ items: { "<key>": "<uuid>" } }`, at most 1,000 per request):
the server's row may already sit at that key under no uuid (a legacy row) or another one (the same file imported
on two devices), and `PUT /` at an occupied key answers with that row without storing the client's uuid, so the
item would come back `unknown` every run and its bookmarks and external resources would answer `item_not_found`.
`/uuids` sets the uuid on a legacy row and answers a conflict for the other case (adopt the server's uuid). Then
register each one like an import (`PUT /` at its local path, then its external resources and bookmarks), parents
before children. No row holds the uuid, so the `PUT` can't move anything, and a deleted item keeps its uuid, so a
book deleted on another device isn't registered again. On PRO the `PUT`'s answer then asks for the file as usual.
The one exception (accepted): an item from before the server had uuids (March 2026) that another device deleted
under its own uuid, or none, before this device's uuid was matched, reads as `unknown` and comes back. `/uuids`
only looks at active rows, so nothing tells it apart from a book imported on this device.
- `unsynced`: an active book with no file in S3. PRO only: upload its file through `/upload/*`, which finds the book by
uuid wherever it now lives. Never re-register these: a `PUT /` at a stale path would move them. Skip books streamed
from a media server (their file arrives when they're downloaded), books with no local file, books over 10 GiB, and
books with an upload already queued. LITE ignores this list: LITE never uploads, so every book it registered is
on it.
- Uuids come back spelled as they were sent, once each; strings that aren't uuids are left out of both lists. A
failed read is a 500, never an empty answer, which would read as "register everything".
- The body is the whole library, so this route's JSON limit is 5 MB (about 130k uuids), parsed only once the caller
is known to be on PRO or LITE; the rest of the API keeps 100 KB. Nothing is capped per request.
- Run it as the registration step of the first sync (after sign-in, and on coming back from a lapse, which a
client treats as a first sync), on LITE → PRO, and weekly, and only when nothing is waiting in the client's own
sync queue: a queued import would otherwise come back `unknown` and be registered twice.
- Until that first sync has run, no listing may delete local items it doesn't show (the first sync's own listing
included): they may be exactly the items the pass is about to register, such as books imported while sync was off.

43 changes: 43 additions & 0 deletions src/__tests__/controllers/LibraryController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,3 +372,46 @@ describe('LibraryController — legacy routes naming a missing item', () => {
expect(res400.json).toHaveBeenCalledWith({ message: 'The destination is invalid' });
});
});

describe('LibraryController.postLibraryStatus', () => {
let libraryService: any;
let controller: LibraryController;
const uuids = ['2c2d0f44-1111-4111-8111-111111111111', '2c2d0f44-2222-4222-8222-222222222222'];

beforeEach(() => {
libraryService = { getItemsStatus: jest.fn() };
controller = new LibraryController(libraryService, {} as any);
(controller as any)._logger = mockLoggerService;
mockLoggerService.log.mockClear();
});

const request = () =>
({ body: { uuids }, user: { id_user: 1, email: 'user@example.com' } }) as any;

it("answers the service's lists as they are", async () => {
libraryService.getItemsStatus.mockResolvedValue({ unknown: [uuids[0]], unsynced: [uuids[1]] });
const res = makeRes();

await controller.postLibraryStatus(request(), res);

expect(libraryService.getItemsStatus).toHaveBeenCalledWith(expect.objectContaining({ id_user: 1 }), uuids);
expect(res.json).toHaveBeenCalledWith({ unknown: [uuids[0]], unsynced: [uuids[1]] });
});

it('answers 500 on a failed read, logging a count rather than the library', async () => {
libraryService.getItemsStatus.mockRejectedValue(new LibraryLookupError());
const res = makeRes();

await controller.postLibraryStatus(request(), res);

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ message: 'Internal error' });
expect(mockLoggerService.log).toHaveBeenCalledWith(
expect.objectContaining({ data: { user_id: 1, count: 2 } }),
'error',
);
const logged = JSON.stringify(mockLoggerService.log.mock.calls);
expect(logged).not.toContain(uuids[0]);
expect(logged).not.toContain('user@example.com');
});
});
43 changes: 43 additions & 0 deletions src/__tests__/database/libraryItemsActive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect } from '@jest/globals';
import { getTestTransaction, createTestUser } from '../setup';

// Migration 20260929120000: every read filters on `active = true`, so a NULL
// `active` was an invisible third state. It's now NOT NULL, defaulting to true.
describe('library_items.active', () => {
const baseRow = (user_id: number, key: string) => ({
user_id,
key,
title: key,
original_filename: key,
speed: 1,
actual_time: '0',
details: key,
duration: '0',
percent_completed: 0,
order_rank: 0,
type: 2,
is_finish: false,
synced: false,
});

it('defaults to true', async () => {
const trx = getTestTransaction();
const user = await createTestUser(trx, { email: 'active-default@example.com' });

const [row] = await trx('library_items').insert(baseRow(user.id_user, 'Default.m4b')).returning('active');

expect(row.active).toBe(true);
});

it('rejects NULL', async () => {
const trx = getTestTransaction();
const user = await createTestUser(trx, { email: 'active-null@example.com' });

// A savepoint, so the failed insert doesn't abort the test's transaction
await expect(
trx.transaction((savepoint) =>
savepoint('library_items').insert({ ...baseRow(user.id_user, 'Null.m4b'), active: null }),
),
).rejects.toThrow(/null value in column "active"/);
});
});
49 changes: 49 additions & 0 deletions src/__tests__/middlewares/jsonBody.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from '@jest/globals';
import express from 'express';
import request from 'supertest';
import { jsonBody, largeJsonBody } from '../../api/middlewares/jsonBody';

// server.ts parses JSON with body-parser's 100 KB limit everywhere except the routes that
// parse their own larger body after checking the caller (POST /v1/library/status).
function makeApp() {
const app = express();
app.use(jsonBody);
app.post('/v1/library/status', largeJsonBody, (req, res) => res.json({ count: req.body.uuids.length }));
app.post('/v1/user/login', (req, res) => res.json({ keys: Object.keys(req.body) }));
app.use((err: { status?: number }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err.status ?? 500).json({});
});
return app;
}

// ~1 MB: over the 100 KB default, well under 5 MB
const library = { uuids: Array.from({ length: 27_000 }, (_, i) => `2c2d0f44-1111-4111-8111-${`${i}`.padStart(12, '0')}`) };

describe('jsonBody', () => {
it('lets the missing-items route parse a whole library', async () => {
const res = await request(makeApp()).post('/v1/library/status').send(library);

expect(res.status).toBe(200);
expect(res.body).toEqual({ count: 27_000 });
});

it('matches the route the way Express does: any case, trailing slash ignored', async () => {
const res = await request(makeApp()).post('/V1/Library/Status/').send(library);

expect(res.status).toBe(200);
expect(res.body).toEqual({ count: 27_000 });
});

it('keeps every other route at 100 KB', async () => {
const res = await request(makeApp()).post('/v1/user/login').send(library);

expect(res.status).toBe(413);
});

it('still parses small bodies everywhere else', async () => {
const res = await request(makeApp()).post('/v1/user/login').send({ token_id: 'abc' });

expect(res.status).toBe(200);
expect(res.body).toEqual({ keys: ['token_id'] });
});
});
38 changes: 38 additions & 0 deletions src/__tests__/services/LibraryDBItemsByUuids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach } from '@jest/globals';
import { LibraryDB } from '../../services/db/LibraryDB';
import {
getTestTransaction,
mockLoggerService,
createTestUser,
createTestLibraryItem,
} from '../setup';

// The uuids become one Postgres array literal cast to uuid[]: a single string that
// isn't a uuid would fail the cast, and with it the whole library's status.
describe('LibraryDB.getItemsByUuids', () => {
let db: LibraryDB;

beforeEach(() => {
db = new LibraryDB();
(db as any).db = getTestTransaction();
(db as any)._logger = mockLoggerService;
mockLoggerService.log.mockClear();
});

it('skips strings that are not uuids instead of failing the query', async () => {
const trx = getTestTransaction();
const user = await createTestUser(trx, { email: 'items-by-uuids@example.com' });
const book = await createTestLibraryItem(trx, { user_id: user.id_user, key: 'Book.m4b' });

const rows = await db.getItemsByUuids(user.id_user, ['Optional("x")', '', 'a,b}', book.uuid]);

expect(rows?.map((row) => row.uuid)).toEqual([book.uuid]);
});

it('answers an empty list, not a failure, when nothing is a uuid', async () => {
const trx = getTestTransaction();
const user = await createTestUser(trx, { email: 'items-by-uuids-none@example.com' });

await expect(db.getItemsByUuids(user.id_user, ['x', ''])).resolves.toEqual([]);
});
});
Loading
Loading