feat: add read-aloud and media playback support

Add EPUB narration, shared media controls, and native device session integration.

Apply the generated migrations and update all first-party clients alongside the server.

BREAKING CHANGE: introduce incompatible database and API contracts for media playback
This commit is contained in:
neon
2026-09-19 02:02:24 +00:00
parent c292d6ccd2
commit e0d3d42150
844 changed files with 151839 additions and 3415 deletions
@@ -16,6 +16,11 @@ describe("toReadingSessionSourceBucket", () => {
expect(toReadingSessionSourceBucket("manual")).toBe("bookorbit");
});
it("keeps iOS and Apple Watch in distinct buckets", () => {
expect(toReadingSessionSourceBucket("ios")).toBe("ios");
expect(toReadingSessionSourceBucket("watchos")).toBe("watchos");
});
it("maps koreader to koreader", () => {
expect(toReadingSessionSourceBucket("koreader")).toBe("koreader");
});
@@ -31,19 +36,21 @@ describe("toReadingSessionSourceBucket", () => {
});
describe("reading session source bucket constants", () => {
it("exposes exactly three buckets", () => {
expect(READING_SESSION_SOURCE_BUCKETS).toEqual(["bookorbit", "koreader", "kobo"]);
it("exposes all five display buckets", () => {
expect(READING_SESSION_SOURCE_BUCKETS).toEqual(["bookorbit", "ios", "watchos", "koreader", "kobo"]);
});
it("labels every bucket", () => {
expect(READING_SESSION_SOURCE_BUCKET_LABELS).toEqual({
bookorbit: "BookOrbit",
ios: "iOS app",
watchos: "Apple Watch",
koreader: "KOReader",
kobo: "Kobo",
});
});
it("builds a zero-filled record", () => {
expect(emptySourceBucketRecord()).toEqual({ bookorbit: 0, koreader: 0, kobo: 0 });
expect(emptySourceBucketRecord()).toEqual({ bookorbit: 0, ios: 0, watchos: 0, koreader: 0, kobo: 0 });
});
});
+8
View File
@@ -25,6 +25,8 @@ export interface AchievementItem {
earned: boolean;
awardedAt: string | null;
context: Record<string, unknown> | null;
contextBookId?: number | null;
contextBookTitle?: string | null;
currentProgress: number | null;
}
@@ -41,3 +43,9 @@ export interface AchievementCatalogueResponse {
totalEarned: number;
totalAvailable: number;
}
export interface AchievementCelebrationClaim {
claimId: string;
expiresAt: string;
achievement: AchievementItem;
}
+202
View File
@@ -0,0 +1,202 @@
import type { ReadingSessionSourceBucket } from "./reading-session-source-bucket";
import type { UserProgressFunnelComparison } from "./user-statistics";
export const ACTIVITY_MEDIA_BUCKETS = ["reading", "listening"] as const;
export type ActivityMediaBucket = (typeof ACTIVITY_MEDIA_BUCKETS)[number];
export type ActivityGoalStatus = "ahead" | "on_pace" | "behind";
export interface ActivityDurationBreakdown {
totalSeconds: number;
readingSeconds: number;
listeningSeconds: number;
}
export interface ActivityDayPoint extends ActivityDurationBreakdown {
day: string;
sessionsCount: number;
bySource: Record<ReadingSessionSourceBucket, number>;
}
export interface ActivityOverviewResponse {
generatedAt: string;
timezone: string;
libraryIds: number[];
achievementsEnabled: boolean;
snapshot: {
today: ActivityDurationBreakdown;
lastSevenDays: ActivityDurationBreakdown;
previousSevenDays: ActivityDurationBreakdown;
currentStreak: number;
longestStreak: number;
completedBooksYtd: number;
};
dailyActivity: {
days: ActivityDayPoint[];
};
calendar: ActivityCalendarResponse;
goal: {
year: number;
goalBooks: number | null;
completedBooks: number;
projectedBooks: number;
status: ActivityGoalStatus | null;
points: Array<{
month: number;
actualCumulative: number;
targetCumulative: number | null;
}>;
};
rhythm: {
windowDays: number;
sessionsCount: number;
weekdays: Array<{
dayOfWeek: number;
averageSeconds: number;
averageReadingSeconds: number;
averageListeningSeconds: number;
sessionsCount: number;
averageBySource: Record<ReadingSessionSourceBucket, number>;
}>;
hours: Array<
ActivityDurationBreakdown & {
hour: number;
sessionsCount: number;
bySource: Record<ReadingSessionSourceBucket, number>;
}
>;
favoriteDayOfWeek: number | null;
peakHour: number | null;
};
completion: {
availableSince: string | null;
months: Array<{ year: number; month: number; count: number }>;
funnel: UserProgressFunnelComparison;
};
sources: {
windowDays: number;
totals: ActivityDurationBreakdown;
slices: Array<{
bucket: ReadingSessionSourceBucket;
totalSeconds: number;
}>;
};
pace: {
eligibleSessions: number;
medianDurationSeconds: number | null;
medianProgressDelta: number | null;
byMedia: Array<{
bucket: ActivityMediaBucket;
eligibleSessions: number;
medianDurationSeconds: number | null;
medianProgressDelta: number | null;
}>;
};
}
export interface ActivityCalendarResponse {
year: number;
availableYears: number[];
days: ActivityDayPoint[];
}
export interface ActivityDayDetailResponse {
day: string;
timezone: string;
totals: ActivityDayPoint;
sessions: Array<{
id: number;
bookId: number;
bookFileId: number | null;
bookTitle: string | null;
hasCover: boolean;
coverVersion: string | null;
format: string | null;
mediaBucket: ActivityMediaBucket;
sourceBucket: ReadingSessionSourceBucket;
startedAt: string;
endedAt: string;
durationOnDaySeconds: number;
progressDelta: number | null;
}>;
}
export const ACTIVITY_SESSION_DURATION_BUCKETS = ["short", "medium", "long", "extended"] as const;
export type ActivitySessionDurationBucket = (typeof ACTIVITY_SESSION_DURATION_BUCKETS)[number];
export interface ActivitySessionPatternsResponse {
generatedAt: string;
timezone: string;
windowDays: number;
sampleCount: number;
typicalDurationSeconds: number | null;
medianDurationSeconds: number | null;
sessionsPerActiveWeek: number | null;
durationBuckets: Array<{
bucket: ActivitySessionDurationBucket;
sessionsCount: number;
totalSeconds: number;
}>;
weeks: Array<{
weekStart: string;
sessionsCount: number;
totalSeconds: number;
}>;
}
export const ACTIVITY_COMPLETION_SPEED_BUCKETS = ["up_to_7", "8_to_30", "31_to_90", "91_to_180", "181_to_365", "over_365"] as const;
export type ActivityCompletionSpeedBucket = (typeof ACTIVITY_COMPLETION_SPEED_BUCKETS)[number];
export interface ActivityCompletionSpeedResponse {
generatedAt: string;
timezone: string;
windowDays: number;
selectedFormat: string | null;
availableFormats: string[];
sampleCount: number;
medianDays: number | null;
percentile75Days: number | null;
percentile90Days: number | null;
buckets: Array<{
bucket: ActivityCompletionSpeedBucket;
completionsCount: number;
}>;
}
export interface ActivityGenreTimeResponse {
generatedAt: string;
timezone: string;
windowDays: number;
totalSeconds: number;
genres: Array<{
genre: string;
totalSeconds: number;
authors: Array<{
author: string | null;
totalSeconds: number;
books: Array<{
bookId: number;
title: string | null;
totalSeconds: number;
}>;
}>;
}>;
}
export const ACTIVITY_PACE_DURATION_BANDS = ["under_15", "15_to_30", "30_to_60", "60_to_120", "120_to_240"] as const;
export type ActivityPaceDurationBand = (typeof ACTIVITY_PACE_DURATION_BANDS)[number];
export interface ActivityPaceDetailResponse {
generatedAt: string;
timezone: string;
windowDays: number;
selectedFormat: string | null;
selectedMedia: ActivityMediaBucket | null;
availableFormats: string[];
sampleCount: number;
bands: Array<{
band: ActivityPaceDurationBand;
medianProgressDelta: number | null;
sampleCount: number;
}>;
}
+1
View File
@@ -98,6 +98,7 @@ export interface AnnotationItem {
chapterIndex: number | null;
highlightedAt: string;
createdAt: string;
updatedAt?: string | null;
/** Present for PDF highlights; null/absent for EPUB and device-synced formats. */
pdf?: AnnotationPdfPosition | null;
}
+7
View File
@@ -0,0 +1,7 @@
export interface AppFeatures {
podcasts: boolean;
}
export const APP_FEATURES = Object.freeze({
podcasts: false,
} as const satisfies AppFeatures);
+14
View File
@@ -44,6 +44,18 @@ export enum AuditAction {
LibraryBulkRename = "library.bulk_rename",
LibraryRecomputeAddedAt = "library.recompute_added_at",
PodcastCreate = "podcast.create",
PodcastUpdate = "podcast.update",
PodcastArchive = "podcast.archive",
PodcastRestore = "podcast.restore",
PodcastPurge = "podcast.purge",
PodcastMerge = "podcast.merge",
PodcastDownload = "podcast.download",
PodcastDownloadRemove = "podcast.download.remove",
PodcastRetentionUpdate = "podcast.retention.update",
PodcastOpmlImport = "podcast.opml.import",
PodcastOpmlExport = "podcast.opml.export",
BookUpload = "book.upload",
BookMetadataUpdate = "book.metadata.update",
BookMetadataLocksUpdate = "book.metadata.locks.update",
@@ -147,6 +159,8 @@ export enum AuditResource {
User = "user",
Library = "library",
Book = "book",
Podcast = "podcast",
PodcastEpisode = "podcast_episode",
Collection = "collection",
SmartScope = "smart_scope",
BookDockFile = "book_dock_file",
+27 -4
View File
@@ -138,10 +138,8 @@ export interface OidcBaseConfig {
autoProvision: OidcAutoProvision;
}
export interface OidcCallbackResult {
export interface OidcCallbackResult extends AuthResponse {
mode: "login";
accessToken: string;
user: AuthUser;
}
export interface OidcLinkResult {
@@ -159,17 +157,42 @@ export interface OidcPreviewResult {
export type OidcCallbackResponse = OidcCallbackResult | OidcLinkResult | OidcPreviewResult;
export interface AuthResponse {
export type AuthClientKind = "web" | "native";
export interface AuthClientOptions {
clientKind?: AuthClientKind;
deviceLabel?: string;
}
export interface NativeCredentials {
accessToken: string;
accessTokenExpiresAt: string;
refreshToken: string;
refreshTokenExpiresAt: string;
sessionId: number;
}
export interface AuthResponse extends RefreshResponse {
user: AuthUser;
}
export interface NativeAuthResponse extends NativeCredentials {
user: AuthUser;
}
export interface RefreshResponse {
accessToken: string;
accessTokenExpiresAt: string;
sessionId: number;
refreshToken?: string;
refreshTokenExpiresAt?: string;
}
export interface Session {
id: number;
clientKind: AuthClientKind;
deviceLabel: string | null;
authenticationMethod: AuthenticationMethod;
createdAt: string;
expiresAt: string;
}
+26
View File
@@ -7,6 +7,7 @@ import type { CustomMetadataBookValue } from "./custom-metadata";
import type { CoverAspectRatio } from "./library";
import { DEFAULT_FORMAT_PRIORITY } from "./library";
import type { SeriesIndex } from "./series-index";
import type { EpubMediaOverlayCapability } from "./epub";
// Derived rather than duplicated: these two lists describe the same set of formats,
// and maintaining them separately let BOOK_FORMATS fall behind on azw and kepub.
@@ -84,6 +85,7 @@ export type BookFileRef = {
format: string | null;
role: string;
sizeBytes: number | null;
mediaOverlay?: EpubMediaOverlayCapability | null;
};
/** The kinds a real file can be. `BookMediaKind` adds the case where no format identifies one. */
@@ -177,6 +179,7 @@ export type BookDetailFile = {
createdAt: string;
filename: string | null;
durationSeconds: number | null;
mediaOverlay?: EpubMediaOverlayCapability | null;
};
export type ProviderIds = Partial<Record<MetadataProviderKey, string | null>>;
@@ -188,6 +191,28 @@ export type AudioMetadata = {
chapters: AudiobookChapter[] | null;
};
export type ReadAloudProgressSyncMode = "auto" | "disabled";
export type ReadAloudProgressSyncState = "enabled" | "disabled" | "unavailable";
export type ReadAloudProgressSyncUnavailableReason =
| "no_media_overlay_epub"
| "no_audio_files"
| "missing_duration"
| "duration_mismatch";
export type ReadAloudProgressSync = {
mode: ReadAloudProgressSyncMode;
state: ReadAloudProgressSyncState;
unavailableReason: ReadAloudProgressSyncUnavailableReason | null;
overlayFileId: number | null;
audioDurationSeconds: number | null;
overlayDurationSeconds: number | null;
durationDifferenceSeconds: number | null;
durationDifferenceRatio: number | null;
koreaderDownloadAvailable: boolean;
};
export type BookFileWriteDisabledReason =
"library_disabled" | "no_primary_file" | "format_not_supported" | "format_disabled" | "file_exceeds_size_limit";
@@ -235,6 +260,7 @@ export type BookDetail = {
metadataScore: number | null;
readStatus: UserBookStatus | null;
audioMetadata: AudioMetadata | null;
readAloudSync: ReadAloudProgressSync;
formatPriority: string[];
comicMetadata: ComicMetadataFields | null;
customMetadata: CustomMetadataBookValue[];
+15
View File
@@ -1,14 +1,21 @@
import type { MediaType } from "./media";
export type Collection = {
id: number;
userId: number;
mediaType: MediaType;
name: string;
icon: string | null;
description: string | null;
isPublic: boolean;
isOwner: boolean;
/** Books only; Kobo has no podcasts. */
syncToKobo: boolean;
displayOrder: number;
/** Member books, on book collections. Always 0 on podcast collections. */
bookCount: number;
/** Member shows, on podcast collections. Always 0 on book collections. */
podcastCount: number;
memberCount?: number;
createdAt: string;
updatedAt: string;
@@ -18,12 +25,20 @@ export type CreateCollectionPayload = {
name: string;
icon: string;
description?: string;
mediaType?: MediaType;
isPublic?: boolean;
syncToKobo?: boolean;
};
export type CollectionSummary = {
id: number;
mediaType: MediaType;
name: string;
bookCount: number;
podcastCount: number;
};
/** The count that belongs to a collection's medium, so callers do not have to branch. */
export function collectionItemCount(collection: Pick<Collection, "mediaType" | "bookCount" | "podcastCount">): number {
return collection.mediaType === "podcasts" ? collection.podcastCount : collection.bookCount;
}
+65 -1
View File
@@ -4,6 +4,7 @@ export const SCROLLER_TYPE = {
RECENTLY_ADDED: "recently-added",
CONTINUE_READING: "continue-reading",
CONTINUE_LISTENING: "continue-listening",
CONTINUE_PODCASTS: "continue-podcasts",
WANT_TO_READ: "want-to-read",
UP_NEXT_IN_SERIES: "up-next-in-series",
RANDOM: "random",
@@ -13,15 +14,65 @@ export const SCROLLER_TYPE = {
export type ScrollerType = (typeof SCROLLER_TYPE)[keyof typeof SCROLLER_TYPE];
export const SCROLLER_TYPES = Object.values(SCROLLER_TYPE) as ReadonlyArray<ScrollerType>;
/**
* Shelves whose rows are books, which is every shelf `GET /dashboard/scrollers/:type` can serve.
* Podcast shelves resolve through the podcast module's own cross-library endpoint instead, so the
* dashboard route rejects them rather than reaching into podcast tables.
*/
export const BOOK_SCROLLER_TYPE = {
RECENTLY_ADDED: SCROLLER_TYPE.RECENTLY_ADDED,
CONTINUE_READING: SCROLLER_TYPE.CONTINUE_READING,
CONTINUE_LISTENING: SCROLLER_TYPE.CONTINUE_LISTENING,
WANT_TO_READ: SCROLLER_TYPE.WANT_TO_READ,
UP_NEXT_IN_SERIES: SCROLLER_TYPE.UP_NEXT_IN_SERIES,
RANDOM: SCROLLER_TYPE.RANDOM,
SMART_SCOPE: SCROLLER_TYPE.SMART_SCOPE,
} as const;
export type BookScrollerType = (typeof BOOK_SCROLLER_TYPE)[keyof typeof BOOK_SCROLLER_TYPE];
export const BOOK_SCROLLER_TYPES = Object.values(BOOK_SCROLLER_TYPE) as ReadonlyArray<BookScrollerType>;
export const PODCAST_SCROLLER_TYPES = [SCROLLER_TYPE.CONTINUE_PODCASTS] as const;
export type PodcastScrollerType = (typeof PODCAST_SCROLLER_TYPES)[number];
export function isPodcastScrollerType(type: ScrollerType): type is PodcastScrollerType {
return (PODCAST_SCROLLER_TYPES as ReadonlyArray<ScrollerType>).includes(type);
}
export const DASHBOARD_SCROLLER_BATCH_MAX = 8;
// The server rejects a larger per-shelf limit. Shared so the client can size a
// multi-row shelf without guessing the ceiling it will be validated against.
export const DASHBOARD_SCROLLER_MAX_LIMIT = 50;
/**
* The single-shelf response for `GET /dashboard/scrollers/:type`.
*
* `total` is the size of the set the shelf speaks for, not how many books came back, so a shelf
* capped at its limit can say what it is a window onto. What that set is belongs to the shelf:
* the continue rows count what is in progress, `want-to-read` counts the list, and
* `recently-added` counts this calendar month rather than the whole library, because how many
* books you own is a different question and the library widget already answers it. Clients label
* the figure per shelf for that reason.
*
* It is deliberately nullable: two shelves cannot answer it for a price worth paying, and no
* number is better than a wrong one.
* `up-next-in-series` would have to materialise its recursive CTE in full, and `random` would
* anti-join the whole library to count a pool it only ever samples. Both send null, and a client
* shows nothing rather than falling back to `books.length`, which is only the limit it asked for.
*
* The batch route deliberately does not carry this. Only the single-shelf route is asked for a
* total today, and putting one on the batch would charge every shelf on the web dashboard for a
* count query nothing there renders.
*/
export interface DashboardScrollerResponse {
books: BookCard[];
total: number | null;
}
export interface DashboardScrollerBatchItem {
id: string;
type: ScrollerType;
type: BookScrollerType;
limit: number;
smartScopeId?: number;
}
@@ -39,6 +90,7 @@ export interface DashboardScrollerBatchResult {
export interface DashboardScrollerBatchResponse {
items: DashboardScrollerBatchResult[];
}
export interface ScrollerConfig {
id: string;
type: ScrollerType;
@@ -95,8 +147,20 @@ export interface CurrentlyReadingBook {
authors: string[];
progress: number;
hasCover: boolean;
/** The book's primary file, whatever its format. */
fileId: number | null;
fileFormat: string | null;
/**
* The three ways a book can be resumed, resolved per book so a client can offer them without a
* detail request each. The iOS home hero is the caller: one book, three possible modes, and the
* primary file alone cannot say which exist. An audiobook-primary book still reports its best
* readable file here, which is why this is not simply `fileId` again.
*/
readFileId: number | null;
readFileFormat: string | null;
/** The EPUB carrying media overlays, when the book has one. Null means no read-along. */
readAlongFileId: number | null;
hasAudio: boolean;
}
export interface CurrentlyReadingWidgetData {
+44
View File
@@ -3,6 +3,7 @@ export interface EpubManifestItem {
href: string;
mediaType: string;
properties?: string[];
mediaOverlay?: string;
size: number;
}
@@ -29,3 +30,46 @@ export interface EpubBookInfo {
metadata: Record<string, unknown>;
coverPath: string | null;
}
export interface EpubMediaOverlayCapability {
available: boolean;
durationSeconds: number | null;
}
export interface EpubMediaOverlayPlaylistItem {
index: number;
sectionIndex: number;
smilHref: string;
textHref: string;
textFragment: string | null;
audioHref: string;
audioMimeType: string;
clipBeginSeconds: number;
clipEndSeconds: number | null;
durationSeconds: number | null;
label: string | null;
}
export interface EpubMediaOverlayPlaylistSection {
index: number;
href: string;
label: string | null;
smilHref: string;
startSeconds: number;
durationSeconds: number | null;
}
export interface EpubMediaOverlayPlaylistResource {
href: string;
mediaType: string;
size: number;
}
export interface EpubMediaOverlayPlaylist {
bookId: number;
fileId: number | null;
durationSeconds: number | null;
items: EpubMediaOverlayPlaylistItem[];
sections: EpubMediaOverlayPlaylistSection[];
resources: EpubMediaOverlayPlaylistResource[];
}
+7
View File
@@ -19,6 +19,7 @@ export * from "./book-move";
export * from "./book-selection";
export * from "./audiobook";
export * from "./upload";
export * from "./media";
export * from "./collection";
export * from "./smart-scope";
export * from "./library";
@@ -29,6 +30,7 @@ export * from "./query";
export * from "./jump-buckets";
export * from "./reader-settings";
export * from "./scanner";
export * from "./search";
export * from "./opds";
export * from "./kobo";
export * from "./koreader";
@@ -53,6 +55,7 @@ export * from "./user-statistics";
export * from "./migration";
export * from "./notification";
export * from "./app-info";
export * from "./app-features";
export * from "./release-notes";
export * from "./series";
export * from "./series-index";
@@ -62,6 +65,7 @@ export * from "./entity-manager";
export * from "./font";
export * from "./dictionary";
export * from "./achievement";
export * from "./activity";
export * from "./reading-session";
export * from "./reading-session-source-bucket";
export * from "./shared-reading-insights";
@@ -73,3 +77,6 @@ export * from "./path";
export * from "./sidebar";
export * from "./cover-search-preferences";
export * from "./cron";
export * from "./tts";
export * from "./watch-download";
export * from "./podcast";
+16
View File
@@ -3,6 +3,7 @@ import type { LibraryLastScan } from "./scanner";
export type OrganizationMode = "book_per_file" | "book_per_folder";
export type CoverAspectRatio = "2/3" | "1/1";
export type AddedAtSource = "imported" | "file_modified" | "file_created";
export type LibraryType = "books" | "podcasts";
export function normalizeCoverAspectRatio(value: unknown): CoverAspectRatio {
return value === "1/1" ? "1/1" : "2/3";
@@ -47,19 +48,32 @@ export const FORMAT_LABELS: Record<string, string> = {
};
export type AccessLevel = "viewer" | "editor" | "owner";
/**
* Who owns the files under a library root. `downloads` roots belong to BookOrbit, which names,
* evicts, and re-fetches files there. `local` roots belong to the user: files are adopted in place,
* never renamed and never evicted. Book libraries only use `downloads`.
*/
export type LibraryFolderRole = "downloads" | "local";
export const LIBRARY_FOLDER_ROLES: readonly LibraryFolderRole[] = ["downloads", "local"] as const;
export interface LibraryFolder {
id: number;
path: string;
role: LibraryFolderRole;
createdAt: string;
}
export interface Library {
id: number;
type: LibraryType;
accessLevel?: AccessLevel | null;
name: string;
icon?: string | null;
displayOrder: number;
coverAspectRatio: CoverAspectRatio;
watch: boolean;
watchLocalFolders?: boolean;
autoScanCronExpression?: string | null;
metadataPrecedence: string[];
formatPriority: string[];
@@ -87,6 +101,8 @@ export interface Library {
fileRenameEnabled: boolean;
folders: LibraryFolder[];
bookCount?: number;
/** Non-archived shows in a podcast library. Null for book libraries, which hold no shows. */
podcastCount?: number | null;
createdAt: string;
updatedAt: string;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Which medium a user-owned container holds. Books and podcasts never share rows: their
* underlying tables are disjoint, so scopes and collections carry this discriminator and
* each one belongs to exactly one medium.
*/
export const MEDIA_TYPES = ["books", "podcasts"] as const;
export type MediaType = (typeof MEDIA_TYPES)[number];
export const DEFAULT_MEDIA_TYPE: MediaType = "books";
export function isMediaType(value: unknown): value is MediaType {
return (MEDIA_TYPES as readonly unknown[]).includes(value);
}
export function normalizeMediaType(value: unknown): MediaType {
return isMediaType(value) ? value : DEFAULT_MEDIA_TYPE;
}
+7
View File
@@ -27,6 +27,9 @@ export const NotificationType = {
BulkRenameCompleted: "bulk_rename_completed",
BulkRenameFailed: "bulk_rename_failed",
AchievementUnlocked: "achievement_unlocked",
PodcastEpisodePublished: "podcast_episode_published",
PodcastFeedUnhealthy: "podcast_feed_unhealthy",
PodcastDownloadFailed: "podcast_download_failed",
} as const;
export type NotificationType = (typeof NotificationType)[keyof typeof NotificationType];
@@ -56,6 +59,7 @@ export const NOTIFICATION_CATEGORY_IDS = [
"fileRename",
"bulkRename",
"achievements",
"podcasts",
] as const;
export type NotificationCategory = (typeof NOTIFICATION_CATEGORY_IDS)[number];
@@ -99,6 +103,9 @@ export const NOTIFICATION_TYPE_META: Record<NotificationType, NotificationTypeMe
[NotificationType.BulkRenameCompleted]: { category: "bulkRename", severity: "success" },
[NotificationType.BulkRenameFailed]: { category: "bulkRename", severity: "warning" },
[NotificationType.AchievementUnlocked]: { category: "achievements", severity: "success" },
[NotificationType.PodcastEpisodePublished]: { category: "podcasts", severity: "success" },
[NotificationType.PodcastFeedUnhealthy]: { category: "podcasts", severity: "warning" },
[NotificationType.PodcastDownloadFailed]: { category: "podcasts", severity: "error" },
};
export const NOTIFICATION_CATEGORIES: Record<NotificationCategory, readonly NotificationType[]> = NOTIFICATION_CATEGORY_IDS.reduce(
+10
View File
@@ -6,6 +6,11 @@ export enum Permission {
LibraryDeleteBooks = "library_delete_books",
BookDockAccess = "book_dock_access",
BookRequestAccess = "book_request_access",
PodcastManageFeeds = "podcast_manage_feeds",
PodcastDownload = "podcast_download",
PodcastEditMetadata = "podcast_edit_metadata",
PodcastManageRetention = "podcast_manage_retention",
PodcastPurge = "podcast_purge",
DemoRestricted = "demo_restricted",
// Devices & Access
@@ -44,6 +49,11 @@ export const PERMISSION_LABELS: Record<Permission, string> = {
[Permission.LibraryDeleteBooks]: "Delete books",
[Permission.BookDockAccess]: "Book Dock",
[Permission.BookRequestAccess]: "Request books",
[Permission.PodcastManageFeeds]: "Manage podcast feeds",
[Permission.PodcastDownload]: "Download podcast episodes",
[Permission.PodcastEditMetadata]: "Edit podcast metadata",
[Permission.PodcastManageRetention]: "Manage podcast retention",
[Permission.PodcastPurge]: "Purge podcast content",
[Permission.DemoRestricted]: "Demo restricted",
[Permission.KoboSync]: "Kobo sync",
[Permission.KoreaderSync]: "KOReader sync",
+964
View File
@@ -0,0 +1,964 @@
/**
* Where a show and its episodes came from. `feed` is a subscribed RSS feed and is refreshable;
* `local` is content that only exists as files in the library folder, so it never refreshes, never
* proxies a remote enclosure, and is never evicted to free space - deleting its file is data loss.
*
* There is deliberately no way to convert a local show into a feed show. If the feed turns up
* later, subscribe to it as a new show and merge the local one into it: merge already moves
* episodes, progress, bookmarks, and queue entries, and the two identity spaces cannot collide, so
* the episodes the feed republishes and the files already on disk end up under one show.
*/
export type PodcastOrigin = "feed" | "local";
/**
* Stable `errorCode` values on the responses that refuse a podcast operation. Client copy is keyed
* off these, never off the server's English message.
*/
export const PODCAST_ERROR_CODES = {
/** A local show has no feed to refresh or reparse. */
localNoRefresh: "PODCAST_LOCAL_NO_REFRESH",
/** A local episode has no enclosure to download; its file is already the only copy. */
localNoDownload: "PODCAST_LOCAL_NO_DOWNLOAD",
/** A local episode's file belongs to the user, so BookOrbit will not delete it on their behalf. */
localFileNotRemovable: "PODCAST_LOCAL_FILE_NOT_REMOVABLE",
/** A local episode's only copy is gone from disk. The media row is now `unavailable`. */
localMediaMissing: "PODCAST_LOCAL_MEDIA_MISSING",
/** A folder cannot become a local show because it holds no importable audio. */
localFolderEmpty: "PODCAST_LOCAL_FOLDER_EMPTY",
/** Retention could not free enough room, usually because local-origin files cannot be evicted. */
storageFull: "PODCAST_STORAGE_FULL",
/**
* This library already holds the feed being added or previewed. The response carries
* `podcastId`, so a client can offer to open the existing show instead of recovering its identity
* from the message text.
*/
duplicateFeed: "PODCAST_DUPLICATE_FEED",
} as const;
export type PodcastErrorCode = (typeof PODCAST_ERROR_CODES)[keyof typeof PODCAST_ERROR_CODES];
export type PodcastAcquisitionPolicy = "remote_only" | "manual" | "newest" | "window";
/**
* What happens to a downloaded file once everyone who listened to the episode has finished it.
* `keep` leaves eviction entirely to storage pressure, which is the behaviour every existing show has.
*/
export type PodcastDownloadCleanup = "keep" | "after_finished";
export type PodcastMediaStatus = "remote" | "queued" | "downloading" | "local" | "failed" | "unavailable";
export type PodcastEpisodeFilter = "latest" | "downloaded" | "in_progress" | "unplayed" | "finished" | "pinned";
export type PodcastEpisodeSort = "newest" | "oldest" | "shortest" | "longest" | "recently_listened";
/** Sort stored in a smart playlist's rules. Identical to the sorts every episode surface accepts. */
export type PodcastPlaylistSort = PodcastEpisodeSort;
export type PodcastSort = "title" | "recent" | "unplayed";
export const PODCAST_NOTIFICATION_MODES = ["off", "immediate", "daily", "weekly"] as const;
export type PodcastNotificationMode = (typeof PODCAST_NOTIFICATION_MODES)[number];
export type PodcastQueuePlacement = "end" | "next";
export interface PodcastPlaybackPreferences {
defaultPlaybackRate: number;
volume: number;
skipBackwardSeconds: number;
skipForwardSeconds: number;
podcastPlaybackRates: Record<string, number>;
}
export interface PodcastChapter {
title: string;
startSeconds: number;
endSeconds?: number;
url?: string;
}
export interface PodcastTranscriptRef {
url: string;
type?: string;
language?: string;
rel?: string;
}
export interface PodcastPlaybackRecommendation {
episodeId: number;
title: string;
positionSeconds: number;
durationSeconds: number | null;
kind: "resume" | "latest";
}
/**
* Show fields a user can pin against feed refreshes. Shared by the server DTO validation and the
* client editor so both agree on which names the lock set may contain.
*/
export const PODCAST_LOCKED_FIELDS = [
"title",
"author",
"description",
"imageUrl",
"siteUrl",
"language",
"podcastType",
"explicit",
"categories",
] as const;
export type PodcastLockedField = (typeof PODCAST_LOCKED_FIELDS)[number];
/** Show fields the metadata endpoint accepts. `podcastType` stays feed-owned and is lockable only. */
export const PODCAST_EDITABLE_FIELDS = ["title", "author", "description", "siteUrl", "language", "explicit", "categories"] as const;
export type PodcastEditableField = (typeof PODCAST_EDITABLE_FIELDS)[number];
export const PODCAST_MAX_CATEGORIES = 100;
/**
* Episode fields a user can pin against feed refreshes. Exactly the fields the refresh upsert
* guards per lock; `guid`, the enclosure fields and `transcripts` are identity and delivery data
* that always take the feed value, so they are absent here on purpose.
*/
export const PODCAST_EPISODE_LOCKED_FIELDS = [
"title",
"subtitle",
"description",
"publishedAt",
"season",
"episode",
"episodeType",
"durationSeconds",
"explicit",
"chapters",
] as const;
export type PodcastEpisodeLockedField = (typeof PODCAST_EPISODE_LOCKED_FIELDS)[number];
/** The iTunes episode types. A feed may publish anything, but an edit has to pick one of these or clear the field. */
export const PODCAST_EPISODE_TYPES = ["full", "trailer", "bonus"] as const;
export type PodcastEpisodeType = (typeof PODCAST_EPISODE_TYPES)[number];
/** One year, the same ceiling the feed parser applies to a published duration. */
export const PODCAST_EPISODE_MAX_DURATION_SECONDS = 31_536_000;
export const PODCAST_EPISODE_MAX_CHAPTERS = 1000;
/**
* Sparse update body for `PATCH /podcast-episodes/:episodeId/metadata`. Same lock-on-edit contract
* as the show endpoint: the server stores `normalize(lockedFields ?? current) ∪ editedFields`, so
* unlocking a field means sending `lockedFields` without it and without sending the field value.
*/
export interface PodcastEpisodeMetadataUpdateRequest {
title?: string;
subtitle?: string | null;
description?: string | null;
publishedAt?: string | null;
season?: string | null;
episode?: string | null;
episodeType?: PodcastEpisodeType | null;
durationSeconds?: number | null;
explicit?: boolean;
chapters?: PodcastChapter[];
lockedFields?: PodcastEpisodeLockedField[];
}
/**
* Sparse update body for `PATCH /podcasts/:podcastId/metadata`. Only the properties present are
* written, and every written property is added to the stored lock set, because an unlocked value
* is overwritten by the next feed refresh. `lockedFields` is the authoritative final set *before*
* that union: the server stores `normalize(lockedFields ?? current) ∪ editedFields`. To unlock a
* field, send `lockedFields` without it and without sending the field value; the next refresh then
* restores whatever the feed publishes.
*/
export interface PodcastMetadataUpdateRequest {
title?: string;
author?: string | null;
description?: string | null;
siteUrl?: string | null;
language?: string | null;
explicit?: boolean;
categories?: string[];
lockedFields?: PodcastLockedField[];
}
export interface PodcastMetadataUpdateResult {
id: number;
title: string;
author: string | null;
description: string | null;
siteUrl: string | null;
language: string | null;
explicit: boolean;
categories: string[];
lockedFields: string[];
}
/** Result of every custom-artwork write. `imageUrl` already carries the cache-busting version. */
export interface PodcastArtworkResult {
id: number;
imageUrl: string | null;
artworkUpdatedAt: string | null;
}
export interface PodcastSummary {
id: number;
libraryId: number;
origin: PodcastOrigin;
title: string;
author: string | null;
description: string | null;
imageUrl: string | null;
siteUrl: string | null;
language: string | null;
podcastType: string | null;
explicit: boolean;
categories: string[];
acquisitionPolicy: PodcastAcquisitionPolicy;
autoDownloadLimit: number | null;
autoDownloadWindowDays: number | null;
refreshIntervalMinutes: number;
downloadCleanup: PodcastDownloadCleanup;
/** How long after the last listener finished an episode its file survives. Ignored while cleanup is `keep`. */
downloadCleanupDelayHours: number;
/**
* Metadata fields pinned against feed refreshes. Exposed on the summary so a client editing
* metadata can show which fields are already locked instead of having to write one to find out.
*/
lockedFields: string[];
/**
* When custom artwork was last uploaded, or null while the show still shows feed artwork. Also
* the cache-busting token already baked into `imageUrl`, so a client can tell the two apart
* without another request.
*/
artworkUpdatedAt: string | null;
episodeCount: number;
unplayedCount: number;
downloadedCount: number;
playbackRecommendation: PodcastPlaybackRecommendation | null;
followed: boolean;
notificationMode: PodcastNotificationMode;
archivedAt: string | null;
/**
* When a local show's folder was found gone from disk, or null while it is there. The show and its
* episodes are kept either way, so this clears on its own once the folder comes back.
*/
missingAt: string | null;
lastRefreshAt: string | null;
lastRefreshSuccessAt: string | null;
/**
* When the server last stored a copy of the feed body, or null while no copy exists. A show with
* a stored copy can be reparsed without another request to the feed.
*/
feedSnapshotAt: string | null;
consecutiveFailures: number;
nextRefreshAt: string;
createdAt: string;
updatedAt: string;
}
export interface PodcastListItem {
id: number;
libraryId: number;
origin: PodcastOrigin;
title: string;
author: string | null;
imageUrl: string | null;
episodeCount: number;
unplayedCount: number;
downloadedCount: number;
latestPublishedAt: string | null;
consecutiveFailures: number;
playbackRecommendation: PodcastPlaybackRecommendation | null;
followed: boolean;
notificationMode: PodcastNotificationMode;
archivedAt: string | null;
/** Set while a local show's folder is gone from disk. See `PodcastSummary.missingAt`. */
missingAt: string | null;
}
export interface PodcastEpisodeSummary {
id: number;
libraryId: number;
podcastId: number;
origin: PodcastOrigin;
podcastTitle: string;
podcastImageUrl: string | null;
title: string;
subtitle: string | null;
description: string | null;
publishedAt: string | null;
season: string | null;
episode: string | null;
episodeType: string | null;
durationSeconds: number | null;
audioFormat: string | null;
explicit: boolean;
chapters: PodcastChapter[];
transcripts: PodcastTranscriptRef[];
/**
* Metadata fields pinned against feed refreshes. Exposed on the summary so a client editing an
* episode can show which fields are already locked instead of having to write one to find out.
*/
lockedFields: string[];
inFeed: boolean;
mediaStatus: PodcastMediaStatus;
localSizeBytes: number | null;
/**
* sha256 hex of the server's cached copy, non-null only while `mediaStatus` is `local`.
* Clients that download an episode to a device verify the transfer against it; an
* origin-proxied episode has no cached copy to hash, so the field stays null.
*/
checksum: string | null;
positionSeconds: number;
progressPercent: number;
finished: boolean;
pinned: boolean;
queued: boolean;
lastListenedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface PodcastEpisodeListItem {
id: number;
libraryId: number;
podcastId: number;
origin: PodcastOrigin;
podcastTitle: string;
podcastImageUrl: string | null;
title: string;
season: string | null;
episode: string | null;
explicit: boolean;
inFeed: boolean;
publishedAt: string | null;
durationSeconds: number | null;
/**
* The audio container, resolved the same way `PodcastEpisodeSummary.audioFormat` resolves it,
* minus the decrypt-the-enclosure-URL fallback that is too costly per page. Present because a
* client that downloads straight from a list row has nothing else to name the file from, and
* `AVURLAsset` on iOS decides a local file's type from its path extension: without this an m4a
* episode was saved as `.mp3` and would not decode offline.
*/
audioFormat: string | null;
mediaStatus: PodcastMediaStatus;
localSizeBytes: number | null;
/**
* sha256 of the server's cached copy, non-null only while `mediaStatus === 'local'`. The same
* field `PodcastEpisodeSummary` carries, and it is here for the same reason `audioFormat` is: a
* client that downloads straight from a list row has nothing else to verify the bytes against.
*
* Without it an iOS download started from a show's episode list carried no checksum at all, so
* the exact comparison the server-cached path is built around never ran and a substituted body
* was admitted by a signature sniff. Costs nothing to send: the media row is already joined for
* `mediaStatus` and `localSizeBytes`.
*/
checksum: string | null;
positionSeconds: number;
progressPercent: number;
finished: boolean;
pinned: boolean;
queued: boolean;
lastListenedAt: string | null;
}
export interface PodcastPage<T> {
items: T[];
total: number;
page: number;
size: number;
}
export interface PodcastEpisodePage extends PodcastPage<PodcastEpisodeListItem> {
totalDurationSeconds: number;
}
/** Rules a smart playlist evaluates against existing episode state. All fields narrow the match. */
export interface PodcastPlaylistRules {
filter: PodcastEpisodeFilter;
sort: PodcastPlaylistSort;
maxDurationMinutes: number | null;
minDurationMinutes: number | null;
publishedWithinDays: number | null;
podcastIds: number[];
followedOnly: boolean;
}
/**
* Podcast smart scopes persist exactly these rules, so the saved playlists that predate them
* migrate without transformation. Kept as an alias rather than a copy so the editor, the query
* builder, and the scope row can never drift apart.
*/
export type PodcastScopeRules = PodcastPlaylistRules;
export interface PodcastPlaylist {
id: string;
name: string;
libraryId: number;
rules: PodcastPlaylistRules;
}
export interface PodcastPlaylistPreferences {
playlists: PodcastPlaylist[];
}
export const PODCAST_PLAYLIST_QUEUE_LIMIT = 100;
export const PODCAST_PLAYLIST_MAX_SHOWS = 50;
export const PODCAST_PLAYLIST_MAX_SAVED = 50;
/** A day is the longest episode worth expressing, and ten years the longest publication window. */
export const PODCAST_PLAYLIST_MAX_DURATION_MINUTES = 1_440;
export const PODCAST_PLAYLIST_MAX_PUBLISHED_WITHIN_DAYS = 3_650;
export const PODCAST_STATE_BATCH_LIMIT = 200;
export const PODCAST_DIRECTORY_SEARCH_MAX_RESULTS = 50;
export const PODCAST_DIRECTORY_SEARCH_MAX_QUERY = 200;
/**
* One show from the public podcast directory, proxied by the server so no client needs a
* directory credential or its own egress path. `feedUrl` is what the add-feed flow hands to
* feed preview; `artworkUrl` points at the directory's own CDN rather than at BookOrbit,
* because a show that is not in the library yet has no artwork endpoint to serve.
*/
export interface PodcastDirectoryResult {
title: string;
author: string | null;
feedUrl: string;
artworkUrl: string | null;
genre: string | null;
/**
* The show this feed already resolves to in a library the caller can see, or null. Annotated per
* request rather than cached with the directory row, because it differs by caller. The directory
* publishes a show's origin feed, so a show followed through a redirect only matches once
* BookOrbit has recorded the origin as an alias: a miss here is possible, a false hit is not.
*/
existingPodcastId: number | null;
existingLibraryId: number | null;
}
export interface PodcastFeedPreview {
feedUrl: string;
title: string;
author: string | null;
description: string | null;
imageUrl: string | null;
language: string | null;
podcastType: string | null;
explicit: boolean;
categories: string[];
episodeCount: number;
}
/**
* `POST /podcast-libraries/:libraryId/podcasts`, for both sources. The show row exists as soon as
* this returns; its episodes do not.
*
* A feed's episodes arrive on the first refresh, so `jobId` is null and `created` is always true.
* A folder's episodes arrive with the import job named by `jobId`, and `created` is false when the
* folder already was a local show and this run only adopts files that appeared since.
*/
export interface PodcastCreateResult {
id: number;
title: string;
created: boolean;
jobId: number | null;
}
/** Which way content enters a podcast library: subscribed from an address, or adopted from disk. */
export const PODCAST_CREATE_SOURCES = ["feed", "folder"] as const;
export type PodcastCreateSource = (typeof PODCAST_CREATE_SOURCES)[number];
/** `POST /podcasts/:podcastId/archive` and `/restore`. `archivedAt` is null once restored. */
export interface PodcastArchiveResult {
id: number;
archivedAt: string | null;
}
/**
* `GET /podcasts/:podcastId/purge-preview`: what a permanent delete would remove from disk, so the
* confirmation can name it before anything is destroyed. Counts cached media only; a show whose
* episodes all stream has zero of both.
*/
export interface PodcastPurgePreview {
files: number;
bytes: number;
}
/** How many shows one bulk delete may name. Keeps a runaway selection from queueing unbounded work. */
export const PODCAST_BULK_DELETE_MAX_SHOWS = 200;
/**
* `POST /podcast-libraries/:libraryId/shows/bulk-purge-preview`: the same accounting as
* `PodcastPurgePreview` summed over a selection, plus how many of the named shows actually resolved.
* The confirmation escalates to typed input only when `bytes` is above zero.
*/
export interface PodcastBulkPurgePreview extends PodcastPurgePreview {
shows: number;
}
/**
* `POST /podcast-libraries/:libraryId/opml/import`. `total` is how many subscriptions the file
* held, `queued` how many became jobs: the difference is feeds the library already follows, which
* are deduplicated rather than reported as failures.
*/
export interface PodcastOpmlImportResult {
total: number;
queued: number;
}
/** `POST /podcasts/:podcastId/follow`: the stored follow row. */
export interface PodcastFollowState {
userId: number;
podcastId: number;
notificationMode: PodcastNotificationMode;
lastNotifiedAt: string;
createdAt: string;
}
export interface PodcastLibrarySettings {
libraryId: number;
storageQuotaBytes: string;
minimumFreeSpaceBytes: string;
defaultRefreshIntervalMinutes: number;
completionRemainingSeconds: number;
usedStorageBytes: string;
}
export interface PodcastQueueItem extends PodcastEpisodeListItem {
queuePosition: number;
}
export interface PodcastQueuePage extends PodcastPage<PodcastQueueItem> {
totalDurationSeconds: number;
}
/** Result of a queue clear. `previousEpisodeIds` is the pre-clear order, so an undo can restore it in one request. */
export interface PodcastQueueClearResult {
removed: number;
previousEpisodeIds: number[];
}
export interface PodcastQueueRestoreResult {
restored: number;
}
/**
* Body of `PATCH /podcast-queue/move`. `position` is a 0-based target index the server clamps to
* the queue length. The single-row form carries no membership snapshot, so it cannot lose a race
* against a concurrent edit the way the full-set reorder can.
*/
export interface PodcastQueueMoveRequest {
episodeId: number;
position: number;
}
export interface PodcastPlaybackContext {
episode: PodcastEpisodeSummary;
queue: {
position: number | null;
total: number;
previous: PodcastQueueItem | null;
upcoming: PodcastQueueItem[];
};
navigation: {
source: "queue" | "podcast" | null;
previous: PodcastEpisodeListItem | null;
next: PodcastEpisodeListItem | null;
};
}
/** Short-lived, single-episode grant that lets a player load `/stream?ticket=` without an auth header. */
export interface PodcastStreamTicket {
ticket: string;
expiresAt: string;
}
/** Per-user episode state as stored, returned by the state PATCH and the batch state read. */
export interface PodcastEpisodeStateRow {
episodeId: number;
positionSeconds: number;
progressPercent: number;
finished: boolean;
finishedAt: string | null;
pinned: boolean;
lastListenedAt: string | null;
updatedAt: string;
}
export interface PodcastBookmark {
id: number;
episodeId: number;
positionSeconds: number;
title: string;
note: string | null;
createdAt: string;
updatedAt: string;
}
export interface PodcastJobStatus {
queued: number;
processing: number;
failed: number;
}
export interface PodcastDownloadProgressEvent {
libraryId: number;
podcastId: number;
episodeId: number;
batchId: string | null;
status: Extract<PodcastMediaStatus, "queued" | "downloading">;
receivedBytes: number;
totalBytes: number | null;
}
export interface PodcastDownloadCompleteEvent {
libraryId: number;
podcastId: number;
episodeId: number;
batchId: string | null;
jobStatus: "queued" | "completed" | "failed" | "cancelled" | null;
mediaStatus: PodcastMediaStatus;
localSizeBytes: number | null;
}
export interface PodcastRefreshCompleteEvent {
libraryId: number;
podcastId: number;
newEpisodes: number;
consecutiveFailures: number;
lastError: string | null;
}
/**
* Progress for either import lane. `kind` exists because both lanes share one channel and a
* client showing OPML progress must not count local-file scans, or the other way round.
*
* The job counts say how much work is outstanding; `processed` and `total` say how far the work
* being done right now has got, so a bar can move rather than only a count changing. `total` is
* null until the run has counted what it is about to do, which is a real state a UI has to render.
*/
export interface PodcastImportProgressEvent extends PodcastJobStatus {
libraryId: number;
kind: PodcastImportKind;
processed: number;
total: number | null;
}
export type PodcastImportKind = "opml" | "local_files";
/**
* A show an import just brought into the library, announced as it happens so a long run fills the
* grid rather than leaving it empty until the end. `episodes` is the count at the moment of the
* event, so it climbs across several events for the same show while its files are still arriving.
*/
export interface PodcastShowDiscoveredEvent {
libraryId: number;
podcastId: number;
title: string;
episodes: number;
/** False when the show already existed and this run only added newly seen files to it. */
created: boolean;
}
/**
* A downloaded file the server removed on its own. `space` is quota or free-space pressure,
* `played` is the per-show cleanup rule firing after every listener finished the episode.
*/
export interface PodcastRetentionEvictedEvent {
libraryId: number;
podcastId: number;
episodeId: number;
reason: "space" | "played";
}
export interface PodcastBulkActionResult {
completed: number;
failed: number;
skipped: number;
firstEpisodeId?: number;
}
export interface PodcastBulkDownloadResult extends PodcastBulkActionResult {
batchId: string | null;
}
export type PodcastDownloadBatchItemStatus = "queued" | "downloading" | "completed" | "failed" | "cancelled";
export interface PodcastDownloadBatchItem {
episodeId: number | null;
title: string | null;
status: PodcastDownloadBatchItemStatus;
receivedBytes: number;
totalBytes: number | null;
}
export interface PodcastDownloadBatch {
id: string;
libraryId: number;
podcastId: number | null;
podcastTitle: string | null;
total: number;
queued: number;
downloading: number;
completed: number;
failed: number;
cancelled: number;
receivedBytes: number;
totalBytes: number | null;
createdAt: string;
updatedAt: string;
items: PodcastDownloadBatchItem[];
}
/**
* Local-file import. A file already inside the library folder is matched against the episodes a
* subscribed feed already published, then adopted in place: nothing is copied, renamed, or moved,
* and an episode row's identity still comes from its feed.
*/
/** Evidence that tied a file to an episode, strongest first. */
export type PodcastImportSignal = "episode_guid" | "episode_id_suffix" | "enclosure_file_name" | "title_and_date" | "title_and_duration" | "manual";
/** `exact` signals adopt on their own; `fuzzy` ones need a second agreeing signal. */
export type PodcastImportMatchTier = "exact" | "fuzzy";
/** How the file's show was resolved, before any episode was looked at. */
export type PodcastImportScope =
| "request"
| "folder_suffix"
| "feed_url_tag"
| "feed_sidecar"
/** Weak: narrows which shows are searched, never enough to adopt on its own. */
| "title_similarity"
/** No show resolved, so only the library-wide exact signals were tried. */
| "library";
/** Why a file was not adopted. Stable codes; the client owns the wording. */
export type PodcastImportSkipReason =
| "show_unresolved"
| "no_episode_candidate"
| "single_fuzzy_signal"
| "multiple_candidates"
| "empty_file"
| "unsupported_format"
| "symlink"
| "unreadable"
| "outside_library_root"
| "attach_conflict";
export type PodcastImportSuggestionSource = "feed_sidecar" | "metadata_json" | "feed_url_tag";
/** One of the episodes an ambiguous file could belong to, for the review UI's picker. */
export interface PodcastImportEpisodeCandidate {
episodeId: number;
podcastId: number;
podcastTitle: string;
episodeTitle: string;
publishedAt: string | null;
durationSeconds: number | null;
signals: PodcastImportSignal[];
}
export interface PodcastImportFile {
/** Path relative to the library folder. Absolute paths stay server-side. */
path: string;
fileName: string;
sizeBytes: number;
}
export interface PodcastImportMatchedFile extends PodcastImportFile {
episodeId: number;
podcastId: number;
podcastTitle: string;
episodeTitle: string;
tier: PodcastImportMatchTier;
signals: PodcastImportSignal[];
scope: PodcastImportScope;
/** False during a dry run, and false when the apply pass lost a race for the episode. */
attached: boolean;
}
export interface PodcastImportAmbiguousFile extends PodcastImportFile {
scope: PodcastImportScope;
reason: Extract<PodcastImportSkipReason, "single_fuzzy_signal" | "multiple_candidates">;
candidates: PodcastImportEpisodeCandidate[];
}
export interface PodcastImportUnmatchedFile extends PodcastImportFile {
reason: PodcastImportSkipReason;
/** Set when the show resolved but no episode did, which is the more actionable half of a miss. */
podcastId: number | null;
podcastTitle: string | null;
}
export interface PodcastImportDuplicateFile extends PodcastImportFile {
episodeId: number;
podcastId: number;
episodeTitle: string;
/** True when the already-adopted copy's bytes differ, which usually means a re-edit or re-encode. */
contentDiffers: boolean;
}
/** A folder holding audio that belongs to no subscribed show, but that names a feed BookOrbit could follow. */
export interface PodcastImportSuggestedFeed {
folderPath: string;
feedUrl: string;
source: PodcastImportSuggestionSource;
fileCount: number;
}
/**
* A folder holding audio that belongs to no show and names no feed. Nothing can be adopted into it,
* so the only way to make it playable is to turn it into a local show. The suggested fields are the
* preview of what creating one would produce.
*/
export interface PodcastImportUnclaimedFolder {
folderPath: string;
suggestedTitle: string;
suggestedAuthor: string | null;
fileCount: number;
/** Set when this folder is already a local show, so re-importing adds its new files instead of creating one. */
existingPodcastId: number | null;
}
export interface PodcastImportSkippedFile {
path: string;
reason: PodcastImportSkipReason;
}
/** Adoption counts toward the library quota exactly like a download, so an apply that would exceed it is refused. */
export interface PodcastImportQuota {
adoptBytes: string;
headroomBytes: string;
exceeded: boolean;
}
export interface PodcastImportCounts {
matched: number;
ambiguous: number;
unmatched: number;
duplicates: number;
skipped: number;
suggestedFeeds: number;
unclaimedFolders: number;
}
/** How many entries each report group carries. The counts stay exact once a group is capped. */
export const PODCAST_IMPORT_REPORT_GROUP_LIMIT = 500;
export const PODCAST_IMPORT_MAX_RESOLUTIONS = 500;
export interface PodcastImportReport {
libraryId: number;
dryRun: boolean;
startedAt: string;
finishedAt: string;
filesDiscovered: number;
filesScanned: number;
/** Candidates whose path a media row already claims, so the walk skipped them before any probing. */
alreadyAdopted: number;
attached: number;
counts: PodcastImportCounts;
matched: PodcastImportMatchedFile[];
ambiguous: PodcastImportAmbiguousFile[];
unmatched: PodcastImportUnmatchedFile[];
duplicates: PodcastImportDuplicateFile[];
suggestedFeeds: PodcastImportSuggestedFeed[];
unclaimedFolders: PodcastImportUnclaimedFolder[];
skipped: PodcastImportSkippedFile[];
/** Folders the walk could not read, relative to the library folder. */
unreadableFolders: string[];
quota: PodcastImportQuota;
/** Set when the apply pass refused to write anything. */
blockedReason: "quota_exceeded" | null;
/** True when at least one group holds fewer entries than its count. */
truncated: boolean;
}
/** One review-UI decision: adopt this file as this episode, overriding whatever the matcher concluded. */
export interface PodcastImportResolution {
path: string;
episodeId: number;
}
export interface PodcastImportScanRequest {
dryRun: boolean;
podcastId?: number;
resolutions?: PodcastImportResolution[];
}
export interface PodcastImportScanJob {
id: number;
status: string;
dryRun: boolean;
progressCurrent: number;
progressTotal: number | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
}
/**
* Body of `DELETE /podcast-episodes/:episodeId/download`, which drops a feed episode's cached copy
* and returns it to `remote`. A local episode is refused with `PODCAST_LOCAL_FILE_NOT_REMOVABLE`:
* that file is the user's own, not a cache. The episode row survives either way, so metadata,
* progress, and bookmarks are never what this endpoint removes.
*/
export type PodcastRemoveDownloadRequest = Record<string, never>;
/** `GET /podcast-libraries/:libraryId/import-scan/latest`. Both halves are null before a first run. */
export interface PodcastImportScanStatus {
job: PodcastImportScanJob | null;
report: PodcastImportReport | null;
}
/**
* `GET /podcast-libraries/:libraryId/jobs/:jobId`: the state of one queued job.
*
* The summary below counts everything in the library, which is the right answer for a status strip
* and the wrong one for a client waiting on the job it just started: any unrelated refresh or cache
* batch kept an OPML import's progress readout claiming it was still running. An enqueueing route
* already returns a `jobId`; this is how that id is asked about.
*/
export interface PodcastJobState {
id: number;
type: string;
status: "queued" | "processing" | "completed" | "failed" | "cancelled";
/** Set when the job finished by failing. Written for a person, not parsed by clients. */
lastError: string | null;
progressCurrent: number | null;
progressTotal: number | null;
}
/** `GET /podcast-libraries/:libraryId/jobs`: outstanding background work for the library. */
export interface PodcastJobSummary {
queued: number;
processing: number;
failed: number;
}
/**
* `GET /podcast-libraries/:libraryId/activity`. One cheap poll answering "what is this library
* doing right now", deliberately counters only.
*
* The detail each counter summarises stays on its own endpoint and is fetched when a user actually
* opens it: the import report from `import-scan/latest`, per-episode batch rows from
* `podcast-download-batches`, per-feed errors from `health`. Folding those payloads in here would
* make the polled response grow with the size of the library, which is the thing polling must not do.
*/
export interface PodcastLibraryActivity {
jobs: PodcastJobSummary;
importScan: PodcastImportScanJob | null;
downloads: { batches: number; queued: number; downloading: number; failed: number };
failingFeeds: number;
}
/**
* How many local shows an OPML export left out. OPML carries one `xmlUrl` per show and a local show
* has none, so the count travels in a response header rather than silently disappearing.
*/
export const PODCAST_OPML_OMITTED_HEADER = "x-podcast-local-shows-omitted";
export interface PodcastFeedHealth {
podcastId: number;
title: string;
lastRefreshAt: string | null;
lastRefreshSuccessAt: string | null;
consecutiveFailures: number;
nextRefreshAt: string;
lastError: string | null;
lastHttpStatus: number | null;
}
+2
View File
@@ -251,6 +251,7 @@ export type GroupRule = {
* User-defined custom metadata fields are sortable too, as `CustomSortField`.
*/
export type StaticSortField =
| "relevance"
| "author"
| "title"
| "series"
@@ -289,6 +290,7 @@ export type SortField = StaticSortField | CustomSortField;
/** Built-in sort fields only. Custom fields are resolved at runtime, not enumerable here. */
export const SORT_FIELDS: StaticSortField[] = [
"relevance",
"author",
"title",
"series",
@@ -1,22 +1,26 @@
import type { ReadingSessionSource } from "./reading-session";
export const READING_SESSION_SOURCE_BUCKETS = ["bookorbit", "koreader", "kobo"] as const;
export const READING_SESSION_SOURCE_BUCKETS = ["bookorbit", "ios", "watchos", "koreader", "kobo"] as const;
export type ReadingSessionSourceBucket = (typeof READING_SESSION_SOURCE_BUCKETS)[number];
export const READING_SESSION_SOURCE_BUCKET_LABELS: Record<ReadingSessionSourceBucket, string> = {
bookorbit: "BookOrbit",
ios: "iOS app",
watchos: "Apple Watch",
koreader: "KOReader",
kobo: "Kobo",
};
// web/manual are both native BookOrbit surfaces; null/unknown (and historical Kobo-as-web
// sessions that predate source tagging) collapse into the BookOrbit bucket by design.
// Web/manual and null/unknown history collapse into the general BookOrbit bucket.
// Native Apple clients remain distinct so device attribution survives aggregation.
export function toReadingSessionSourceBucket(source: ReadingSessionSource | null | undefined): ReadingSessionSourceBucket {
if (source === "ios") return "ios";
if (source === "watchos") return "watchos";
if (source === "koreader") return "koreader";
if (source === "kobo") return "kobo";
return "bookorbit";
}
export function emptySourceBucketRecord(): Record<ReadingSessionSourceBucket, number> {
return { bookorbit: 0, koreader: 0, kobo: 0 };
return { bookorbit: 0, ios: 0, watchos: 0, koreader: 0, kobo: 0 };
}
+5 -2
View File
@@ -1,8 +1,11 @@
import type { ReadingSessionSourceBucket } from "./reading-session-source-bucket";
export const READING_SESSION_SOURCES = ["web", "koreader", "manual", "kobo"] as const;
export const READING_SESSION_SOURCES = ["web", "ios", "watchos", "koreader", "manual", "kobo"] as const;
export type ReadingSessionSource = (typeof READING_SESSION_SOURCES)[number];
export const CLIENT_READING_SESSION_SOURCES = ["ios", "watchos"] as const;
export type ClientReadingSessionSource = (typeof CLIENT_READING_SESSION_SOURCES)[number];
export interface BookReadingSession {
id: number;
bookFileId: number | null;
@@ -38,7 +41,7 @@ export interface BookReadingSessionStats {
// session has. Unlike every other field here it ignores the list filters, because it
// feeds the book's progress ring rather than a summary of the filtered rows.
latestEndProgress: number | null;
// Reading time/sessions split across the 3 display buckets, ordered by
// Reading time/sessions split across the display buckets, ordered by
// READING_SESSION_SOURCE_BUCKETS; only buckets with activity are included.
bySource: BookReadingSourceSlice[];
// Longest single session in the filtered window. The client only ever holds one page of
+23
View File
@@ -0,0 +1,23 @@
import type { AuthorSummary } from "./author";
import type { BookCard } from "./book";
import type { SeriesSummary } from "./series";
export type GlobalSearchMatchField = "title" | "author" | "series" | "narrator" | "name";
export type GlobalSearchHit<T> = {
item: T;
matchedField: GlobalSearchMatchField;
matchedText: string | null;
};
export type GlobalSearchSection<T> = {
items: GlobalSearchHit<T>[];
total: number;
};
export type GlobalSearchResponse = {
query: string;
books: GlobalSearchSection<BookCard>;
authors: GlobalSearchSection<AuthorSummary>;
series: GlobalSearchSection<SeriesSummary>;
};
@@ -1,3 +1,5 @@
import type { ReadingSessionSource } from "./reading-session";
export const READING_INSIGHTS_SHARING_LEVELS = ["private", "summary", "detailed"] as const;
export type ReadingInsightsSharingLevel = (typeof READING_INSIGHTS_SHARING_LEVELS)[number];
@@ -55,7 +57,7 @@ export interface SharedReadingGenreDistributionItem {
}
export interface SharedReadingSourceItem {
source: string;
source: ReadingSessionSource | "unknown";
readingSeconds: number;
sessionsCount: number;
}
@@ -105,6 +107,4 @@ export interface SharedReadingInsightsViewSession {
}
export type ReadingInsightsPreview =
| ReadingInsightsSharingSettings
| SharedReadingInsightsSummary
| { summary: SharedReadingInsightsSummary; detail: SharedReadingInsightsDetail };
ReadingInsightsSharingSettings | SharedReadingInsightsSummary | { summary: SharedReadingInsightsSummary; detail: SharedReadingInsightsDetail };
+17 -2
View File
@@ -1,8 +1,23 @@
export const SIDEBAR_SECTION_IDS = ["browse", "libraries", "smartScopes", "collections"] as const;
export const SIDEBAR_SECTION_IDS = [
"browse",
"libraries",
"podcasts",
"smartScopes",
"collections",
"podcastScopes",
"podcastCollections",
] as const;
export type SidebarSectionId = (typeof SIDEBAR_SECTION_IDS)[number];
/** Sections backed by a variable-length entity list, so only these carry a rows-shown cap. */
export const SIDEBAR_CAPPED_SECTION_IDS = ["libraries", "smartScopes", "collections"] as const;
export const SIDEBAR_CAPPED_SECTION_IDS = [
"libraries",
"podcasts",
"smartScopes",
"collections",
"podcastScopes",
"podcastCollections",
] as const;
export type SidebarCappedSectionId = (typeof SIDEBAR_CAPPED_SECTION_IDS)[number];
/** How many rows a sidebar entity section renders before the See-all link takes over. */
+34 -3
View File
@@ -1,28 +1,59 @@
import type { MediaType } from "./media";
import type { PodcastScopeRules } from "./podcast";
import type { GroupRule, SortSpec } from "./query";
/** Book scopes carry a rule tree; podcast scopes carry the flat episode rules. `mediaType` says which. */
export type SmartScopeFilter = GroupRule | PodcastScopeRules | null;
export interface SmartScope {
id: number;
userId: number;
mediaType: MediaType;
/** Set only on podcast scopes, which evaluate episodes inside one library. */
libraryId: number | null;
name: string;
icon: string | null;
filter: GroupRule | null;
filter: SmartScopeFilter;
defaultSort: SortSpec[];
isPublic: boolean;
/** The owner's own preference. Only the owner's devices follow it. */
/** The owner's own preference. Only the owner's devices follow it. Books only; Kobo has no podcasts. */
syncToKobo: boolean;
/** Whether this scope reaches the requesting user's Kobo: the owner's flag for the owner, an explicit opt-in for everyone else. */
koboSyncEnabled: boolean;
isOwner: boolean;
displayOrder: number;
/** Matching books, on book scopes. Null when the filter failed to validate. */
bookCount?: number | null;
/** Matching episodes, on podcast scopes. Null when the rules failed to validate. */
episodeCount?: number | null;
createdAt: string;
updatedAt: string;
}
export function isPodcastScope(scope: Pick<SmartScope, "mediaType">): boolean {
return scope.mediaType === "podcasts";
}
/** Narrows the filter union to episode rules, so callers never treat a book rule tree as one. */
export function podcastScopeRules(scope: Pick<SmartScope, "mediaType" | "filter">): PodcastScopeRules | null {
if (scope.mediaType !== "podcasts") return null;
return (scope.filter as PodcastScopeRules | null) ?? null;
}
/** The count that belongs to a scope's medium, so callers do not have to branch. */
export function smartScopeItemCount(scope: Pick<SmartScope, "mediaType" | "bookCount" | "episodeCount">): number | null {
const count = scope.mediaType === "podcasts" ? scope.episodeCount : scope.bookCount;
return typeof count === "number" ? count : null;
}
export interface CreateSmartScopePayload {
name: string;
icon: string;
filter?: GroupRule;
/** Defaults to books when omitted, so existing book-scope callers need no change. */
mediaType?: MediaType;
/** Required for podcast scopes, rejected for book scopes. */
libraryId?: number;
filter?: SmartScopeFilter;
defaultSort: SortSpec[];
isPublic?: boolean;
syncToKobo?: boolean;
+120
View File
@@ -0,0 +1,120 @@
// Admin provider configuration
export interface TtsOpenAiProvider {
id: string;
name: string;
baseUrl: string;
apiKey: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface TtsProviderConfiguration {
providers: TtsOpenAiProvider[];
}
// Voice representation
export interface TtsVoice {
id: string;
name: string;
shortName: string;
language: string;
locale: string;
gender: string;
providerId: string;
providerName: string;
}
// User preferences
export interface TtsUserPreferences {
providerId: string | null;
voiceId: string | null;
speed: number;
}
export interface TtsBookPreferences {
providerId: string | null;
voiceId: string | null;
speed: number | null;
bookId: number;
}
export interface TtsEffectivePreferences {
providerId: string | null;
voiceId: string | null;
speed: number;
isBookOverride: boolean;
}
// Playback state
export type TtsPlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
// Position persistence
export interface TtsPosition {
cfi: string;
chapterIndex: number | null;
}
// Synthesis
export interface TtsSynthesisRequest {
text: string;
voiceId: string;
providerId: string;
speed: number;
format?: string;
}
// Provider status (for admin UI)
export interface TtsProviderStatus {
id: string;
name: string;
connected: boolean;
voiceCount: number;
error?: string;
}
// Server-side chapter text extraction
export interface TtsChapterSentence {
text: string;
index: number;
}
export interface TtsChapterText {
chapterIndex: number;
sentences: TtsChapterSentence[];
}
// Provider info exposed to users (no secrets)
export interface TtsProviderInfo {
id: string;
name: string;
type: 'openai-compatible';
}
// Captioned synthesis: audio plus the timing of each spoken word, for readers that
// highlight along with narration. Only providers whose upstream reports word boundaries
// can produce it, so the words array is allowed to be empty.
export interface TtsWordTiming {
/** The spoken token. Punctuation can arrive as its own entry. */
word: string;
/** Seconds into the returned audio, not into the request text. */
startTime: number;
endTime: number;
}
export interface TtsCaptionedSpeech {
/** Base64 audio in the requested format. */
audio: string;
format: string;
words: TtsWordTiming[];
}
+92
View File
@@ -1,3 +1,94 @@
import type { EpubMediaOverlayCapability } from "./epub";
export const UPLOAD_SUPPORTED_FORMATS = [
"epub",
"kepub",
"pdf",
"mobi",
"azw",
"azw3",
"cbz",
"cbr",
"cb7",
"fb2",
"m4b",
"m4a",
"mp3",
"opus",
"ogg",
"flac",
] as const;
export type UploadSupportedFormat = (typeof UPLOAD_SUPPORTED_FORMATS)[number];
export const UploadErrorCode = {
TooLarge: "UPLOAD_TOO_LARGE",
Empty: "UPLOAD_EMPTY",
UnsupportedFormat: "UPLOAD_FORMAT_UNSUPPORTED",
FormatNotAllowed: "UPLOAD_FORMAT_NOT_ALLOWED",
InvalidContent: "UPLOAD_CONTENT_INVALID",
Duplicate: "UPLOAD_DUPLICATE",
DestinationConflict: "UPLOAD_DESTINATION_CONFLICT",
StorageFull: "UPLOAD_STORAGE_FULL",
InvalidTarget: "UPLOAD_TARGET_INVALID",
OffsetMismatch: "UPLOAD_OFFSET_MISMATCH",
ChecksumMismatch: "UPLOAD_CHECKSUM_MISMATCH",
SessionExpired: "UPLOAD_SESSION_EXPIRED",
SessionStateInvalid: "UPLOAD_SESSION_STATE_INVALID",
ImportFailed: "UPLOAD_IMPORT_FAILED",
} as const;
export type UploadErrorCode = (typeof UploadErrorCode)[keyof typeof UploadErrorCode];
export type UploadTarget =
{ kind: "library"; libraryId: number; folderId?: number } | { kind: "existing_book"; bookId: number } | { kind: "book_dock" };
export type UploadSessionStatus = "receiving" | "processing" | "completed" | "failed" | "cancelled" | "expired";
export interface CreateUploadSessionRequest {
filename: string;
sizeBytes: number;
idempotencyKey: string;
target: UploadTarget;
contentType?: string;
sha256?: string;
}
export interface UploadSessionResponse {
id: string;
filename: string;
sizeBytes: number;
receivedBytes: number;
chunkSizeBytes: number;
status: UploadSessionStatus;
target: UploadTarget;
errorCode?: UploadErrorCode | null;
errorMessage?: string | null;
bookId?: number | null;
bookDockFileId?: number | null;
expiresAt: string;
createdAt: string;
updatedAt: string;
completedAt?: string | null;
}
export interface UploadCapabilitiesLibrary {
id: number;
name: string;
allowedFormats: string[];
organizationMode: "book_per_file" | "book_per_folder";
folders: { id: number; name: string }[];
}
export interface UploadCapabilitiesResponse {
maxFileSizeBytes: number;
chunkSizeBytes: number;
supportedFormats: string[];
canUploadToLibrary: boolean;
canUseBookDock: boolean;
libraries: UploadCapabilitiesLibrary[];
}
export type UploadResult = {
bookId: number;
filename: string;
@@ -14,5 +105,6 @@ export type AddBookFileResult = {
createdAt: string;
filename: string;
durationSeconds: number | null;
mediaOverlay?: EpubMediaOverlayCapability | null;
bookStatus: string;
};
+43
View File
@@ -0,0 +1,43 @@
export interface WatchDownloadTicketRequest {
bookId: number;
fileIds: number[];
}
export interface WatchDownloadTicketFile {
fileId: number;
format: string;
sizeBytes: number;
downloadPath: string;
}
export interface WatchDownloadTicketResponse {
token: string;
expiresAt: string;
files: WatchDownloadTicketFile[];
coverPath: string | null;
}
/**
* Narration audio for an EPUB3 media-overlay book, addressed by its href inside the archive
* rather than by a book file id. Only the iOS Watch path calls this; the web client plays the
* same resources through the reader's own streaming route.
*/
export interface WatchMediaOverlayDownloadTicketRequest {
bookId: number;
fileId: number;
hrefs: string[];
}
export interface WatchMediaOverlayDownloadTicketFile {
href: string;
mediaType: string;
sizeBytes: number;
downloadPath: string;
}
export interface WatchMediaOverlayDownloadTicketResponse {
token: string;
expiresAt: string;
files: WatchMediaOverlayDownloadTicketFile[];
coverPath: string | null;
}