feat(cover): give books with both media separate ebook and audiobook covers

Keep one cover slot per medium and show the one each surface needs: the
library's shape for the face, square art on audio screens, the ebook slot
for Kobo and OPDS. Legacy covers convert on upgrade or on first read, and a
write that names no slot keeps working for released iOS builds. Also read
the dev client origin from one constant.
This commit is contained in:
neon
2026-09-23 19:55:25 +00:00
parent 4b86ff73cf
commit e28bd67fa3
298 changed files with 34879 additions and 2448 deletions
+42 -5
View File
@@ -103,6 +103,35 @@ export type BookMediaProfile = {
type BookMediaFile = Pick<BookFileRef, "format" | "role">;
export const COVER_MEDIA = ["ebook", "audio"] as const;
export type CoverMedium = (typeof COVER_MEDIA)[number];
type CoverMediaFile = BookMediaFile & {
mediaOverlay?: Pick<EpubMediaOverlayCapability, "available"> | null;
mediaOverlayAvailable?: boolean | null;
};
export type CoverMedia = {
hasEbook: boolean;
hasAudio: boolean;
};
export function getCoverMedia(files: readonly CoverMediaFile[]): CoverMedia {
let hasEbook = false;
let hasAudio = false;
for (const file of files) {
if (file.role !== "content" && file.role !== "primary") continue;
const format = file.format?.trim().toLowerCase();
if (!format) continue;
if (isAudioFormat(format)) hasAudio = true;
else hasEbook = true;
if (format === "epub" && (file.mediaOverlay?.available === true || file.mediaOverlayAvailable === true)) hasAudio = true;
}
return { hasEbook, hasAudio };
}
export function getPrimaryBookFile<T extends BookMediaFile>(files: readonly T[]): T | null {
return files.find((file) => file.role === "primary") ?? files.find((file) => file.format != null) ?? files[0] ?? null;
}
@@ -154,6 +183,7 @@ export type BookCard = {
readStatus: UserBookStatus | null;
addedAt: string;
updatedAt: string | null;
coverVersion: string;
metadataScore: number | null;
hasCover: boolean;
hasMetadataLocks: boolean;
@@ -195,11 +225,7 @@ 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 ReadAloudProgressSyncUnavailableReason = "no_media_overlay_epub" | "no_audio_files" | "missing_duration" | "duration_mismatch";
export type ReadAloudProgressSync = {
mode: ReadAloudProgressSyncMode;
@@ -250,6 +276,9 @@ export type BookDetail = {
personalNoteUpdatedAt: string | null;
communityRatings: BookCommunityRating[];
coverSource: "extracted" | "custom" | null;
coverMedia: CoverMedium[];
covers: Record<CoverMedium, BookCoverSlot | null>;
coverVersion: string;
hardcoverEditionId: string | null;
providerIds: ProviderIds;
authors: { id: number; name: string; sortName: string | null }[];
@@ -269,6 +298,13 @@ export type BookDetail = {
fileWriteStatus?: BookFileWriteStatus;
};
export type BookCoverSlot = {
source: "extracted" | "custom";
updatedAt: string;
width: number | null;
height: number | null;
};
export type BookMetadataSaveResult = {
book: BookDetail;
write: WriteResult | null;
@@ -291,6 +327,7 @@ export type BookMetadataRefreshPreviewFields = {
seriesMemberships?: MetadataSeriesMembership[] | null;
communityRatings?: BookCommunityRating[];
coverUrl?: string;
audioCoverUrl?: string;
googleBooksId?: string | null;
goodreadsId?: string | null;
amazonId?: string | null;
+11
View File
@@ -1,3 +1,5 @@
import type { CoverMedium } from "./book";
export const MISSING_RESOURCE_CATEGORIES = ["missing_books", "broken_covers", "orphaned_cover_dirs"] as const;
export type MissingResourceCategory = (typeof MISSING_RESOURCE_CATEGORIES)[number];
@@ -35,6 +37,11 @@ export type MissingBookEntry = {
updatedAt: string | null;
};
export type BrokenCoverSlot = {
medium: CoverMedium;
source: "extracted" | "custom";
};
export type BrokenCoverEntry = {
id: number;
title: string | null;
@@ -42,12 +49,16 @@ export type BrokenCoverEntry = {
libraryId: number;
libraryName: string;
coverSource: "extracted" | "custom";
/** The slots whose image is gone. Empty for a cover the upgrade has not moved into a slot yet. */
slots: BrokenCoverSlot[];
};
export type OrphanedCoverDirEntry = {
bookId: number;
fileCount: number;
sizeBytes: number;
/** The slot folders it holds. Empty for a folder from before covers had slots. */
media: CoverMedium[];
};
export type MissingResourcePage<TEntry> = {
+42
View File
@@ -1,4 +1,5 @@
import type { AudiobookChapter, NarratorRef } from "./audiobook";
import type { CoverMedium } from "./book";
import type { SeriesIndex } from "./series-index";
export const MetadataProviderKey = {
@@ -59,6 +60,33 @@ export interface BookCommunityRating {
updatedAt: string | null;
}
/**
* The shape of a candidate's cover as the provider knows it, without downloading the image. A
* provider that cannot tell (a book-level image that may belong to any edition) says `unknown`.
*/
export type MetadataCoverShape = "square" | "portrait" | "unknown";
/**
* Width over height from this ratio through `SQUARE_COVER_MAX_RATIO` reads as square art. Measured
* on provider covers in September 2026: real audiobook art sat at 0.998-1.000, while portrait
* jackets padded to 0.93 and box-set photos came between 0.90 and 0.95, so the floor sits above them.
*/
export const SQUARE_COVER_MIN_RATIO = 0.95;
export const SQUARE_COVER_MAX_RATIO = 1.1;
/** Width over height at or below this reads as a portrait jacket; real ebook art reached 0.85. */
export const PORTRAIT_COVER_MAX_RATIO = 0.85;
/** Provider placeholders and search thumbnails are smaller than this on their short side; no real cover is. */
export const MIN_COVER_SHORT_SIDE_PX = 150;
/** The shape of a cover of this size, or `unknown` for a size that is neither square nor portrait. */
export function coverShapeFromSize(width: number | null | undefined, height: number | null | undefined): MetadataCoverShape {
if (!width || !height || width <= 0 || height <= 0) return "unknown";
const ratio = width / height;
if (ratio >= SQUARE_COVER_MIN_RATIO && ratio <= SQUARE_COVER_MAX_RATIO) return "square";
if (ratio <= PORTRAIT_COVER_MAX_RATIO) return "portrait";
return "unknown";
}
export interface MetadataCandidate {
provider: MetadataProviderKey;
providerId: string;
@@ -83,6 +111,10 @@ export interface MetadataCandidate {
seriesMemberships?: MetadataSeriesMembership[];
genres?: string[];
coverUrl?: string;
coverShape?: MetadataCoverShape;
/** Pixel size of the cover when the provider states it, so a shape can be checked without a download. */
coverWidth?: number;
coverHeight?: number;
sourceUrl?: string;
narrators?: string[];
durationSeconds?: number;
@@ -101,6 +133,8 @@ export interface MetadataProviderInfo {
selectedByFieldRules?: boolean;
/** Zero-based priority for the effective Cover field rule. Absent when the provider is not used for covers. */
coverPriority?: number;
/** Zero-based priority for the effective Audiobook cover field rule. Absent when the provider is not used for it. */
audioCoverPriority?: number;
}
/**
@@ -120,6 +154,12 @@ export interface MetadataProviderSearchStatus {
export type MetadataFetchEmptyReason =
"no_active_providers" | "no_existing_provider_ids" | "providers_throttled" | "no_candidates" | "no_resolved_fields";
/** How a fetch settled one cover slot. `pass` 2 is the re-query for a book's other medium. */
export interface MetadataFetchCoverSlotDiagnostics {
provider: MetadataProviderKey | null;
pass: 1 | 2 | null;
}
export interface MetadataFetchDiagnostics {
reason: MetadataFetchEmptyReason | null;
activeProviders: MetadataProviderKey[];
@@ -130,6 +170,8 @@ export interface MetadataFetchDiagnostics {
candidateProviders: MetadataProviderKey[];
candidateCount: number;
resolvedFieldCount: number;
/** One entry per cover slot the fetch applied to; a slot outside the book's media is absent. */
coverSlots?: Partial<Record<CoverMedium, MetadataFetchCoverSlotDiagnostics>>;
}
export interface ProviderThrottleRuntimeState {
+1
View File
@@ -44,6 +44,7 @@ export const BOOK_METADATA_LOCK_FIELDS = [
"comicTeams",
"comicLocations",
"cover",
"audioCover",
] as const;
export type BookMetadataLockField = (typeof BOOK_METADATA_LOCK_FIELDS)[number];
@@ -5,6 +5,7 @@ export type MetadataField =
| "subtitle"
| "description"
| "cover"
| "audioCover"
| "authors"
| "publisher"
| "publishedYear"
@@ -23,6 +24,7 @@ export const ALL_METADATA_FIELDS: MetadataField[] = [
"subtitle",
"description",
"cover",
"audioCover",
"authors",
"publisher",
"publishedYear",
+4
View File
@@ -21,6 +21,8 @@ import type { CommunityRatingProviderKey } from "./metadata-fetch";
* - `fileSize` - resolved via `book_files.size_bytes` (primary file; rule values are bytes)
* - `isbn` - matches both `isbn10` and `isbn13` in `book_metadata`
* - `publishedDate` - uses full dates when available and falls back to published year
* - `cover` - either cover slot filled (`book_metadata.cover_source` is the summary of both)
* - `audioCover` - books whose cover media include audio, by whether their audio cover slot is filled
* - `lockStatus` - derived from `book_metadata.locked_fields` (non-empty array = locked)
* - `seriesStatus` - computed per-user: "up next in series" (next unstarted book whose earlier series entries are all finished)
*
@@ -55,6 +57,7 @@ export type StaticRuleField =
| "isbn"
| "metadataScore"
| "cover"
| "audioCover"
| "lockStatus"
| "seriesStatus";
@@ -131,6 +134,7 @@ export const FIELD_OPERATORS: Record<StaticRuleField, RuleOperator[]> = {
isbn: ["isEmpty", "isNotEmpty", "eq"],
metadataScore: ["gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
cover: ["isMissing", "isPresent"],
audioCover: ["isMissing", "isPresent"],
lockStatus: ["isLocked", "isUnlocked"],
seriesStatus: ["isUpNext"],
};
+2 -2
View File
@@ -38,8 +38,8 @@ export interface CoverRefreshProgressEvent {
}
export interface CoverRefreshedEvent {
bookId: number;
libraryId: number;
bookIds: number[];
libraryId?: number;
}
export interface BookMissingEvent {