mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): add an audiobook chapter editor
Add a Chapters tab to edit chapter timing and titles, look chapters up on Audible or in the ebook, snap starts to pauses, keep a version history and write chapters into M4B, M4A and MP3 files. Add a chapter health tool with a bulk Audible lookup queue. Editing needs Editor access to the book's library. Chapter edits keep released iOS playback working by accepting a stale manifest revision while the track layout is unchanged. Ref #1120
This commit is contained in:
@@ -1,8 +1,220 @@
|
||||
import type { BookFileWriteDisabledReason } from "./book";
|
||||
|
||||
export interface AudiobookChapter {
|
||||
title: string;
|
||||
startMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a book's chapter list came from. `tracks` is the one-chapter-per-file list the server
|
||||
* derives when nothing is stored; it is reported but never persisted.
|
||||
*/
|
||||
export const AUDIOBOOK_CHAPTER_SOURCES = ["embedded", "tracks", "provider", "migration", "manual"] as const;
|
||||
export type AudiobookChapterSource = (typeof AUDIOBOOK_CHAPTER_SOURCES)[number];
|
||||
|
||||
export const AUDIOBOOK_CHAPTER_LIMITS = { maxChapters: 2000, maxTitleLength: 500 } as const;
|
||||
|
||||
/** A book's audio file as the chapter editor lays it out on the book timeline. */
|
||||
export interface BookChapterTrack {
|
||||
fileId: number;
|
||||
name: string;
|
||||
/** Null once an earlier track's length is unknown. */
|
||||
startMs: number | null;
|
||||
durationMs: number | null;
|
||||
}
|
||||
|
||||
export interface BookChapterEditor {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Audible storefronts a chapter lookup can ask, by the region code Audible and Audnexus share. */
|
||||
export const AUDIBLE_REGIONS = ["us", "uk", "ca", "au", "de", "fr", "it", "es", "jp", "in"] as const;
|
||||
export type AudibleRegion = (typeof AUDIBLE_REGIONS)[number];
|
||||
|
||||
export interface BookChapterSources {
|
||||
/** An EPUB of this book whose table of contents can name the chapters. */
|
||||
ebookFileId: number | null;
|
||||
/** A read-along EPUB, whose narration timings can place them. */
|
||||
readAlongFileId: number | null;
|
||||
}
|
||||
|
||||
/** Formats whose chapters can be written into the file. FLAC is left out: ffmpeg cannot store them there. */
|
||||
export const CHAPTER_WRITE_FORMATS = ["m4b", "m4a", "mp3"] as const;
|
||||
|
||||
/** Whether the chapters can be written into this book's audio files, and if not, why. */
|
||||
export interface BookChapterWriteBack {
|
||||
available: boolean;
|
||||
reason: BookFileWriteDisabledReason | null;
|
||||
/** Audio files a write would touch. */
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
export interface BookChaptersState {
|
||||
chapters: AudiobookChapter[];
|
||||
source: AudiobookChapterSource | null;
|
||||
/** True when nothing is stored and the list is one chapter per track. */
|
||||
derived: boolean;
|
||||
locked: boolean;
|
||||
/** Opaque; send it back as `baseRevision` so a save cannot overwrite a newer one. */
|
||||
revision: string;
|
||||
updatedAt: string | null;
|
||||
updatedBy: BookChapterEditor | null;
|
||||
totalDurationMs: number | null;
|
||||
tracks: BookChapterTrack[];
|
||||
audibleId: string | null;
|
||||
versionCount: number;
|
||||
/** The storefront the Audible metadata provider is set to, used as the lookup default. */
|
||||
audibleRegion: AudibleRegion;
|
||||
sources: BookChapterSources;
|
||||
writeBack: BookChapterWriteBack;
|
||||
}
|
||||
|
||||
export interface SaveBookChaptersRequest {
|
||||
chapters: AudiobookChapter[];
|
||||
baseRevision: string;
|
||||
/** Protect the saved list from rescans and metadata refreshes. Defaults to true. */
|
||||
lock?: boolean;
|
||||
}
|
||||
|
||||
export interface RevertBookChaptersRequest {
|
||||
baseRevision: string;
|
||||
}
|
||||
|
||||
export interface EmbeddedBookChapters {
|
||||
chapters: AudiobookChapter[];
|
||||
}
|
||||
|
||||
/** One chapter from a lookup. Audible nests chapters under parts; a part with its own audio starts before its first child. */
|
||||
export interface ChapterLookupNode {
|
||||
title: string;
|
||||
startMs: number;
|
||||
lengthMs: number | null;
|
||||
children: ChapterLookupNode[];
|
||||
}
|
||||
|
||||
export interface ChapterLookupResult {
|
||||
/** True when provider bounds prevented a complete chapter list. */
|
||||
truncated?: boolean;
|
||||
provider: "audible" | "audnexus";
|
||||
asin: string;
|
||||
region: AudibleRegion;
|
||||
chapters: ChapterLookupNode[];
|
||||
runtimeMs: number | null;
|
||||
/** The "This is Audible" lead-in and sign-off in Audible's own timings, which many files no longer carry. */
|
||||
brandIntroMs: number;
|
||||
brandOutroMs: number;
|
||||
/** Audible's flag for timings it considers exact. Null when the source does not say. */
|
||||
isAccurate: boolean | null;
|
||||
}
|
||||
|
||||
export const EBOOK_CHAPTER_SOURCES = ["toc", "read_along"] as const;
|
||||
export type EbookChapterSource = (typeof EBOOK_CHAPTER_SOURCES)[number];
|
||||
|
||||
export interface EbookBookChapters {
|
||||
source: EbookChapterSource;
|
||||
fileId: number;
|
||||
chapters: AudiobookChapter[];
|
||||
/** True for a table of contents: titles in reading order, with no times. */
|
||||
titlesOnly: boolean;
|
||||
/** Length of the read-along narration, for comparing against the audiobook's own runtime. */
|
||||
runtimeMs: number | null;
|
||||
}
|
||||
|
||||
/** Bounds on one pause analysis; the editor splits larger jobs into several requests. */
|
||||
export const CHAPTER_PAUSE_LIMITS = {
|
||||
maxPoints: 300,
|
||||
minWindowMs: 1_000,
|
||||
maxWindowMs: 60_000,
|
||||
/** Audio decoded by one request, summed over its windows. */
|
||||
maxAudioMs: 60 * 60_000,
|
||||
} as const;
|
||||
|
||||
export interface AnalyzeBookChaptersRequest {
|
||||
/** Times on the book's timeline to listen around. */
|
||||
points: number[];
|
||||
/** Width of the window centred on each point. Defaults to 10 seconds. */
|
||||
windowMs?: number;
|
||||
}
|
||||
|
||||
/** A stretch of near-silence on the book's timeline. */
|
||||
export interface ChapterPause {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export interface ChapterPauseWindow {
|
||||
pointMs: number;
|
||||
/** The window actually listened to, clipped to the book. */
|
||||
fromMs: number;
|
||||
toMs: number;
|
||||
pauses: ChapterPause[];
|
||||
/** True when the audio in this window could not be decoded. */
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyzeBookChaptersResult {
|
||||
windows: ChapterPauseWindow[];
|
||||
}
|
||||
|
||||
/** Bounds on a waveform request around one chapter boundary. */
|
||||
export const CHAPTER_WAVEFORM_LIMITS = {
|
||||
minWindowMs: 1_000,
|
||||
maxWindowMs: 60_000,
|
||||
minBuckets: 20,
|
||||
maxBuckets: 600,
|
||||
} as const;
|
||||
|
||||
/** How loud the book is around a point, for placing a chapter boundary by eye. */
|
||||
export interface ChapterWaveform {
|
||||
/** The window drawn, clipped to the book. */
|
||||
fromMs: number;
|
||||
toMs: number;
|
||||
/** Peak level of each equal slice of the window, from 0 to 1. */
|
||||
peaks: number[];
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export interface WriteBookChaptersRequest {
|
||||
/** The saved list being written, so a newer save is never overwritten by an older one. */
|
||||
baseRevision: string;
|
||||
}
|
||||
|
||||
export interface WriteBookChaptersResult {
|
||||
status: "success" | "failed";
|
||||
filesWritten: number;
|
||||
filesTotal: number;
|
||||
}
|
||||
|
||||
export interface BookChapterVersionSummary {
|
||||
id: number;
|
||||
source: AudiobookChapterSource;
|
||||
chapterCount: number;
|
||||
createdAt: string;
|
||||
createdBy: BookChapterEditor | null;
|
||||
}
|
||||
|
||||
export interface BookChapterVersion extends BookChapterVersionSummary {
|
||||
chapters: AudiobookChapter[];
|
||||
}
|
||||
|
||||
export const BOOK_CHAPTERS_ERROR_CODES = {
|
||||
stale: "CHAPTERS_STALE",
|
||||
emptyTitle: "CHAPTERS_EMPTY_TITLE",
|
||||
firstNotZero: "CHAPTERS_FIRST_NOT_ZERO",
|
||||
notAscending: "CHAPTERS_NOT_ASCENDING",
|
||||
pastEnd: "CHAPTERS_PAST_END",
|
||||
filesUnreadable: "CHAPTERS_FILES_UNREADABLE",
|
||||
lookupNotFound: "CHAPTERS_LOOKUP_NOT_FOUND",
|
||||
lookupUnavailable: "CHAPTERS_LOOKUP_UNAVAILABLE",
|
||||
ebookUnavailable: "CHAPTERS_EBOOK_UNAVAILABLE",
|
||||
analysisTooLarge: "CHAPTERS_ANALYSIS_TOO_LARGE",
|
||||
analysisUnavailable: "CHAPTERS_ANALYSIS_UNAVAILABLE",
|
||||
writeUnavailable: "CHAPTERS_WRITE_UNAVAILABLE",
|
||||
nothingToWrite: "CHAPTERS_NOTHING_TO_WRITE",
|
||||
} as const;
|
||||
export type BookChaptersErrorCode = (typeof BOOK_CHAPTERS_ERROR_CODES)[keyof typeof BOOK_CHAPTERS_ERROR_CODES];
|
||||
|
||||
export const AUDIOBOOK_MANIFEST_SCHEMA = "bookorbit.audiobook-manifest" as const;
|
||||
export const AUDIOBOOK_MANIFEST_VERSION = 2 as const;
|
||||
|
||||
@@ -92,3 +304,14 @@ export interface NarratorRef {
|
||||
sortName: string | null;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
export type BookChapterJobKind = "write" | "read" | "revert";
|
||||
export interface BookChapterJobState {
|
||||
id: string;
|
||||
kind: BookChapterJobKind;
|
||||
status: "queued" | "processing" | "success" | "failed";
|
||||
filesWritten: number;
|
||||
filesTotal: number;
|
||||
result?: WriteBookChaptersResult | EmbeddedBookChapters | BookChaptersState;
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BookCommunityRating, MetadataFetchDiagnostics, MetadataProviderKey, MetadataSeriesMembership } from "./metadata-fetch";
|
||||
import type { BookMetadataLockField } from "./metadata-lock";
|
||||
import type { AudiobookChapter, NarratorRef } from "./audiobook";
|
||||
import type { AudiobookChapter, AudiobookChapterSource, NarratorRef } from "./audiobook";
|
||||
import type { ComicMetadataFields } from "./metadata-fetch";
|
||||
import type { BookFileWriteField, WriteResult } from "./file-write";
|
||||
import type { CustomMetadataBookValue } from "./custom-metadata";
|
||||
@@ -250,6 +250,9 @@ export type AudioMetadata = {
|
||||
durationSeconds: number | null;
|
||||
abridged: boolean;
|
||||
chapters: AudiobookChapter[] | null;
|
||||
chaptersSource?: AudiobookChapterSource | null;
|
||||
/** When the stored chapter list last changed; null for a derived or legacy list. */
|
||||
chaptersUpdatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type ReadAloudProgressSyncMode = "auto" | "disabled";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { AudiobookChapterSource } from "./audiobook";
|
||||
|
||||
/**
|
||||
* Ways an audiobook's chapters can need attention, plus `edited` for lists a person shaped by hand.
|
||||
* `needs_review` holds bulk lookups that were not confident enough to apply on their own.
|
||||
*/
|
||||
export const CHAPTER_HEALTH_CATEGORIES = ["no_chapters", "one_per_file", "numbered_tracks", "needs_review", "edited"] as const;
|
||||
export type ChapterHealthCategory = (typeof CHAPTER_HEALTH_CATEGORIES)[number];
|
||||
|
||||
export interface ChapterHealthSummary {
|
||||
counts: Record<ChapterHealthCategory, number>;
|
||||
/** How many books in each category have an Audible ASIN to look up. */
|
||||
withAsin: Record<ChapterHealthCategory, number>;
|
||||
}
|
||||
|
||||
export const CHAPTER_LOOKUP_QUEUE_STATUSES = ["queued", "processing", "applied", "review", "not_found", "failed", "skipped"] as const;
|
||||
export type ChapterLookupQueueStatus = (typeof CHAPTER_LOOKUP_QUEUE_STATUSES)[number];
|
||||
|
||||
/** Why a bulk lookup was left for a person instead of applied. */
|
||||
export const CHAPTER_LOOKUP_REVIEW_REASONS = ["runtime_mismatch", "not_exact", "implausible", "unknown_length", "locked"] as const;
|
||||
export type ChapterLookupReviewReason = (typeof CHAPTER_LOOKUP_REVIEW_REASONS)[number];
|
||||
|
||||
/** What a bulk lookup found, kept so the review list can say why it stopped short. */
|
||||
export interface ChapterLookupReviewSummary {
|
||||
provider: "audible" | "audnexus";
|
||||
chapterCount: number;
|
||||
runtimeMs: number | null;
|
||||
totalDurationMs: number | null;
|
||||
isAccurate: boolean | null;
|
||||
}
|
||||
|
||||
export interface ChapterHealthBook {
|
||||
bookId: number;
|
||||
title: string | null;
|
||||
authors: string[];
|
||||
libraryName: string;
|
||||
audioFiles: number;
|
||||
chapterCount: number;
|
||||
source: AudiobookChapterSource | null;
|
||||
audibleId: string | null;
|
||||
/** Set on `needs_review` rows. */
|
||||
review: { reason: ChapterLookupReviewReason | null; summary: ChapterLookupReviewSummary | null } | null;
|
||||
}
|
||||
|
||||
export interface ChapterHealthPage {
|
||||
items: ChapterHealthBook[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface EnqueueChapterLookupRequest {
|
||||
/** Exactly these books; or, without them, every book with an ASIN in `category`. */
|
||||
bookIds?: number[];
|
||||
category?: ChapterHealthCategory;
|
||||
libraryId?: number;
|
||||
}
|
||||
|
||||
export interface EnqueueChapterLookupResult {
|
||||
queued: number;
|
||||
/** Books left out because they have no ASIN or are not reachable. */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/** The caller's own bulk lookups: what is waiting and how the finished ones went. */
|
||||
export interface ChapterLookupQueueState {
|
||||
queued: number;
|
||||
processing: { bookId: number; title: string | null } | null;
|
||||
finished: Record<"applied" | "review" | "not_found" | "failed" | "skipped", number>;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export * from "./book-duplicates";
|
||||
export * from "./book-move";
|
||||
export * from "./book-selection";
|
||||
export * from "./audiobook";
|
||||
export * from "./chapter-health";
|
||||
export * from "./upload";
|
||||
export * from "./media";
|
||||
export * from "./collection";
|
||||
|
||||
@@ -18,6 +18,7 @@ export const BOOK_METADATA_LOCK_FIELDS = [
|
||||
"narrators",
|
||||
"durationSeconds",
|
||||
"abridged",
|
||||
"chapters",
|
||||
"googleBooksId",
|
||||
"goodreadsId",
|
||||
"amazonId",
|
||||
|
||||
Reference in New Issue
Block a user