mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -38,8 +38,8 @@ export interface CoverRefreshProgressEvent {
|
||||
}
|
||||
|
||||
export interface CoverRefreshedEvent {
|
||||
bookId: number;
|
||||
libraryId: number;
|
||||
bookIds: number[];
|
||||
libraryId?: number;
|
||||
}
|
||||
|
||||
export interface BookMissingEvent {
|
||||
|
||||
Reference in New Issue
Block a user