mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface AppFeatures {
|
||||
podcasts: boolean;
|
||||
}
|
||||
|
||||
export const APP_FEATURES = Object.freeze({
|
||||
podcasts: false,
|
||||
} as const satisfies AppFeatures);
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user