mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(scanner): play disc-folder audiobook tracks disc by disc
The walker sorted a book's files by file name, so tracks in disc folders that reuse names, such as CD 1/01.mp3 and CD 2/01.mp3, interleaved in playback, merged chapters, and written track numbers. Order them by their whole path instead, everywhere a track order is chosen. A rescan that only reorders a book's existing tracks now moves every audio bookmark and the listening progress percentage to the same moment of the same track, and rebuilds merged chapters. Disc folders are never skipped by incremental scans, so existing libraries are fixed by their next scan. Also inventory the notebook and dashboard highlights routes in the authorization matrix, and pin the app locale in two date tests that depended on the machine's locale.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { AchievementItem } from '@bookorbit/types'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { i18n } from '@/i18n'
|
||||
import TieredAchievementCard from '../components/TieredAchievementCard.vue'
|
||||
import type { TieredGroup } from '../types'
|
||||
|
||||
@@ -189,9 +190,11 @@ describe('TieredAchievementCard', () => {
|
||||
})
|
||||
|
||||
it('expanded panel shows tier dates for earned tiers', async () => {
|
||||
// Dates follow the app locale, not the machine's, so pin both sides to it.
|
||||
i18n.global.locale.value = 'en'
|
||||
const wrapper = mount(TieredAchievementCard, { props: { group: makeGroup() } })
|
||||
await wrapper.trigger('click')
|
||||
const expectedDate = new Date('2026-05-15T00:00:00Z').toLocaleDateString(undefined, {
|
||||
const expectedDate = new Date('2026-05-15T00:00:00Z').toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { i18n } from '@/i18n'
|
||||
import {
|
||||
cfiRangesMatch,
|
||||
cfiRangesOverlap,
|
||||
@@ -18,8 +19,10 @@ describe('epub utils', () => {
|
||||
})
|
||||
|
||||
it('formats dates using locale short month/day/year format', () => {
|
||||
// Dates follow the app locale, not the machine's, so pin both sides to it.
|
||||
i18n.global.locale.value = 'en'
|
||||
const iso = '2026-02-14T12:00:00.000Z'
|
||||
const expected = new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
const expected = new Date(iso).toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
|
||||
expect(formatDate(iso)).toBe(expected)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { audioBookPercentageAt, compareBookFilePaths, remapAudioBookPosition, type AudioTrackSpan } from './audio-track-order.utils';
|
||||
|
||||
describe('compareBookFilePaths', () => {
|
||||
it('keeps disc folders together instead of interleaving tracks that share a name', () => {
|
||||
const files = ['/b/Book/CD 2/01.mp3', '/b/Book/CD 1/02.mp3', '/b/Book/CD 2/02.mp3', '/b/Book/CD 1/01.mp3'];
|
||||
expect(files.sort(compareBookFilePaths)).toEqual(['/b/Book/CD 1/01.mp3', '/b/Book/CD 1/02.mp3', '/b/Book/CD 2/01.mp3', '/b/Book/CD 2/02.mp3']);
|
||||
});
|
||||
|
||||
it('orders disc numbers naturally', () => {
|
||||
const files = ['/b/Book/Disc 10/01.mp3', '/b/Book/Disc 2/01.mp3', '/b/Book/Disc 1/01.mp3'];
|
||||
expect(files.sort(compareBookFilePaths)).toEqual(['/b/Book/Disc 1/01.mp3', '/b/Book/Disc 2/01.mp3', '/b/Book/Disc 10/01.mp3']);
|
||||
});
|
||||
|
||||
it('is natural file-name order within one folder', () => {
|
||||
const files = ['/b/Book 2/Part 10.mp3', '/b/Book 2/Part 2.mp3', '/b/Book 2/Part 1.mp3'];
|
||||
expect(files.sort(compareBookFilePaths)).toEqual(['/b/Book 2/Part 1.mp3', '/b/Book 2/Part 2.mp3', '/b/Book 2/Part 10.mp3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remapAudioBookPosition', () => {
|
||||
// Interleaved: CD1/01 (100), CD2/01 (200), CD1/02 (300). Grouped: CD1/01, CD1/02, CD2/01.
|
||||
const interleaved: AudioTrackSpan[] = [
|
||||
{ fileId: 1, durationSeconds: 100 },
|
||||
{ fileId: 3, durationSeconds: 200 },
|
||||
{ fileId: 2, durationSeconds: 300 },
|
||||
];
|
||||
const grouped: AudioTrackSpan[] = [
|
||||
{ fileId: 1, durationSeconds: 100 },
|
||||
{ fileId: 2, durationSeconds: 300 },
|
||||
{ fileId: 3, durationSeconds: 200 },
|
||||
];
|
||||
|
||||
it('keeps a position on the same moment of the same track', () => {
|
||||
expect(remapAudioBookPosition(50, interleaved, grouped)).toBe(50);
|
||||
expect(remapAudioBookPosition(150, interleaved, grouped)).toBe(450);
|
||||
expect(remapAudioBookPosition(350, interleaved, grouped)).toBe(150);
|
||||
});
|
||||
|
||||
it('places a track boundary at the start of the following track', () => {
|
||||
expect(remapAudioBookPosition(100, interleaved, grouped)).toBe(400);
|
||||
expect(remapAudioBookPosition(0, interleaved, grouped)).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a position past the end inside the last track of the old order', () => {
|
||||
expect(remapAudioBookPosition(900, interleaved, grouped)).toBe(400);
|
||||
expect(remapAudioBookPosition(-5, interleaved, grouped)).toBe(0);
|
||||
});
|
||||
|
||||
it('cannot place a position when a track length is unknown', () => {
|
||||
expect(remapAudioBookPosition(50, [{ fileId: 1, durationSeconds: null }, ...interleaved.slice(1)], grouped)).toBeNull();
|
||||
expect(remapAudioBookPosition(50, interleaved, [{ fileId: 1, durationSeconds: 0 }, ...grouped.slice(1)])).toBeNull();
|
||||
});
|
||||
|
||||
it('cannot place a position when the orders hold different tracks', () => {
|
||||
expect(remapAudioBookPosition(50, interleaved, grouped.slice(0, 2))).toBeNull();
|
||||
expect(remapAudioBookPosition(50, interleaved, [...grouped.slice(0, 2), { fileId: 9, durationSeconds: 200 }])).toBeNull();
|
||||
expect(remapAudioBookPosition(50, [], [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('audioBookPercentageAt', () => {
|
||||
const order: AudioTrackSpan[] = [
|
||||
{ fileId: 1, durationSeconds: 100 },
|
||||
{ fileId: 2, durationSeconds: 300 },
|
||||
];
|
||||
|
||||
it('is the share of the book reached at an offset into a track', () => {
|
||||
expect(audioBookPercentageAt(order, 2, 100)).toBe(50);
|
||||
expect(audioBookPercentageAt(order, 1, 0)).toBe(0);
|
||||
expect(audioBookPercentageAt(order, 2, 1000)).toBe(100);
|
||||
});
|
||||
|
||||
it('is null for a track outside the order or an unknown length', () => {
|
||||
expect(audioBookPercentageAt(order, 9, 10)).toBeNull();
|
||||
expect(audioBookPercentageAt([{ fileId: 1, durationSeconds: null }], 1, 10)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { naturalCompare } from './natural-sort.utils';
|
||||
|
||||
/**
|
||||
* Natural order of a book's files by their whole path. A book's files share its folder, so within
|
||||
* one folder this is natural file-name order, and a disc folder (`CD 1`, `CD 2`) keeps its tracks
|
||||
* together instead of interleaving them with the next disc's by file name.
|
||||
*/
|
||||
export function compareBookFilePaths(leftPath: string, rightPath: string): number {
|
||||
return naturalCompare(leftPath, rightPath);
|
||||
}
|
||||
|
||||
export type AudioTrackSpan = { fileId: number; durationSeconds: number | null };
|
||||
|
||||
/** Seconds from the start of the book to the start of each track, or null when a length is unknown. */
|
||||
function trackStarts(order: readonly AudioTrackSpan[]): Map<number, number> | null {
|
||||
const starts = new Map<number, number>();
|
||||
let elapsed = 0;
|
||||
for (const track of order) {
|
||||
if (track.durationSeconds === null || !(track.durationSeconds > 0)) return null;
|
||||
starts.set(track.fileId, elapsed);
|
||||
elapsed += track.durationSeconds;
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
function sameTracks(left: readonly AudioTrackSpan[], right: readonly AudioTrackSpan[]): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
const rightIds = new Set(right.map((track) => track.fileId));
|
||||
return rightIds.size === right.length && left.every((track) => rightIds.has(track.fileId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an absolute book position recorded under one track order to the same moment of the same
|
||||
* track under another order. Null when the orders hold different tracks or a track length is
|
||||
* unknown, because the position cannot then be placed in a track.
|
||||
*/
|
||||
export function remapAudioBookPosition(positionSeconds: number, previous: readonly AudioTrackSpan[], next: readonly AudioTrackSpan[]): number | null {
|
||||
if (previous.length === 0 || !sameTracks(previous, next)) return null;
|
||||
const previousStarts = trackStarts(previous);
|
||||
const nextStarts = trackStarts(next);
|
||||
if (!previousStarts || !nextStarts) return null;
|
||||
|
||||
const position = Math.max(0, positionSeconds);
|
||||
let track = previous[previous.length - 1]!;
|
||||
for (const candidate of previous) {
|
||||
if (position < previousStarts.get(candidate.fileId)! + candidate.durationSeconds!) {
|
||||
track = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const offset = Math.min(position - previousStarts.get(track.fileId)!, track.durationSeconds!);
|
||||
return nextStarts.get(track.fileId)! + offset;
|
||||
}
|
||||
|
||||
/** Share of the book reached at an offset into one track, 0 to 100, or null when it cannot be placed. */
|
||||
export function audioBookPercentageAt(order: readonly AudioTrackSpan[], fileId: number, offsetSeconds: number): number | null {
|
||||
const starts = trackStarts(order);
|
||||
const start = starts?.get(fileId);
|
||||
if (start === undefined) return null;
|
||||
const total = order.reduce((sum, track) => sum + track.durationSeconds!, 0);
|
||||
return Math.max(0, Math.min(100, ((start + Math.max(0, offsetSeconds)) / total) * 100));
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { basename } from 'node:path';
|
||||
|
||||
import { getBookMediaProfile, type CoverMedia, type CoverMedium } from '@bookorbit/types';
|
||||
|
||||
import { naturalCompare } from './natural-sort.utils';
|
||||
import { compareBookFilePaths } from './audio-track-order.utils';
|
||||
|
||||
type BookMediaFileRow = { format: string | null; role: string };
|
||||
|
||||
@@ -42,9 +40,9 @@ export function coverFetchInputs(state: CoverFetchState): {
|
||||
|
||||
type AudioTrackOrderRow = { sortOrder?: number | null; absolutePath: string };
|
||||
|
||||
/** Playback order of an audiobook's tracks: explicit sort order first, unset last, then natural file name. */
|
||||
/** Playback order of an audiobook's tracks: explicit sort order first, unset last, then natural path. */
|
||||
export function compareAudioTracks(left: AudioTrackOrderRow, right: AudioTrackOrderRow): number {
|
||||
const byOrder = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
||||
if (byOrder !== 0) return byOrder;
|
||||
return naturalCompare(basename(left.absolutePath), basename(right.absolutePath));
|
||||
return compareBookFilePaths(left.absolutePath, right.absolutePath);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ describe('selectEmbeddedCoverSources', () => {
|
||||
expect(sources.audio?.absolutePath).toBe('/b/Part 1.mp3');
|
||||
});
|
||||
|
||||
it('takes the first track of the first disc when discs reuse file names', () => {
|
||||
const files = [file('/b/CD 2/01.mp3'), file('/b/CD 1/02.mp3'), file('/b/CD 1/01.mp3')];
|
||||
|
||||
expect(selectEmbeddedCoverSources(files, ['mp3']).audio?.absolutePath).toBe('/b/CD 1/01.mp3');
|
||||
});
|
||||
|
||||
it('gives a read-along EPUB no audio source, because its art is the book cover', () => {
|
||||
const sources = selectEmbeddedCoverSources([file('/b/book.epub', { mediaOverlayAvailable: true })], ['epub']);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isAudioFormat, type CoverMedium } from '@bookorbit/types';
|
||||
import { basename } from 'path';
|
||||
|
||||
import { compareBookFilePaths } from '../../common/utils/audio-track-order.utils';
|
||||
import { naturalCompare } from '../../common/utils/natural-sort.utils';
|
||||
import { selectPrimaryFile } from '../../common/utils/primary-file-selection.utils';
|
||||
|
||||
@@ -32,7 +33,7 @@ export function selectEmbeddedCoverSources<T extends CoverSourceCandidate>(
|
||||
const ebooks = content.filter((file) => !isAudioFormat(file.format!.toLowerCase()));
|
||||
const audio = content
|
||||
.filter((file) => isAudioFormat(file.format!.toLowerCase()))
|
||||
.sort((a, b) => naturalCompare(basename(a.absolutePath), basename(b.absolutePath)));
|
||||
.sort((a, b) => compareBookFilePaths(a.absolutePath, b.absolutePath));
|
||||
return {
|
||||
ebook: selectPrimaryFile(ebooks, formatPriority, { allowZeroByteFallback: true }),
|
||||
audio: audio[0] ?? null,
|
||||
|
||||
@@ -21,7 +21,7 @@ import { mapWithConcurrency } from '../../common/utils/batch.utils';
|
||||
import { SelfWriteRegistry } from '../../common/services/self-write-registry.service';
|
||||
import { selectPrimaryFile } from '../../common/utils/primary-file-selection.utils';
|
||||
import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils';
|
||||
import { naturalCompare } from '../../common/utils/natural-sort.utils';
|
||||
import { compareBookFilePaths } from '../../common/utils/audio-track-order.utils';
|
||||
import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils';
|
||||
import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils';
|
||||
import { SeriesExpectedCountService } from '../../common/services/series-expected-count.service';
|
||||
@@ -3267,9 +3267,7 @@ export class BookService {
|
||||
const meta = book.book_metadata;
|
||||
const customMetadata = await this.customMetadataService.getBookValues(id, book.books.libraryId);
|
||||
const hasAudioFiles = fileRows.some((f) => f.format && isAudioFormat(f.format));
|
||||
const orderedFileRows = hasAudioFiles
|
||||
? [...fileRows].sort((a, b) => naturalCompare(basename(a.absolutePath), basename(b.absolutePath)))
|
||||
: fileRows;
|
||||
const orderedFileRows = hasAudioFiles ? [...fileRows].sort((a, b) => compareBookFilePaths(a.absolutePath, b.absolutePath)) : fileRows;
|
||||
const resolvedChapters = this.resolveChapters(meta?.chapters as AudiobookChapter[] | null | undefined, orderedFileRows);
|
||||
const supplementalFields = buildBookDetailSupplementalFields({
|
||||
readStatus,
|
||||
|
||||
@@ -379,6 +379,21 @@ describe('disc folder flattening', () => {
|
||||
expect(candidates[0].files).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('orders tracks disc by disc when the discs reuse file names', async () => {
|
||||
await file('Book/CD 2/01.mp3');
|
||||
await file('Book/CD 1/02.mp3');
|
||||
await file('Book/CD 10/01.mp3');
|
||||
await file('Book/CD 1/01.mp3');
|
||||
await file('Book/CD 2/02.mp3');
|
||||
|
||||
const { candidates } = await findBookCandidates(root);
|
||||
const expected = ['CD 1/01.mp3', 'CD 1/02.mp3', 'CD 2/01.mp3', 'CD 2/02.mp3', 'CD 10/01.mp3'].map((name) => join(root, 'Book', name));
|
||||
expect(candidates[0].files.map((f) => f.absolutePath)).toEqual(expected);
|
||||
|
||||
const single = await buildSingleBookCandidate(join(root, 'Book'), root);
|
||||
expect(single?.files.map((f) => f.absolutePath)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('flattens Disc, Disk, Part, Pt, Side patterns', async () => {
|
||||
await file('MultiDisc/Disc 1/track-01.mp3');
|
||||
await file('MultiDisc/Disk 2/track-02.mp3');
|
||||
|
||||
@@ -2,7 +2,7 @@ import { usableFileTime } from '../../../common/utils/file-time.utils';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { basename, dirname, join, relative } from 'path';
|
||||
|
||||
import { naturalCompare } from '../../../common/utils/natural-sort.utils';
|
||||
import { compareBookFilePaths } from '../../../common/utils/audio-track-order.utils';
|
||||
import { buildNameExcludeMatcher, walkDirectoryTree, WALK_MAX_PATH_LENGTH } from '../../../common/fs-walk.utils';
|
||||
import { classifyFile, isPrimaryFormat, isAudioFormat, type FileRole } from './classify';
|
||||
|
||||
@@ -269,13 +269,14 @@ export async function findBookCandidates(
|
||||
}
|
||||
|
||||
// If any primary file is an audio format, treat the entire folder as one audiobook.
|
||||
// Files are natural-sorted by basename so playback order is deterministic.
|
||||
// Files are natural-sorted by path so playback order is deterministic and flattened disc
|
||||
// folders play disc by disc.
|
||||
const hasAudio = primaryFiles.some((f) => {
|
||||
return f.format !== null && f.format !== undefined && isAudioFormat(f.format);
|
||||
});
|
||||
|
||||
if (hasAudio) {
|
||||
const sorted = [...files].sort((a, b) => naturalCompare(basename(a.absolutePath), basename(b.absolutePath)));
|
||||
const sorted = [...files].sort((a, b) => compareBookFilePaths(a.absolutePath, b.absolutePath));
|
||||
candidates.push({ folderPath: dir, files: sorted });
|
||||
continue;
|
||||
}
|
||||
@@ -416,6 +417,6 @@ export async function buildSingleBookCandidate(
|
||||
|
||||
return {
|
||||
folderPath,
|
||||
files: allFiles.sort((a, b) => naturalCompare(basename(a.absolutePath), basename(b.absolutePath))),
|
||||
files: allFiles.sort((a, b) => compareBookFilePaths(a.absolutePath, b.absolutePath)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -444,4 +444,42 @@ describe('ScannerRepository', () => {
|
||||
await expect(repo.findLatestScanJobs([])).resolves.toEqual([]);
|
||||
expect(db.selectDistinctOn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('remapAudioPositions', () => {
|
||||
it('moves audio bookmarks and progress percentages in one transaction, skipping finished books and unplaceable rows', async () => {
|
||||
const { repo, queues, db, chains } = makeRepo();
|
||||
queues.select.push([
|
||||
{ id: 1, positionSeconds: 350 },
|
||||
{ id: 2, positionSeconds: 50 },
|
||||
{ id: 3, positionSeconds: 9999 },
|
||||
]);
|
||||
queues.select.push([
|
||||
{ userId: 7, currentFileId: 21, positionSeconds: 20, percentage: 12 },
|
||||
{ userId: 8, currentFileId: 21, positionSeconds: 20, percentage: 100 },
|
||||
{ userId: 9, currentFileId: 99, positionSeconds: 20, percentage: 30 },
|
||||
]);
|
||||
const remap = vi.fn((seconds: number) => (seconds === 350 ? 150 : seconds === 9999 ? null : seconds));
|
||||
const percentageAt = vi.fn((fileId: number) => (fileId === 21 ? 42 : null));
|
||||
|
||||
await expect(repo.remapAudioPositions(4, remap, percentageAt)).resolves.toEqual({ bookmarks: 1, progress: 1 });
|
||||
|
||||
expect(db.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(chains.select[0]!.mocks.for).toHaveBeenCalledWith('update');
|
||||
expect(chains.select[1]!.mocks.for).toHaveBeenCalledWith('update');
|
||||
expect(db.update).toHaveBeenCalledTimes(2);
|
||||
expect(chains.update[0]!.mocks.set).toHaveBeenCalledWith({ positionSeconds: 150 });
|
||||
expect(chains.update[1]!.mocks.set).toHaveBeenCalledWith({ percentage: 42 });
|
||||
expect(percentageAt.mock.calls).toEqual([
|
||||
[21, 20],
|
||||
[99, 20],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the formats of a book's files", async () => {
|
||||
const { repo, queues } = makeRepo();
|
||||
queues.select.push([{ id: 3, format: 'mp3' }]);
|
||||
|
||||
await expect(repo.findBookFileFormats(4)).resolves.toEqual([{ id: 3, format: 'mp3' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { deleteBookFilesWithHashInvalidation, deleteBooksWithHashInvalidation } from '../../db/book-file-hash-history';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, asc, desc, eq, gte, inArray, lt, ne, or, sql } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import {
|
||||
audiobookProgress,
|
||||
authors,
|
||||
bookAuthors,
|
||||
bookFiles,
|
||||
bookmarks,
|
||||
bookGenres,
|
||||
bookMetadata,
|
||||
books,
|
||||
@@ -389,6 +391,60 @@ export class ScannerRepository {
|
||||
return this.db.select().from(bookFiles).where(eq(bookFiles.bookId, bookId));
|
||||
}
|
||||
|
||||
async findBookFileFormats(bookId: number): Promise<Array<{ id: number; format: string | null }>> {
|
||||
return this.db.select({ id: bookFiles.id, format: bookFiles.format }).from(bookFiles).where(eq(bookFiles.bookId, bookId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a book's stored audio positions after its track order changed: every user's audio
|
||||
* bookmarks, which hold a position from the start of the book, and the progress percentage, which
|
||||
* follows from it. Rows are locked so none is read under one order and written under the other.
|
||||
*/
|
||||
async remapAudioPositions(
|
||||
bookId: number,
|
||||
remapPosition: (positionSeconds: number) => number | null,
|
||||
percentageAt: (fileId: number, offsetSeconds: number) => number | null,
|
||||
): Promise<{ bookmarks: number; progress: number }> {
|
||||
return this.db.transaction(async (tx) => {
|
||||
let bookmarksUpdated = 0;
|
||||
const bookmarkRows = await tx
|
||||
.select({ id: bookmarks.id, positionSeconds: bookmarks.positionSeconds })
|
||||
.from(bookmarks)
|
||||
.where(and(eq(bookmarks.bookId, bookId), isNull(bookmarks.cfi), isNotNull(bookmarks.positionSeconds)))
|
||||
.for('update');
|
||||
for (const row of bookmarkRows) {
|
||||
const positionSeconds = remapPosition(row.positionSeconds!);
|
||||
if (positionSeconds === null || positionSeconds === row.positionSeconds) continue;
|
||||
await tx.update(bookmarks).set({ positionSeconds }).where(eq(bookmarks.id, row.id));
|
||||
bookmarksUpdated++;
|
||||
}
|
||||
|
||||
let progressUpdated = 0;
|
||||
const progressRows = await tx
|
||||
.select({
|
||||
userId: audiobookProgress.userId,
|
||||
currentFileId: audiobookProgress.currentFileId,
|
||||
positionSeconds: audiobookProgress.positionSeconds,
|
||||
percentage: audiobookProgress.percentage,
|
||||
})
|
||||
.from(audiobookProgress)
|
||||
.where(eq(audiobookProgress.bookId, bookId))
|
||||
.for('update');
|
||||
for (const row of progressRows) {
|
||||
// A finished book stays finished: its last position was the end of the old order.
|
||||
if (row.percentage >= 100) continue;
|
||||
const percentage = percentageAt(row.currentFileId, row.positionSeconds);
|
||||
if (percentage === null || percentage === row.percentage) continue;
|
||||
await tx
|
||||
.update(audiobookProgress)
|
||||
.set({ percentage })
|
||||
.where(and(eq(audiobookProgress.userId, row.userId), eq(audiobookProgress.bookId, bookId)));
|
||||
progressUpdated++;
|
||||
}
|
||||
return { bookmarks: bookmarksUpdated, progress: progressUpdated };
|
||||
});
|
||||
}
|
||||
|
||||
async findBookFilesByBookIds(bookIds: number[]): Promise<(typeof bookFiles.$inferSelect)[]> {
|
||||
if (bookIds.length === 0) return [];
|
||||
return this.db.select().from(bookFiles).where(inArray(bookFiles.bookId, bookIds));
|
||||
|
||||
@@ -125,6 +125,8 @@ function makeRepo(overrides: Record<string, unknown> = {}) {
|
||||
findBooksByFolderPath: vi.fn().mockResolvedValue([]),
|
||||
findBookFilesByBookId: vi.fn().mockResolvedValue([]),
|
||||
findBookFilesByBookIds: vi.fn().mockResolvedValue([]),
|
||||
findBookFileFormats: vi.fn().mockResolvedValue([]),
|
||||
remapAudioPositions: vi.fn().mockResolvedValue({ bookmarks: 0, progress: 0 }),
|
||||
findBookById: vi.fn().mockResolvedValue(null),
|
||||
findBooksByIds: vi.fn().mockResolvedValue([]),
|
||||
findBookCardData: vi.fn().mockResolvedValue({ rows: [], authorRows: [], fileRows: [], genreRows: [] }),
|
||||
@@ -1804,6 +1806,142 @@ describe('audio multi-file audiobook', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('disc folders reordered by a rescan', () => {
|
||||
// Stored under the old file-name order, which interleaved the discs.
|
||||
const tracks = [
|
||||
{ id: 20, path: '/library/Book/CD 1/01.mp3', ino: 9101n, oldOrder: 0, duration: 100 },
|
||||
{ id: 21, path: '/library/Book/CD 2/01.mp3', ino: 9102n, oldOrder: 1, duration: 200 },
|
||||
{ id: 22, path: '/library/Book/CD 1/02.mp3', ino: 9103n, oldOrder: 2, duration: 300 },
|
||||
{ id: 23, path: '/library/Book/CD 2/02.mp3', ino: 9104n, oldOrder: 3, duration: 400 },
|
||||
];
|
||||
const pathOrder = ['/library/Book/CD 1/01.mp3', '/library/Book/CD 1/02.mp3', '/library/Book/CD 2/01.mp3', '/library/Book/CD 2/02.mp3'];
|
||||
|
||||
function stat(track: (typeof tracks)[number]) {
|
||||
return makeFileStat({
|
||||
absolutePath: track.path,
|
||||
relPath: track.path.slice('/library/'.length),
|
||||
ino: track.ino,
|
||||
format: 'mp3',
|
||||
role: 'content',
|
||||
});
|
||||
}
|
||||
|
||||
function reorderRepo(overrides: Record<string, unknown> = {}) {
|
||||
return makeRepo({
|
||||
findBooksByLibraryFolder: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Book', status: 'present' }]),
|
||||
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue(
|
||||
tracks.map((track) =>
|
||||
makeBookFile({
|
||||
id: track.id,
|
||||
bookId: 1,
|
||||
absolutePath: track.path,
|
||||
relPath: track.path.slice('/library/'.length),
|
||||
ino: track.ino,
|
||||
format: 'mp3',
|
||||
sortOrder: track.oldOrder,
|
||||
durationSeconds: track.duration,
|
||||
}),
|
||||
),
|
||||
),
|
||||
findBookFileFormats: vi.fn().mockResolvedValue(tracks.map((track) => ({ id: track.id, format: 'mp3' }))),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function scan(repo: ReturnType<typeof makeRepo>, files = pathOrder.map((path) => stat(tracks.find((track) => track.path === path)!))) {
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Book', files)],
|
||||
skippedDirs: new Set(),
|
||||
unchangedDirs: new Set(),
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
await service.startScan(1, 'manual');
|
||||
await done;
|
||||
}
|
||||
|
||||
it('moves bookmarks to the same moment of the same track and rebuilds chapters in disc order', async () => {
|
||||
const repo = reorderRepo();
|
||||
await scan(repo);
|
||||
|
||||
expect(repo.remapAudioPositions).toHaveBeenCalledTimes(1);
|
||||
const [bookId, remap, percentageAt] = repo.remapAudioPositions.mock.calls[0] as [
|
||||
number,
|
||||
(seconds: number) => number | null,
|
||||
(fileId: number, offset: number) => number | null,
|
||||
];
|
||||
expect(bookId).toBe(1);
|
||||
// 350 s was 50 s into CD 1/02, which followed CD 1/01 and CD 2/01; it now follows CD 1/01 only.
|
||||
expect(remap(350)).toBe(150);
|
||||
// 120 s was 20 s into CD 2/01, which now follows both CD 1 tracks.
|
||||
expect(remap(120)).toBe(420);
|
||||
expect(percentageAt(21, 20)).toBe(42);
|
||||
expect(mockMetadata.extractMergedAudioChapters).toHaveBeenCalledWith(1, pathOrder, { filesChanged: true });
|
||||
});
|
||||
|
||||
it('leaves positions alone when the order did not change', async () => {
|
||||
const repo = reorderRepo({
|
||||
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue(
|
||||
pathOrder.map((path, index) => {
|
||||
const track = tracks.find((candidate) => candidate.path === path)!;
|
||||
return makeBookFile({
|
||||
id: track.id,
|
||||
bookId: 1,
|
||||
absolutePath: path,
|
||||
relPath: path.slice('/library/'.length),
|
||||
ino: track.ino,
|
||||
format: 'mp3',
|
||||
sortOrder: index,
|
||||
});
|
||||
}),
|
||||
),
|
||||
});
|
||||
await scan(repo);
|
||||
|
||||
expect(repo.remapAudioPositions).not.toHaveBeenCalled();
|
||||
expect(mockMetadata.extractMergedAudioChapters).toHaveBeenCalledWith(1, pathOrder, { filesChanged: false });
|
||||
});
|
||||
|
||||
it('leaves positions alone when a track is new, because they cannot be placed', async () => {
|
||||
const repo = reorderRepo();
|
||||
const added = makeFileStat({
|
||||
absolutePath: '/library/Book/CD 1/03.mp3',
|
||||
relPath: 'Book/CD 1/03.mp3',
|
||||
ino: 9105n,
|
||||
format: 'mp3',
|
||||
role: 'content',
|
||||
});
|
||||
await scan(repo, [
|
||||
...pathOrder.slice(0, 2).map((path) => stat(tracks.find((track) => track.path === path)!)),
|
||||
added,
|
||||
...pathOrder.slice(2).map((path) => stat(tracks.find((track) => track.path === path)!)),
|
||||
]);
|
||||
|
||||
expect(repo.findBookFileFormats).not.toHaveBeenCalled();
|
||||
expect(repo.remapAudioPositions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves positions alone when a stored track is no longer in the folder, but still rebuilds chapters', async () => {
|
||||
const repo = reorderRepo({
|
||||
findBookFileFormats: vi.fn().mockResolvedValue([...tracks.map((track) => ({ id: track.id, format: 'mp3' })), { id: 29, format: 'mp3' }]),
|
||||
});
|
||||
await scan(repo);
|
||||
|
||||
expect(repo.remapAudioPositions).not.toHaveBeenCalled();
|
||||
expect(mockMetadata.extractMergedAudioChapters).toHaveBeenCalledWith(1, pathOrder, { filesChanged: true });
|
||||
});
|
||||
|
||||
it('still rebuilds chapters when remapping positions fails', async () => {
|
||||
const repo = reorderRepo({ remapAudioPositions: vi.fn().mockRejectedValue(new Error('deadlock detected')) });
|
||||
await scan(repo);
|
||||
|
||||
expect(mockMetadata.extractMergedAudioChapters).toHaveBeenCalledWith(1, pathOrder, { filesChanged: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves non-content audio files out of the merged chapter list', async () => {
|
||||
const content = makeFileStat({ absolutePath: '/library/Book/01.m4b', relPath: 'Book/01.m4b', format: 'm4b', role: 'content' });
|
||||
const other = makeFileStat({
|
||||
|
||||
@@ -2,7 +2,8 @@ import { ConflictException, Injectable, Logger, NotFoundException, OnApplication
|
||||
import { createHash } from 'crypto';
|
||||
import { mapWithConcurrency } from '../../common/utils/batch.utils';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { naturalCompare } from '../../common/utils/natural-sort.utils';
|
||||
import { audioBookPercentageAt, compareBookFilePaths, remapAudioBookPosition, type AudioTrackSpan } from '../../common/utils/audio-track-order.utils';
|
||||
import { compareAudioTracks } from '../../common/utils/book-media.utils';
|
||||
import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils';
|
||||
import { selectPrimaryFile } from '../../common/utils/primary-file-selection.utils';
|
||||
import { SelfWriteRegistry } from '../../common/services/self-write-registry.service';
|
||||
@@ -157,6 +158,8 @@ interface RegisteredFile {
|
||||
wasReassigned: boolean;
|
||||
wasChanged: boolean;
|
||||
mediaOverlayAvailable: boolean;
|
||||
/** This book's stored sort order for the file before this scan; null when the file is new to it. */
|
||||
previousSortOrder: number | null;
|
||||
}
|
||||
|
||||
interface MetadataExtractionSource {
|
||||
@@ -1751,6 +1754,55 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps audio bookmarks and progress on the same moment of the same track when a rescan only
|
||||
* reorders a book's existing tracks, such as the disc folders that used to interleave. Positions
|
||||
* are left alone when tracks were also added or removed, because they cannot then be placed.
|
||||
* `audioFiles` is in the new playback order. Returns whether the order changed.
|
||||
*/
|
||||
private async remapAudioPositionsIfReordered(bookId: number, audioFiles: RegisteredFile[]): Promise<boolean> {
|
||||
if (audioFiles.length < 2 || audioFiles.some((file) => file.isNew || file.wasReassigned || file.previousSortOrder === null)) {
|
||||
return false;
|
||||
}
|
||||
const previous = [...audioFiles].sort((left, right) =>
|
||||
compareAudioTracks(
|
||||
{ sortOrder: left.previousSortOrder, absolutePath: left.absolutePath },
|
||||
{ sortOrder: right.previousSortOrder, absolutePath: right.absolutePath },
|
||||
),
|
||||
);
|
||||
if (previous.every((file, index) => file.fileId === audioFiles[index]!.fileId)) return false;
|
||||
|
||||
const event = 'scanner.remap_audio_positions';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] bookId=${bookId} tracks=${audioFiles.length} - audio track order changed`);
|
||||
try {
|
||||
const storedAudioIds = (await this.scannerRepo.findBookFileFormats(bookId))
|
||||
.filter((file) => file.format !== null && isAudioFormat(file.format))
|
||||
.map((file) => file.id);
|
||||
const currentIds = new Set(audioFiles.map((file) => file.fileId));
|
||||
if (storedAudioIds.length !== currentIds.size || storedAudioIds.some((id) => !currentIds.has(id))) {
|
||||
this.logger.log(`[${event}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} skipped=tracks_changed - positions left unchanged`);
|
||||
return true;
|
||||
}
|
||||
const span = (file: RegisteredFile): AudioTrackSpan => ({ fileId: file.fileId, durationSeconds: file.durationSeconds });
|
||||
const previousSpans = previous.map(span);
|
||||
const nextSpans = audioFiles.map(span);
|
||||
const updated = await this.scannerRepo.remapAudioPositions(
|
||||
bookId,
|
||||
(positionSeconds) => remapAudioBookPosition(positionSeconds, previousSpans, nextSpans),
|
||||
(fileId, offsetSeconds) => audioBookPercentageAt(nextSpans, fileId, offsetSeconds),
|
||||
);
|
||||
this.logger.log(
|
||||
`[${event}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} bookmarks=${updated.bookmarks} progress=${updated.progress} - audio positions remapped`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`[${event}] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - audio position remap failed`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async processCandidate(
|
||||
candidate: BookCandidate,
|
||||
libraryId: number,
|
||||
@@ -1804,7 +1856,9 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
|
||||
const fileCount: ScanCounts = { addedCount: 0, updatedCount: 0, missingCount: 0 };
|
||||
let processResult: ProcessedFileResult;
|
||||
const knownDurationSeconds = fileByPath.get(fileStat.absolutePath)?.durationSeconds ?? null;
|
||||
const knownEntry = fileByPath.get(fileStat.absolutePath);
|
||||
const knownDurationSeconds = knownEntry?.durationSeconds ?? null;
|
||||
const previousSortOrder = knownEntry?.bookId === book.id ? knownEntry.sortOrder : null;
|
||||
|
||||
try {
|
||||
processResult = await this.processFile(
|
||||
@@ -1843,6 +1897,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
wasReassigned: processResult.reassigned,
|
||||
wasChanged: processResult.changed,
|
||||
mediaOverlayAvailable: fileByPath.get(fileStat.absolutePath)?.mediaOverlayAvailable === true,
|
||||
previousSortOrder,
|
||||
});
|
||||
retainedFileIds.add(processResult.fileId);
|
||||
}
|
||||
@@ -1880,6 +1935,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
(file) => hasMetadataSourceChanged(file) || file.durationSeconds === null || file.durationSeconds <= 0,
|
||||
);
|
||||
const winnerIsAudio = winner !== null && winner.format !== null && isAudioFormat(winner.format);
|
||||
const audioOrderChanged = await this.remapAudioPositionsIfReordered(book.id, audioContentFiles);
|
||||
|
||||
// 3a: Extract audio-specific fields (chapters, narrators) from the first audio file if any audio
|
||||
// file is new, reassigned, or changed. Skipped when the audio winner is itself the leading
|
||||
@@ -1895,9 +1951,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
if (!audioWinnerLeadsMetadata && changedAudioFiles.length > 0 && !selfWriteInProgress) {
|
||||
const sortedAudio = [...audioContentFiles].sort((a, b) =>
|
||||
basename(a.absolutePath).localeCompare(basename(b.absolutePath), undefined, { numeric: true }),
|
||||
);
|
||||
const sortedAudio = [...audioContentFiles].sort((a, b) => compareBookFilePaths(a.absolutePath, b.absolutePath));
|
||||
const firstAudio = sortedAudio[0];
|
||||
try {
|
||||
await this.metadataService.extractAudioChaptersAndNarrators(book.id, firstAudio.absolutePath, firstAudio.format!);
|
||||
@@ -1945,9 +1999,10 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
// what repairs the ones scanned before chapters were merged; it settles after one pass.
|
||||
if (audioContentFiles.length > 1) {
|
||||
const orderedAudioPaths = [...audioContentFiles]
|
||||
.sort((a, b) => naturalCompare(basename(a.absolutePath), basename(b.absolutePath)))
|
||||
.sort((a, b) => compareBookFilePaths(a.absolutePath, b.absolutePath))
|
||||
.map((file) => file.absolutePath);
|
||||
const filesChanged = shouldExtractMetadata || changedAudioFiles.length > 0;
|
||||
// A reorder keeps the total length, so stored chapters still look complete; rebuild them anyway.
|
||||
const filesChanged = shouldExtractMetadata || changedAudioFiles.length > 0 || audioOrderChanged;
|
||||
try {
|
||||
await this.metadataService.extractMergedAudioChapters(book.id, orderedAudioPaths, { filesChanged });
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"totalRoutes": 623,
|
||||
"totalRoutes": 630,
|
||||
"byPermission": {
|
||||
"(none)": 271,
|
||||
"(none)": 278,
|
||||
"Permission.ManageMetadataConfig": 34,
|
||||
"Permission.EmailSend": 30,
|
||||
"Permission.LibraryEditMetadata": 35,
|
||||
|
||||
@@ -838,5 +838,89 @@
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notebook/notebook.controller.ts",
|
||||
"className": "NotebookController",
|
||||
"methodName": "entries",
|
||||
"httpMethod": "GET",
|
||||
"path": "/notebook/entries",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notebook/notebook.controller.ts",
|
||||
"className": "NotebookController",
|
||||
"methodName": "overview",
|
||||
"httpMethod": "GET",
|
||||
"path": "/notebook/overview",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notebook/notebook.controller.ts",
|
||||
"className": "NotebookController",
|
||||
"methodName": "books",
|
||||
"httpMethod": "GET",
|
||||
"path": "/notebook/books",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notebook/notebook.controller.ts",
|
||||
"className": "NotebookController",
|
||||
"methodName": "review",
|
||||
"httpMethod": "GET",
|
||||
"path": "/notebook/review",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notebook/notebook.controller.ts",
|
||||
"className": "NotebookController",
|
||||
"methodName": "onThisDay",
|
||||
"httpMethod": "GET",
|
||||
"path": "/notebook/on-this-day",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notebook/notebook.controller.ts",
|
||||
"className": "NotebookController",
|
||||
"methodName": "trash",
|
||||
"httpMethod": "GET",
|
||||
"path": "/notebook/trash",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/dashboard/dashboard.controller.ts",
|
||||
"className": "DashboardController",
|
||||
"methodName": "getHighlights",
|
||||
"httpMethod": "GET",
|
||||
"path": "/dashboard/widgets/highlights",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user