mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(stats): repair stale KOReader file identities
Recompute the open file digest and recover previously verified matches without guessing between duplicate files. Preserve stranded statistics across lifecycle retries and advertise plugin version 1.5.3 so installed devices can update. Fixes #1497
This commit is contained in:
@@ -320,7 +320,9 @@ describe('KOReader plugin update source wiring', () => {
|
||||
expect(bookSync).toContain('metadata = BookOrbitStatsReader.primeIdentity(digest)');
|
||||
expect(statsReader).toContain('function BookOrbitStatsReader.primeIdentity(md5)');
|
||||
expect(statsReader).toContain('local book = BookOrbitStatsReader.getBook(md5)');
|
||||
expect(bookSync).toContain('local stats_ambiguous = metadata.metadata_ambiguous == true');
|
||||
expect(bookSync).toContain('local current_stats_id = stats and tonumber(stats.id_curr_book) or nil');
|
||||
expect(bookSync).toContain('local stats_ambiguous = current_stats_id == nil and metadata.metadata_ambiguous == true');
|
||||
expect(bookSync).toContain('table.insert(stats_ids, current_stats_id)');
|
||||
expect(bookSync).toContain('title = stats_ambiguous and titleFromFile(file) or (metadata.title or titleFromFile(file))');
|
||||
expect(bookSync).toContain('authors = stats_ambiguous and nil or metadata.authors');
|
||||
expect(bookSync).toContain('last_open = metadata.last_open or ts');
|
||||
@@ -331,6 +333,7 @@ describe('KOReader plugin update source wiring', () => {
|
||||
expect(bookSync).toContain('authors = ctx.snap.authors');
|
||||
expect(bookSync).toContain('last_open = ctx.snap.last_open');
|
||||
expect(bookSync).toContain('source = "current_file"');
|
||||
expect(bookSync).toContain('book_file_id = book and book.fileId or nil');
|
||||
expect(bookSync).toContain('metadata_ambiguous = ctx.snap.metadata_ambiguous');
|
||||
expect(bookSync).toContain('if ctx.snap.stats_metadata_ambiguous then');
|
||||
expect(bookSync).toContain('ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, ctx.snap.file)');
|
||||
|
||||
@@ -138,6 +138,55 @@ describe('KoreaderPluginService', () => {
|
||||
expect(result.matches).toEqual([{ hash: HASH_A, bookId: 20, bookFileId: 11 }]);
|
||||
});
|
||||
|
||||
it('links an opened file using its previously verified file identity', async () => {
|
||||
koreaderRepo.resolveBookFilesByHashes.mockResolvedValue(new Map());
|
||||
bookService.verifyFileAccess.mockResolvedValue({ id: 11, bookId: 21, libraryId: 1, format: 'epub', role: 'content' });
|
||||
const dto = {
|
||||
...deviceFields(),
|
||||
hashes: [HASH_B],
|
||||
books: [{ hash: HASH_B, source: 'current_file', bookFileId: 11, title: 'Recovered book' }],
|
||||
} as MatchCheckDto;
|
||||
|
||||
const result = await service.matchCheck(makeUser(), dto);
|
||||
|
||||
expect(koreaderRepo.upsertBookHashLink).toHaveBeenCalledWith(7, HASH_B, 11, {
|
||||
title: 'Recovered book',
|
||||
authors: null,
|
||||
lastOpen: null,
|
||||
});
|
||||
expect(result.matches).toEqual([{ hash: HASH_B, bookId: 21, bookFileId: 11 }]);
|
||||
});
|
||||
|
||||
it('does not trust a file id reported only by a statistics row', async () => {
|
||||
koreaderRepo.resolveBookFilesByHashes.mockResolvedValue(new Map());
|
||||
const dto = {
|
||||
...deviceFields(),
|
||||
hashes: [HASH_B],
|
||||
books: [{ hash: HASH_B, source: 'statistics', bookFileId: 11 }],
|
||||
} as MatchCheckDto;
|
||||
|
||||
const result = await service.matchCheck(makeUser(), dto);
|
||||
|
||||
expect(bookService.verifyFileAccess).not.toHaveBeenCalled();
|
||||
expect(koreaderRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||
expect(result.matches).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not link an opened file when the reported file is inaccessible', async () => {
|
||||
koreaderRepo.resolveBookFilesByHashes.mockResolvedValue(new Map());
|
||||
bookService.verifyFileAccess.mockRejectedValue(new Error('forbidden'));
|
||||
const dto = {
|
||||
...deviceFields(),
|
||||
hashes: [HASH_B],
|
||||
books: [{ hash: HASH_B, source: 'current_file', bookFileId: 11 }],
|
||||
} as MatchCheckDto;
|
||||
|
||||
const result = await service.matchCheck(makeUser(), dto);
|
||||
|
||||
expect(koreaderRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||
expect(result.matches).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not rewrite an existing link when the explicit catalog file already resolves', async () => {
|
||||
const dto = {
|
||||
...deviceFields(),
|
||||
|
||||
@@ -325,7 +325,7 @@ export class KoreaderPluginService {
|
||||
const linked = new Map<string, ResolvedBookFile>();
|
||||
for (const hash of hashes) {
|
||||
const candidate = candidates.get(hash);
|
||||
if (!candidate?.bookFileId || candidate.source !== 'file') continue;
|
||||
if (!candidate?.bookFileId || (candidate.source !== 'file' && candidate.source !== 'current_file')) continue;
|
||||
if (resolved.get(hash)?.bookFileId === candidate.bookFileId) continue;
|
||||
|
||||
const file = await this.bookService.verifyFileAccess(candidate.bookFileId, user).catch(() => null);
|
||||
|
||||
@@ -265,10 +265,10 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
|
||||
expect(await waitForAchievement(session!.userId, 'power_hour')).toBe(true);
|
||||
});
|
||||
|
||||
// A file hash is not unique: identical bytes in two places are two book file rows. The resolver
|
||||
// has to land on the same one every time, or a device's statistics split across both and the
|
||||
// plugin sees its sync target move, which discards its local upload cursor.
|
||||
it('resolves a hash shared by two book files to the same row on every upload', async () => {
|
||||
// A file hash is not unique: identical bytes in two places are two book file rows. The server
|
||||
// must refuse to guess, then honor an accessible file identity previously verified by the
|
||||
// plugin so every later upload has one stable target.
|
||||
it('requires and persists an explicit identity for a hash shared by two book files', async () => {
|
||||
const duplicatePath = join(library.folderPath, 'page-stats-book-copy.epub');
|
||||
await copyFile(epubPath, duplicatePath);
|
||||
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
||||
@@ -282,6 +282,31 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
|
||||
expect(sharing[0]!.id).toBe(epub.bookFileId);
|
||||
|
||||
const firstStart = BASE_EPOCH + 20_000_000;
|
||||
const ambiguous = await uploadPageStats([{ page: 1, startTime: firstStart, durationSeconds: 55, totalPages: TOTAL_PAGES }]);
|
||||
expect(ambiguous.results).toEqual([]);
|
||||
expect(ambiguous.unmatched).toEqual([fileHash]);
|
||||
|
||||
const beforeLink = await ctx.db
|
||||
.select({ id: schema.koreaderPageStats.id })
|
||||
.from(schema.koreaderPageStats)
|
||||
.where(and(eq(schema.koreaderPageStats.deviceId, DEVICE_ID), gte(schema.koreaderPageStats.startTime, firstStart)));
|
||||
expect(beforeLink).toEqual([]);
|
||||
|
||||
const matchResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/koreader/plugin/match-check',
|
||||
headers: deviceHeaders(),
|
||||
payload: {
|
||||
deviceId: DEVICE_ID,
|
||||
deviceModel: 'E2E',
|
||||
pluginVersion: '1.5.3',
|
||||
hashes: [fileHash],
|
||||
books: [{ hash: fileHash, source: 'current_file', bookFileId: epub.bookFileId }],
|
||||
},
|
||||
});
|
||||
expect(matchResponse.statusCode).toBe(201);
|
||||
expect(matchResponse.json().matches).toEqual([{ hash: fileHash, bookId: epub.bookId, bookFileId: epub.bookFileId }]);
|
||||
|
||||
await uploadPageStats([{ page: 1, startTime: firstStart, durationSeconds: 55, totalPages: TOTAL_PAGES }]);
|
||||
await uploadPageStats([{ page: 2, startTime: firstStart + 60, durationSeconds: 55, totalPages: TOTAL_PAGES }]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user