mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(kobo): version cover ids from the cover, not the metadata row
Any write to book_metadata bumped updated_at and minted a fresh CoverImageId, so a device holding that cover locally fell back to the placeholder offline. Stamp cover_updated_at at each cover write instead; migration 0080 backfills it from updated_at so no id churns on upgrade. Also trims the DECIMAL(10,2) scale Booklore hands back for a series index, which sorted 2.50 after 2.9. Closes #943.
This commit is contained in:
@@ -707,6 +707,25 @@ export const E2E_SUITES = Object.freeze({
|
|||||||
...SHARED_DB_AND_HELPER_PATHS,
|
...SHARED_DB_AND_HELPER_PATHS,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"kobo-cover-identity": {
|
||||||
|
id: "kobo-cover-identity",
|
||||||
|
name: "Kobo Cover Identity",
|
||||||
|
timeout: 60,
|
||||||
|
lane: "full",
|
||||||
|
description: "Kobo CoverImageId stability across metadata edits and cover changes",
|
||||||
|
vitestTarget: "test/kobo-cover-identity.e2e-spec.ts",
|
||||||
|
junitOutput: `${TEST_RESULTS_DIR}/kobo-cover-identity-e2e-junit.xml`,
|
||||||
|
prepareDedicatedDatabase: true,
|
||||||
|
useDedicatedDatabase: true,
|
||||||
|
changedPaths: [
|
||||||
|
"server/src/modules/kobo/**",
|
||||||
|
"server/src/modules/cover/**",
|
||||||
|
"server/src/modules/metadata/**",
|
||||||
|
"server/test/kobo-cover-identity.e2e-spec.ts",
|
||||||
|
"server/test/e2e/reader-state-isolation/**",
|
||||||
|
...SHARED_DB_AND_HELPER_PATHS,
|
||||||
|
],
|
||||||
|
},
|
||||||
"users-admin-lifecycle": {
|
"users-admin-lifecycle": {
|
||||||
id: "users-admin-lifecycle",
|
id: "users-admin-lifecycle",
|
||||||
name: "Users Admin Lifecycle",
|
name: "Users Admin Lifecycle",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "book_metadata" ADD COLUMN "cover_updated_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
UPDATE "book_metadata" SET "cover_updated_at" = "updated_at";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -561,6 +561,13 @@
|
|||||||
"when": 1787777420432,
|
"when": 1787777420432,
|
||||||
"tag": "0079_add_annotations_user_created_active_idx",
|
"tag": "0079_add_annotations_user_created_active_idx",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 80,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787850415027,
|
||||||
|
"tag": "0080_add_book_metadata_cover_updated_at",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export const bookMetadata = pgTable(
|
|||||||
.array()
|
.array()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`'{}'::text[]`),
|
.default(sql`'{}'::text[]`),
|
||||||
|
// Moves only when the served cover image changes, so a Kobo device is not handed a fresh
|
||||||
|
// CoverImageId for a picture it already stores locally. See buildVersionedCoverImageId().
|
||||||
|
coverUpdatedAt: timestamp('cover_updated_at', { withTimezone: true }),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ function createMutationService(options?: { assertFieldsUnlocked?: ReturnType<typ
|
|||||||
scoreService as never,
|
scoreService as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { service, mockDb, updateSet, scoreService };
|
return { service, mockDb, updateSet, scoreService, values, onConflictDoUpdate };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('CoverService', () => {
|
describe('CoverService', () => {
|
||||||
@@ -611,4 +611,59 @@ describe('CoverService', () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A Kobo device keys its locally stored cover by CoverImageId, which is versioned from
|
||||||
|
// coverUpdatedAt. Leaving the stamp behind here strands the device on a stale picture.
|
||||||
|
describe('coverUpdatedAt stamping (issue #943)', () => {
|
||||||
|
it('stamps coverUpdatedAt when a custom cover is uploaded', async () => {
|
||||||
|
const { service, values, onConflictDoUpdate } = createMutationService();
|
||||||
|
|
||||||
|
vi.mocked(coverDirPath).mockReturnValue('/tmp/books/covers/12');
|
||||||
|
vi.mocked(imageExt).mockReturnValue('jpg');
|
||||||
|
vi.mocked(generateThumbnail).mockResolvedValue(Buffer.from('thumb'));
|
||||||
|
vi.mocked(readdir).mockResolvedValue([] as never);
|
||||||
|
vi.mocked(mkdir).mockResolvedValue(undefined as never);
|
||||||
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(rename).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.uploadCover(12, Buffer.from('image-data'), 'image/jpeg', makeUser());
|
||||||
|
|
||||||
|
expect(values).toHaveBeenCalledWith({ bookId: 12, coverSource: 'custom', coverUpdatedAt: expect.any(Date), updatedAt: expect.any(Date) });
|
||||||
|
expect(onConflictDoUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ set: { coverSource: 'custom', coverUpdatedAt: expect.any(Date), updatedAt: expect.any(Date) } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps coverUpdatedAt when deleting the last cover leaves the book without one', async () => {
|
||||||
|
const { service, values } = createMutationService();
|
||||||
|
|
||||||
|
vi.mocked(coverDirPath).mockReturnValue('/tmp/books/covers/12');
|
||||||
|
vi.mocked(readdir).mockResolvedValue(['cover_custom.jpg'] as never);
|
||||||
|
vi.mocked(unlink).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.deleteCover(12, makeUser());
|
||||||
|
|
||||||
|
expect(values).toHaveBeenCalledWith({ bookId: 12, coverSource: null, coverUpdatedAt: expect.any(Date), updatedAt: expect.any(Date) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps coverUpdatedAt when deleting a custom cover falls back to the extracted one', async () => {
|
||||||
|
const { service, values } = createMutationService();
|
||||||
|
|
||||||
|
vi.mocked(coverDirPath).mockReturnValue('/tmp/books/covers/12');
|
||||||
|
vi.mocked(readdir).mockResolvedValue(['cover_custom.jpg', 'cover_extracted.png'] as never);
|
||||||
|
vi.mocked(readFile).mockResolvedValue(Buffer.from('extracted-img'));
|
||||||
|
vi.mocked(generateThumbnail).mockResolvedValue(Buffer.from('thumb'));
|
||||||
|
vi.mocked(unlink).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.deleteCover(12, makeUser());
|
||||||
|
|
||||||
|
expect(values).toHaveBeenCalledWith({
|
||||||
|
bookId: 12,
|
||||||
|
coverSource: 'extracted',
|
||||||
|
coverUpdatedAt: expect.any(Date),
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -298,8 +298,8 @@ export class CoverService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
await this.db
|
await this.db
|
||||||
.insert(bookMetadata)
|
.insert(bookMetadata)
|
||||||
.values({ bookId, coverSource: source, updatedAt: now })
|
.values({ bookId, coverSource: source, coverUpdatedAt: now, updatedAt: now })
|
||||||
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { coverSource: source, updatedAt: now } });
|
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { coverSource: source, coverUpdatedAt: now, updatedAt: now } });
|
||||||
await this.db.update(books).set({ updatedAt: now }).where(eq(books.id, bookId));
|
await this.db.update(books).set({ updatedAt: now }).where(eq(books.id, bookId));
|
||||||
await this.metadataScoreService.calculateAndSave(bookId);
|
await this.metadataScoreService.calculateAndSave(bookId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1103,6 +1103,7 @@ export class KoboSyncService {
|
|||||||
seriesName: schema.bookMetadata.seriesName,
|
seriesName: schema.bookMetadata.seriesName,
|
||||||
seriesIndex: schema.bookMetadata.seriesIndex,
|
seriesIndex: schema.bookMetadata.seriesIndex,
|
||||||
metadataUpdatedAt: schema.bookMetadata.updatedAt,
|
metadataUpdatedAt: schema.bookMetadata.updatedAt,
|
||||||
|
coverUpdatedAt: schema.bookMetadata.coverUpdatedAt,
|
||||||
fileFormat: schema.bookFiles.format,
|
fileFormat: schema.bookFiles.format,
|
||||||
fileSizeBytes: schema.bookFiles.sizeBytes,
|
fileSizeBytes: schema.bookFiles.sizeBytes,
|
||||||
fileHash: schema.bookFiles.fileHash,
|
fileHash: schema.bookFiles.fileHash,
|
||||||
@@ -1126,6 +1127,7 @@ export class KoboSyncService {
|
|||||||
schema.bookMetadata.seriesName,
|
schema.bookMetadata.seriesName,
|
||||||
schema.bookMetadata.seriesIndex,
|
schema.bookMetadata.seriesIndex,
|
||||||
schema.bookMetadata.updatedAt,
|
schema.bookMetadata.updatedAt,
|
||||||
|
schema.bookMetadata.coverUpdatedAt,
|
||||||
schema.bookFiles.format,
|
schema.bookFiles.format,
|
||||||
schema.bookFiles.sizeBytes,
|
schema.bookFiles.sizeBytes,
|
||||||
schema.bookFiles.fileHash,
|
schema.bookFiles.fileHash,
|
||||||
@@ -1147,7 +1149,7 @@ export class KoboSyncService {
|
|||||||
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, isFixedLayout);
|
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, isFixedLayout);
|
||||||
const identity = identitiesById.get(row.bookId);
|
const identity = identitiesById.get(row.bookId);
|
||||||
const coverImageId = identity
|
const coverImageId = identity
|
||||||
? this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.metadataUpdatedAt)
|
? this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.coverUpdatedAt)
|
||||||
: String(row.bookId);
|
: String(row.bookId);
|
||||||
return {
|
return {
|
||||||
bookId: row.bookId,
|
bookId: row.bookId,
|
||||||
@@ -1200,6 +1202,7 @@ export class KoboSyncService {
|
|||||||
fileHash: schema.bookFiles.fileHash,
|
fileHash: schema.bookFiles.fileHash,
|
||||||
isFixedLayout: schema.bookFiles.isFixedLayout,
|
isFixedLayout: schema.bookFiles.isFixedLayout,
|
||||||
metadataUpdatedAt: schema.bookMetadata.updatedAt,
|
metadataUpdatedAt: schema.bookMetadata.updatedAt,
|
||||||
|
coverUpdatedAt: schema.bookMetadata.coverUpdatedAt,
|
||||||
addedAt: schema.books.addedAt,
|
addedAt: schema.books.addedAt,
|
||||||
updatedAt: schema.books.updatedAt,
|
updatedAt: schema.books.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -1253,7 +1256,7 @@ export class KoboSyncService {
|
|||||||
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, row.isFixedLayout);
|
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, row.isFixedLayout);
|
||||||
const identity = identitiesById.get(row.bookId);
|
const identity = identitiesById.get(row.bookId);
|
||||||
if (!identity) continue;
|
if (!identity) continue;
|
||||||
const coverImageId = this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.metadataUpdatedAt);
|
const coverImageId = this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.coverUpdatedAt);
|
||||||
const language = normalizeKoboLanguage(row.language);
|
const language = normalizeKoboLanguage(row.language);
|
||||||
const isbn = selectKoboIsbn(row.isbn13, row.isbn10);
|
const isbn = selectKoboIsbn(row.isbn13, row.isbn10);
|
||||||
byId.set(row.bookId, {
|
byId.set(row.bookId, {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export class MissingResourcesRepository {
|
|||||||
if (bookIds.length === 0) return 0;
|
if (bookIds.length === 0) return 0;
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.update(bookMetadata)
|
.update(bookMetadata)
|
||||||
.set({ coverSource: null, updatedAt: new Date() })
|
.set({ coverSource: null, coverUpdatedAt: new Date(), updatedAt: new Date() })
|
||||||
.where(and(isNotNull(bookMetadata.coverSource), inArray(bookMetadata.bookId, bookIds)));
|
.where(and(isNotNull(bookMetadata.coverSource), inArray(bookMetadata.bookId, bookIds)));
|
||||||
return result.rowCount ?? 0;
|
return result.rowCount ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -272,6 +272,77 @@ describe('MetadataService', () => {
|
|||||||
expect(mockWriteFile).toHaveBeenCalledWith('/books/covers/11/cover_extracted.png', Buffer.from('image-bytes'));
|
expect(mockWriteFile).toHaveBeenCalledWith('/books/covers/11/cover_extracted.png', Buffer.from('image-bytes'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Kobo CoverImageId is versioned from coverUpdatedAt, so it must move whenever the served
|
||||||
|
// image does, and stay put when it does not (issue #943).
|
||||||
|
describe('coverUpdatedAt stamping', () => {
|
||||||
|
function stubEpubCoverExtraction(cover: Buffer): void {
|
||||||
|
mockExtractEpubMetadata.mockResolvedValueOnce({
|
||||||
|
title: 'Refreshable book',
|
||||||
|
subtitle: null,
|
||||||
|
description: null,
|
||||||
|
isbn10: null,
|
||||||
|
isbn13: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedYear: null,
|
||||||
|
language: null,
|
||||||
|
seriesName: null,
|
||||||
|
seriesIndex: null,
|
||||||
|
authors: [],
|
||||||
|
narrators: [],
|
||||||
|
genres: [],
|
||||||
|
tags: [],
|
||||||
|
rating: null,
|
||||||
|
pageCount: null,
|
||||||
|
googleBooksId: null,
|
||||||
|
goodreadsId: null,
|
||||||
|
amazonId: null,
|
||||||
|
hardcoverId: null,
|
||||||
|
hardcoverEditionId: null,
|
||||||
|
openLibraryId: null,
|
||||||
|
ranobedbId: null,
|
||||||
|
itunesId: null,
|
||||||
|
coverBuffer: null,
|
||||||
|
});
|
||||||
|
mockExtractEpubCover.mockResolvedValueOnce(cover);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stamps coverUpdatedAt when an overwriting extraction replaces the cover', async () => {
|
||||||
|
const { db, updateSet } = makeDb();
|
||||||
|
const service = makeService(db);
|
||||||
|
mockReaddir.mockResolvedValue(['cover_extracted.png']);
|
||||||
|
|
||||||
|
await service.saveExtractedCoverBytes(21, Buffer.from('image-bytes'));
|
||||||
|
|
||||||
|
expect(db.update).toHaveBeenCalledWith(bookMetadata);
|
||||||
|
expect(updateSet).toHaveBeenCalledWith({ coverUpdatedAt: expect.any(Date) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps coverUpdatedAt when first-writer-wins matches no row but the image still changed', async () => {
|
||||||
|
const { db, updateSet, selectLimit } = makeDb();
|
||||||
|
const service = makeService(db);
|
||||||
|
selectLimit.mockResolvedValue([{ coverSource: 'extracted' }]);
|
||||||
|
mockReaddir.mockResolvedValue(['cover_extracted.png', 'thumbnail.jpg']);
|
||||||
|
stubEpubCoverExtraction(Buffer.from('fresher-image-bytes'));
|
||||||
|
|
||||||
|
await expect(service.refreshCoverForBook(23, '/book.epub', 'epub')).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(mockWriteFile).toHaveBeenCalledWith('/books/covers/23/cover_extracted.png', Buffer.from('fresher-image-bytes'));
|
||||||
|
expect(updateSet).toHaveBeenCalledWith({ coverUpdatedAt: expect.any(Date) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves coverUpdatedAt alone when a DB-owned custom cover still wins', async () => {
|
||||||
|
const { db, updateSet, selectLimit } = makeDb();
|
||||||
|
const service = makeService(db);
|
||||||
|
selectLimit.mockResolvedValue([{ coverSource: 'custom' }]);
|
||||||
|
mockReaddir.mockResolvedValue(['cover_custom.jpg', 'cover_extracted.png', 'thumbnail.jpg']);
|
||||||
|
stubEpubCoverExtraction(Buffer.from('image-bytes'));
|
||||||
|
|
||||||
|
await expect(service.refreshCoverForBook(24, '/book.epub', 'epub')).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(updateSet).not.toHaveBeenCalledWith(expect.objectContaining({ coverUpdatedAt: expect.anything() }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('saveExtractedCoverBytes removes stale custom files unless the DB owns a custom cover', async () => {
|
it('saveExtractedCoverBytes removes stale custom files unless the DB owns a custom cover', async () => {
|
||||||
const { db } = makeDb();
|
const { db } = makeDb();
|
||||||
const service = makeService(db);
|
const service = makeService(db);
|
||||||
|
|||||||
@@ -885,6 +885,9 @@ export class MetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!preserveCustom) {
|
if (!preserveCustom) {
|
||||||
|
// The extracted cover and its thumbnail were just rewritten, so the served image moved even
|
||||||
|
// when the first-writer-wins update above matched no row.
|
||||||
|
await this.db.update(bookMetadata).set({ coverUpdatedAt: now }).where(eq(bookMetadata.bookId, bookId));
|
||||||
await this.db.update(books).set({ updatedAt: now }).where(eq(books.id, bookId));
|
await this.db.update(books).set({ updatedAt: now }).where(eq(books.id, bookId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'os';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { BookloreSourceAdapter } from './booklore-source.adapter';
|
import { BookloreSourceAdapter, parseBookloreSeriesIndex } from './booklore-source.adapter';
|
||||||
import { BOOKLORE_TABLES } from './booklore-tables';
|
import { BOOKLORE_TABLES } from './booklore-tables';
|
||||||
|
|
||||||
describe('BookloreSourceAdapter shelf export', () => {
|
describe('BookloreSourceAdapter shelf export', () => {
|
||||||
@@ -1135,6 +1135,28 @@ describe('BookloreSourceAdapter exportData - minimal and edge cases', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseBookloreSeriesIndex', () => {
|
||||||
|
it.each([
|
||||||
|
['2.50', '2.5'],
|
||||||
|
['2.00', '2'],
|
||||||
|
['0.50', '0.5'],
|
||||||
|
['2.100', '2.1'],
|
||||||
|
['1.25', '1.25'],
|
||||||
|
['2.105', '2.105'],
|
||||||
|
['3', '3'],
|
||||||
|
// A trailing zero outside a decimal is a real digit, not scale padding.
|
||||||
|
['10', '10'],
|
||||||
|
['100', '100'],
|
||||||
|
[' 2.50 ', '2.5'],
|
||||||
|
[2.5, '2.5'],
|
||||||
|
[null, null],
|
||||||
|
['', null],
|
||||||
|
['not-a-number', null],
|
||||||
|
])('normalizes %o to %o', (input, expected) => {
|
||||||
|
expect(parseBookloreSeriesIndex(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('BookloreSourceAdapter private method branches', () => {
|
describe('BookloreSourceAdapter private method branches', () => {
|
||||||
it('fetchUserBookStatuses returns empty array when no progressTable', async () => {
|
it('fetchUserBookStatuses returns empty array when no progressTable', async () => {
|
||||||
const connector = { queryRows: vi.fn(), listColumns: vi.fn() };
|
const connector = { queryRows: vi.fn(), listColumns: vi.fn() };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { parseSeriesIndex } from '@bookorbit/types';
|
import { parseSeriesIndex, type SeriesIndex } from '@bookorbit/types';
|
||||||
import { constants as fsConstants } from 'fs';
|
import { constants as fsConstants } from 'fs';
|
||||||
import { access, stat } from 'fs/promises';
|
import { access, stat } from 'fs/promises';
|
||||||
import type mysql from 'mysql2/promise';
|
import type mysql from 'mysql2/promise';
|
||||||
@@ -527,7 +527,7 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
|
|||||||
language: asString(row.language),
|
language: asString(row.language),
|
||||||
pageCount: asInteger(row.pageCount),
|
pageCount: asInteger(row.pageCount),
|
||||||
seriesName: asString(row.seriesName),
|
seriesName: asString(row.seriesName),
|
||||||
seriesIndex: parseSeriesIndex(row.seriesIndex),
|
seriesIndex: parseBookloreSeriesIndex(row.seriesIndex),
|
||||||
rating: asInteger(row.rating),
|
rating: asInteger(row.rating),
|
||||||
googleBooksId: asString(row.googleBooksId),
|
googleBooksId: asString(row.googleBooksId),
|
||||||
goodreadsId: asString(row.goodreadsId),
|
goodreadsId: asString(row.goodreadsId),
|
||||||
@@ -1214,6 +1214,18 @@ function sqlString(alias: string, column: string | null, outName: string): strin
|
|||||||
return `CAST(${alias}.\`${column}\` AS CHAR) AS ${outName}`;
|
return `CAST(${alias}.\`${column}\` AS CHAR) AS ${outName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booklore stores the series index in a `DECIMAL(10,2)` column, which the driver hands back at its
|
||||||
|
* full scale: 2.5 arrives as "2.50". The scale is storage formatting, not a label anyone authored,
|
||||||
|
* and keeping it sorts the book wrong, because the series sort index reads the fractional part as
|
||||||
|
* its own number and ranks 50 above 9. Trim it before the value becomes a series index.
|
||||||
|
*/
|
||||||
|
export function parseBookloreSeriesIndex(value: unknown): SeriesIndex | null {
|
||||||
|
const candidate = typeof value === 'string' ? value.trim() : value;
|
||||||
|
if (typeof candidate !== 'string' || !/^\d+\.\d+$/.test(candidate)) return parseSeriesIndex(candidate);
|
||||||
|
return parseSeriesIndex(candidate.replace(/0+$/, '').replace(/\.$/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
function sqlNumber(alias: string, column: string | null, outName: string): string {
|
function sqlNumber(alias: string, column: string | null, outName: string): string {
|
||||||
if (!column) return `NULL AS ${outName}`;
|
if (!column) return `NULL AS ${outName}`;
|
||||||
return `${alias}.\`${column}\` AS ${outName}`;
|
return `${alias}.\`${column}\` AS ${outName}`;
|
||||||
|
|||||||
@@ -191,8 +191,8 @@ export class MigrationImportRepository {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
await this.db
|
await this.db
|
||||||
.insert(schema.bookMetadata)
|
.insert(schema.bookMetadata)
|
||||||
.values({ bookId, coverSource: 'custom', updatedAt: now })
|
.values({ bookId, coverSource: 'custom', coverUpdatedAt: now, updatedAt: now })
|
||||||
.onConflictDoUpdate({ target: schema.bookMetadata.bookId, set: { coverSource: 'custom', updatedAt: now } });
|
.onConflictDoUpdate({ target: schema.bookMetadata.bookId, set: { coverSource: 'custom', coverUpdatedAt: now, updatedAt: now } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- User book statuses ---
|
// --- User book statuses ---
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { mkdir, writeFile } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import * as schema from '../src/db/schema';
|
||||||
|
import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
|
||||||
|
import {
|
||||||
|
authHeader,
|
||||||
|
closeReaderStateIsolationE2EContext,
|
||||||
|
createLibraryWithFolder,
|
||||||
|
createReaderStateIsolationE2EContext,
|
||||||
|
locateBookByAbsolutePath,
|
||||||
|
triggerAndWaitForLibraryScan,
|
||||||
|
type CreatedLibrary,
|
||||||
|
type ReaderStateIsolationE2EContext,
|
||||||
|
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
|
||||||
|
|
||||||
|
type KoboDevice = { id: number; token: string };
|
||||||
|
type BookMetadata = { EntitlementId: string; CoverImageId: string; Title: string | null; Publisher: { Name: string } | null };
|
||||||
|
type SyncEntry = { NewEntitlement?: { BookMetadata: BookMetadata }; ChangedProductMetadata?: { BookMetadata: BookMetadata } };
|
||||||
|
|
||||||
|
// A one pixel JPEG, so the cover on disk is real bytes rather than a placeholder string.
|
||||||
|
const COVER_BYTES = Buffer.from(
|
||||||
|
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Kobo stores a downloaded book's cover locally under the CoverImageId it was given, so an id
|
||||||
|
* that churns on unrelated metadata edits strands the device on the placeholder cover whenever it
|
||||||
|
* is offline. These tests pin the id to the cover itself. See issue #943.
|
||||||
|
*/
|
||||||
|
describe('Kobo cover image identity (e2e)', { timeout: 180_000 }, () => {
|
||||||
|
let ctx!: ReaderStateIsolationE2EContext;
|
||||||
|
let library!: CreatedLibrary;
|
||||||
|
let userId!: number;
|
||||||
|
let bookId!: number;
|
||||||
|
let device!: KoboDevice;
|
||||||
|
let entitlementId!: string;
|
||||||
|
let coverIdHeldByDevice!: string;
|
||||||
|
|
||||||
|
async function sync(syncToken?: string): Promise<{ entries: SyncEntry[]; hasMore: boolean; syncToken: string }> {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/kobo/${device.token}/v1/library/sync`,
|
||||||
|
headers: syncToken ? { 'x-kobo-synctoken': syncToken } : undefined,
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
return {
|
||||||
|
entries: response.json() as SyncEntry[],
|
||||||
|
hasMore: response.headers['x-kobo-sync'] === 'continue',
|
||||||
|
syncToken: response.headers['x-kobo-synctoken'] as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drainEntries(): Promise<SyncEntry[]> {
|
||||||
|
const delivered: SyncEntry[] = [];
|
||||||
|
let syncToken: string | undefined;
|
||||||
|
for (let page = 0; page < 10; page += 1) {
|
||||||
|
const response = await sync(syncToken);
|
||||||
|
delivered.push(...response.entries);
|
||||||
|
if (!response.hasMore) return delivered;
|
||||||
|
syncToken = response.syncToken;
|
||||||
|
}
|
||||||
|
throw new Error('sync did not finish within ten pages');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped to this book: the shared e2e database can still hold Kobo-eligible books from an
|
||||||
|
// earlier suite, and this one is not about how many entries a sync carries.
|
||||||
|
function deliveredMetadata(entries: SyncEntry[]): BookMetadata[] {
|
||||||
|
return entries.flatMap((entry) => {
|
||||||
|
const metadata = entry.NewEntitlement?.BookMetadata ?? entry.ChangedProductMetadata?.BookMetadata;
|
||||||
|
return metadata && metadata.EntitlementId === entitlementId ? [metadata] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A drained device is told nothing until something changes, so the id last delivered is the id
|
||||||
|
// the device is holding. Every test compares against that rather than re-syncing for it.
|
||||||
|
async function drainDelivered(): Promise<BookMetadata[]> {
|
||||||
|
const delivered = deliveredMetadata(await drainEntries());
|
||||||
|
const last = delivered[delivered.length - 1];
|
||||||
|
if (last) coverIdHeldByDevice = last.CoverImageId;
|
||||||
|
return delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A book only reports a metadata change once its snapshot row is settled, so quiet this book
|
||||||
|
// before each test. A sync is never entirely empty: a syncToKobo collection re-sends its
|
||||||
|
// ChangedTag every time, which is why this waits on the book rather than on the whole payload.
|
||||||
|
async function settleDevice(): Promise<void> {
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const delivered = await drainDelivered();
|
||||||
|
if (delivered.length === 0) return;
|
||||||
|
}
|
||||||
|
throw new Error('device never settled: the book kept reporting changes');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thumbnailStatus(coverImageId: string): Promise<number> {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/kobo/${device.token}/v1/books/${coverImageId}/thumbnail/400/600/false/image.jpg`,
|
||||||
|
});
|
||||||
|
return response.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editMetadata(payload: Record<string, unknown>): Promise<void> {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/books/${bookId}/metadata`,
|
||||||
|
headers: authHeader(ctx.adminToken),
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createReaderStateIsolationE2EContext();
|
||||||
|
library = await createLibraryWithFolder(ctx, { name: `kobo-cover-identity-${randomUUID()}` });
|
||||||
|
|
||||||
|
const path = await createEpubFixture(library.folderPath, 'kobo-cover-identity.epub', {
|
||||||
|
title: 'Kobo Cover Identity',
|
||||||
|
uid: `urn:uuid:${randomUUID()}`,
|
||||||
|
});
|
||||||
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
||||||
|
bookId = (await locateBookByAbsolutePath(ctx, path)).bookId;
|
||||||
|
|
||||||
|
const [user] = await ctx.db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.username, 'reader-state-e2e-admin'));
|
||||||
|
userId = user!.id;
|
||||||
|
|
||||||
|
const coverDir = join(process.env.APP_DATA_PATH!, 'covers', String(bookId));
|
||||||
|
await mkdir(coverDir, { recursive: true });
|
||||||
|
await writeFile(join(coverDir, 'cover_custom.jpg'), COVER_BYTES);
|
||||||
|
await ctx.db.update(schema.bookMetadata).set({ coverSource: 'custom', coverUpdatedAt: new Date() }).where(eq(schema.bookMetadata.bookId, bookId));
|
||||||
|
|
||||||
|
const collectionResponse = await ctx.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/collections',
|
||||||
|
headers: authHeader(ctx.adminToken),
|
||||||
|
payload: { name: `Kobo Cover Identity ${randomUUID().slice(0, 8)}`, icon: 'book', syncToKobo: true },
|
||||||
|
});
|
||||||
|
expect([200, 201]).toContain(collectionResponse.statusCode);
|
||||||
|
|
||||||
|
const addBooksResponse = await ctx.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/collections/${(collectionResponse.json() as { id: number }).id}/books`,
|
||||||
|
headers: authHeader(ctx.adminToken),
|
||||||
|
payload: { bookIds: [bookId] },
|
||||||
|
});
|
||||||
|
expect([200, 201]).toContain(addBooksResponse.statusCode);
|
||||||
|
|
||||||
|
const deviceResponse = await ctx.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/kobo/devices',
|
||||||
|
headers: authHeader(ctx.adminToken),
|
||||||
|
payload: { name: 'Kobo Cover Identity' },
|
||||||
|
});
|
||||||
|
expect([200, 201]).toContain(deviceResponse.statusCode);
|
||||||
|
device = deviceResponse.json() as KoboDevice;
|
||||||
|
|
||||||
|
// The entitlement row is minted lazily by the first sync, so drain first and resolve after.
|
||||||
|
const initialEntries = await drainEntries();
|
||||||
|
const [identity] = await ctx.db
|
||||||
|
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
||||||
|
.from(schema.koboBookEntitlements)
|
||||||
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.bookId, bookId)));
|
||||||
|
entitlementId = identity!.entitlementId;
|
||||||
|
|
||||||
|
const initial = deliveredMetadata(initialEntries);
|
||||||
|
expect(initial).toHaveLength(1);
|
||||||
|
coverIdHeldByDevice = initial[0]!.CoverImageId;
|
||||||
|
await settleDevice();
|
||||||
|
}, 180_000);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await settleDevice();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the CoverImageId across a metadata edit while still delivering the change', async () => {
|
||||||
|
const coverIdBefore = coverIdHeldByDevice;
|
||||||
|
expect(await thumbnailStatus(coverIdBefore)).toBe(200);
|
||||||
|
|
||||||
|
await editMetadata({ title: 'Kobo Cover Identity, Revised', publisher: 'Repro Press' });
|
||||||
|
|
||||||
|
const delivered = await drainDelivered();
|
||||||
|
expect(delivered).toHaveLength(1);
|
||||||
|
// The edit still reaches the device; only the cover identity holds still.
|
||||||
|
expect(delivered[0]!.Title).toBe('Kobo Cover Identity, Revised');
|
||||||
|
expect(delivered[0]!.Publisher?.Name).toBe('Repro Press');
|
||||||
|
expect(delivered[0]!.CoverImageId).toBe(coverIdBefore);
|
||||||
|
expect(await thumbnailStatus(coverIdBefore)).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the CoverImageId across a series edit', async () => {
|
||||||
|
const coverIdBefore = coverIdHeldByDevice;
|
||||||
|
|
||||||
|
await editMetadata({ seriesName: 'Repro Cycle', seriesIndex: '2' });
|
||||||
|
|
||||||
|
const delivered = await drainDelivered();
|
||||||
|
expect(delivered).toHaveLength(1);
|
||||||
|
expect(delivered[0]!.CoverImageId).toBe(coverIdBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the CoverImageId across a derived write no user ever made', async () => {
|
||||||
|
const coverIdBefore = coverIdHeldByDevice;
|
||||||
|
const [before] = await ctx.db
|
||||||
|
.select({ coverUpdatedAt: schema.bookMetadata.coverUpdatedAt })
|
||||||
|
.from(schema.bookMetadata)
|
||||||
|
.where(eq(schema.bookMetadata.bookId, bookId));
|
||||||
|
|
||||||
|
// The shape of BookEmbedderRepository.saveEmbedding and updateMetadataScore: one derived
|
||||||
|
// column, no mention of updatedAt, which $onUpdateFn bumps anyway.
|
||||||
|
await ctx.db.update(schema.bookMetadata).set({ metadataScore: 42 }).where(eq(schema.bookMetadata.bookId, bookId));
|
||||||
|
|
||||||
|
const [after] = await ctx.db
|
||||||
|
.select({ coverUpdatedAt: schema.bookMetadata.coverUpdatedAt, updatedAt: schema.bookMetadata.updatedAt })
|
||||||
|
.from(schema.bookMetadata)
|
||||||
|
.where(eq(schema.bookMetadata.bookId, bookId));
|
||||||
|
expect(after!.updatedAt.getTime()).toBeGreaterThan(before!.coverUpdatedAt!.getTime());
|
||||||
|
expect(after!.coverUpdatedAt!.getTime()).toBe(before!.coverUpdatedAt!.getTime());
|
||||||
|
|
||||||
|
// And the id the device is actually handed on the next delivery is still the same one.
|
||||||
|
await editMetadata({ title: 'Kobo Cover Identity, Rescored' });
|
||||||
|
const delivered = await drainDelivered();
|
||||||
|
expect(delivered).toHaveLength(1);
|
||||||
|
expect(delivered[0]!.CoverImageId).toBe(coverIdBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still resolves cover ids a device was handed before the id scheme settled', async () => {
|
||||||
|
const [identity] = await ctx.db
|
||||||
|
.select({ coverImageId: schema.koboBookEntitlements.coverImageId })
|
||||||
|
.from(schema.koboBookEntitlements)
|
||||||
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.bookId, bookId)));
|
||||||
|
|
||||||
|
expect(await thumbnailStatus(identity!.coverImageId)).toBe(200);
|
||||||
|
expect(await thumbnailStatus(`${identity!.coverImageId}_1700000000000`)).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes the CoverImageId when the cover itself changes', async () => {
|
||||||
|
const coverIdBefore = coverIdHeldByDevice;
|
||||||
|
|
||||||
|
const deleteResponse = await ctx.app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/v1/books/${bookId}/cover`,
|
||||||
|
headers: authHeader(ctx.adminToken),
|
||||||
|
});
|
||||||
|
expect(deleteResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const delivered = await drainDelivered();
|
||||||
|
expect(delivered).toHaveLength(1);
|
||||||
|
expect(delivered[0]!.CoverImageId).not.toBe(coverIdBefore);
|
||||||
|
|
||||||
|
const [row] = await ctx.db
|
||||||
|
.select({ coverUpdatedAt: schema.bookMetadata.coverUpdatedAt })
|
||||||
|
.from(schema.bookMetadata)
|
||||||
|
.where(eq(schema.bookMetadata.bookId, bookId));
|
||||||
|
expect(delivered[0]!.CoverImageId).toBe(`${delivered[0]!.CoverImageId.split('_')[0]}_${row!.coverUpdatedAt!.getTime()}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -527,7 +527,7 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
|
|||||||
language: 'en',
|
language: 'en',
|
||||||
pageCount: 321,
|
pageCount: 321,
|
||||||
seriesName: 'Series Prime',
|
seriesName: 'Series Prime',
|
||||||
seriesIndex: 2.5,
|
seriesIndex: '2.5',
|
||||||
rating: 5,
|
rating: 5,
|
||||||
googleBooksId: 'google-101',
|
googleBooksId: 'google-101',
|
||||||
goodreadsId: 'gr-101',
|
goodreadsId: 'gr-101',
|
||||||
@@ -971,7 +971,7 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
|
|||||||
language: 'en',
|
language: 'en',
|
||||||
pageCount: 222,
|
pageCount: 222,
|
||||||
seriesName: 'Variant Series',
|
seriesName: 'Variant Series',
|
||||||
seriesIndex: 1.25,
|
seriesIndex: '1.25',
|
||||||
rating: 4,
|
rating: 4,
|
||||||
googleBooksId: 'variant-google-id',
|
googleBooksId: 'variant-google-id',
|
||||||
durationSeconds: 987,
|
durationSeconds: 987,
|
||||||
|
|||||||
Reference in New Issue
Block a user