Skip to content
Open
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ All routes require auth + an active subscription (`checkSubscription`); most als

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/` | List / resolve items. **`uuid` names the item; a trailing `/` on `relativePath` asks for its contents.** A valid uuid is authoritative (not found → `[]`, no path fallback); a folder or bound book resolved by uuid with a trailing slash lists its children by the *server-side* key. No uuid → path lookup. `sign=true` presigns URLs (PRO only). A failed DB read is a 500, never an empty library. |
| GET | `/` | List / resolve items. **`uuid` names the item; a trailing `/` on `relativePath` asks for its contents.** A valid uuid is authoritative (not found → `[]`, no path fallback); a folder or bound book resolved by uuid with a trailing slash lists its children by the *server-side* key. No uuid → path lookup. `sign=true` presigns URLs (PRO only). A failed DB read is a 500, never an empty library. When the request names ONE item (a non-root path with no trailing slash resolving to a single non-folder row) for a PRO user and the file sits in Deep Archive, a Standard-tier restore is requested on the spot and the item carries `storageState: "restoring"` (`GlacierRestoreService`; a bound book resolved directly has no object of its own, so it carries no state and its files are checked in the background); only on that first frozen tap do the book's artwork and, for a bound book, its other chapters and cover follow in the background — a warm item costs one HEAD. |
| POST / PUT / DELETE | `/` | Update metadata / upload metadata / soft-delete an item and its true children (bounded, escaped key match). POST and PUT bodies are validated with zod (`src/validation/libraryItem.ts`): every metadata field optional, unknown keys stripped, so `source_path` — and `synced` on PUT — can't be written by a client. Both apps retry a failed sync job forever, so keep those schemas matching what they send (the fixtures in `src/__tests__/validation/libraryItem.test.ts`) |
| GET | `/last_played` | Resume item, or `null` when nothing has been played; 500 on a failed read |
| PUT / DELETE | `/external` | Link / unlink an external resource (Jellyfin, Audiobookshelf, …) |
Expand Down
625 changes: 625 additions & 0 deletions src/__tests__/services/GlacierRestoreHook.test.ts

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions src/__tests__/services/S3ServiceFileExists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ describe('S3Service.fileExists — tri-state', () => {
await expect(service.fileExists('prefix/root/a.m4b')).resolves.toBeNull();
});

it('is a mapping over headObject, the one HEAD request', async () => {
const head = jest.spyOn(service, 'headObject');
head.mockResolvedValueOnce('missing');
await expect(service.fileExists('k')).resolves.toBe(false);
head.mockResolvedValueOnce(null);
await expect(service.fileExists('k')).resolves.toBeNull();
head.mockResolvedValueOnce({ storageClass: 'DEEP_ARCHIVE', restore: 'none', contentLength: 1 });
await expect(service.fileExists('k')).resolves.toBe(true);
expect(headObjectMock).not.toHaveBeenCalled();
});

it('does not log the storage prefix, which can be the account email', async () => {
headObjectMock.mockImplementation(async () => {
throw Object.assign(new Error('Forbidden'), {
Expand Down
68 changes: 68 additions & 0 deletions src/__tests__/services/S3ServiceRestore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { S3Service } from '../../services/S3Service';
import { mockLoggerService } from '../setup';

function s3Error(status: number, name = 'Error', message = 'boom') {
return Object.assign(new Error(message), { name, $metadata: { httpStatusCode: status } });
}

/** The two S3 calls the on-demand thaw relies on, at the wire: what is sent, how answers are classified. */
describe('S3Service — headObject / restoreObject', () => {
let service: S3Service;
let client: { headObject: jest.Mock<any>; restoreObject: jest.Mock<any> };

beforeEach(() => {
service = new S3Service();
client = { headObject: jest.fn<any>(), restoreObject: jest.fn<any>() };
(service as any).client = client;
(service as any)._logger = mockLoggerService;
mockLoggerService.log.mockClear();
});

it('headObject maps the Restore header to none / ongoing / ready', async () => {
client.headObject.mockResolvedValueOnce({ StorageClass: 'DEEP_ARCHIVE', ContentLength: 5, $metadata: { httpStatusCode: 200 } });
expect(await service.headObject('k')).toEqual({ storageClass: 'DEEP_ARCHIVE', restore: 'none', contentLength: 5 });

client.headObject.mockResolvedValueOnce({ StorageClass: 'DEEP_ARCHIVE', Restore: 'ongoing-request="true"', $metadata: { httpStatusCode: 200 } });
expect((await service.headObject('k') as any).restore).toBe('ongoing');

client.headObject.mockResolvedValueOnce({
StorageClass: 'DEEP_ARCHIVE',
Restore: 'ongoing-request="false", expiry-date="Fri, 24 Oct 2026 00:00:00 GMT"',
$metadata: { httpStatusCode: 200 },
});
expect((await service.headObject('k') as any).restore).toBe('ready');

// STANDARD objects carry no StorageClass header at all.
client.headObject.mockResolvedValueOnce({ ContentLength: 7, $metadata: { httpStatusCode: 200 } });
expect(await service.headObject('k')).toEqual({ storageClass: undefined, restore: 'none', contentLength: 7 });
});

it('headObject: 404 is "missing", anything else is indeterminate (null) and logged at warn', async () => {
client.headObject.mockRejectedValueOnce(s3Error(404, 'NotFound'));
expect(await service.headObject('k')).toBe('missing');

client.headObject.mockRejectedValueOnce(s3Error(403, 'AccessDenied'));
expect(await service.headObject('k')).toBeNull();
expect(mockLoggerService.log).toHaveBeenCalledWith(expect.objectContaining({ origin: 'S3Service.headObject' }), 'warn');
});

it('restoreObject sends Days and the Glacier tier, and treats an in-flight restore as success', async () => {
client.restoreObject.mockResolvedValueOnce({ $metadata: { httpStatusCode: 202 } });
expect(await service.restoreObject('pfx/root/a.m4b', { days: 30, tier: 'Standard' })).toBe(true);
expect(client.restoreObject).toHaveBeenCalledWith({
Bucket: process.env.S3_BUCKET,
Key: 'pfx/root/a.m4b',
RestoreRequest: { Days: 30, GlacierJobParameters: { Tier: 'Standard' } },
});

client.restoreObject.mockRejectedValueOnce(s3Error(409, 'RestoreAlreadyInProgress'));
expect(await service.restoreObject('k', { days: 30, tier: 'Standard' })).toBe(true);
});

it('restoreObject: any other failure is null and logged at warn', async () => {
client.restoreObject.mockRejectedValueOnce(s3Error(403, 'InvalidObjectState'));
expect(await service.restoreObject('k', { days: 30, tier: 'Standard' })).toBeNull();
expect(mockLoggerService.log).toHaveBeenCalledWith(expect.objectContaining({ origin: 'S3Service.restoreObject' }), 'warn');
});
});
43 changes: 43 additions & 0 deletions src/database/migrations/20260925120000_glacier_restore_requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Knex } from 'knex';

// One row per (user, S3 key) whose restore out of Deep Archive was requested.
// Written by the on-demand hook in LibraryService when a Pro user plays or
// downloads a frozen book; read by the glacier-cleanup Lambda, which makes
// each READY restore permanent (self-copy to Intelligent-Tiering) and marks
// the row finalized. A book that freezes again later re-opens its own row.
//
// `library_item_id` lets a listing join open requests per item without a
// single S3 call; `kind` separates a book's file from its artwork, which live
// under different keys. `state = 'archived'` is reserved for the Lambda to
// record frozen objects it sees without restoring them.
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('glacier_restore_requests', (table) => {
table.increments('id_glacier_restore_request');
table.integer('user_id').unsigned().notNullable();
table.foreign('user_id').references('id_user').inTable('users');
table.integer('library_item_id').unsigned().nullable();
table
.foreign('library_item_id')
.references('id_library_item')
.inTable('library_items')
.onDelete('SET NULL');
table.string('kind', 16).notNullable().defaultTo('object'); // object | thumbnail
table.string('key', 1024).notNullable(); // full S3 key, prefix included
table.string('tier', 16).notNullable().defaultTo('Standard');
table.smallint('days').notNullable().defaultTo(30);
table.string('state', 16).notNullable().defaultTo('requested'); // requested | finalized | failed | archived
table.smallint('attempts').notNullable().defaultTo(1);
table.timestamp('requested_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('finalized_at', { useTz: true }).nullable();
table.text('last_error').nullable();
table.timestamps(true, true);

table.unique(['user_id', 'key']);
table.index(['state', 'requested_at']);
table.index(['library_item_id', 'state']);
});
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('glacier_restore_requests');
}
Loading
Loading