feat(audiobooks): make audiobook libraries first-class catalog items (#73)

* docs(audiobooks): design spec for plugin absorption

Plan to absorb silo-plugin-audiobooks into silo-server as a first-party
feature. Audiobooks land in silo's existing SPA; ABS clients connect
directly. Hard constraints: reuse existing tables (media_items,
media_files, user_watch_progress, user_playback_sessions, people,
item_people, library_collections); only two new tables (abs_sessions,
podcast_feeds) and at most one column add (media_libraries.kind);
silo's main :8080 listener handles ABS Socket.io natively. Out of
scope: audiobook requests flow, smart collections, share links,
external recommender, custom metadata providers, separate audiobook
SPA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): implementation plan sub-plan 1 (discovery + schema)

First of six sub-plans for the absorption. Six tasks: a discovery
audit that resolves the spec's Risk questions, four idempotent SQL
migrations (abs_sessions, podcast_feeds, media_libraries.kind,
audiobooks.enabled feature flag), and an empty-but-compiling
internal/audiobooks package scaffolded into cmd/silo. Lands as a
strict no-op for users (feature flag defaults to false).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): discovery findings for absorption sub-plan 1

Locks schema/code decisions for migrations 139-142 and downstream
sub-plans. Resolves open Risk questions from the absorption design spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): migration 139 add abs_sessions table

Parallel of jellycompat_sessions for Audiobookshelf-compatible clients.
Lets ABS mobile/desktop apps maintain a device-bound session that
silo's audiobooks/abs handlers will validate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(audiobooks): match codebase conventions in migration 139

Lowercases type keywords in the abs_sessions CREATE TABLE body to
match neighboring migrations, fixes the client_version column
alignment, and replaces the misleading "parallel to
jellycompat_sessions" header comment with a more accurate
description of the table's role.

Cosmetic only — the running schema is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): migration 140 add podcast_feeds table

Side table on media_items for RSS-subscribed podcasts. Holds feed URL,
ETag/Last-Modified for conditional fetches, last-refresh timestamp, and
the per-feed refresh interval consumed by the upcoming
podcastfeed.Refresher scheduled task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(audiobooks): uppercase PRIMARY KEY in migration 140

Aligns with the codebase convention (type keywords lowercase,
constraint keywords uppercase) established in migration 139's
post-style-fix form. Cosmetic only — running schema is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(audiobooks): migration 141 no-op for media_folders.type

Sub-plan 1 originally reserved migration 141 to add a 'kind' column to
media_libraries discriminating audiobook/podcast libraries. Discovery
audit (sub-plan 1 Task 1) found that the actual table is media_folders
and it already has a type text NOT NULL column with no CHECK constraint
or enum, so 'audiobooks' and 'podcasts' can be added as future values
without DDL.

Landing this migration as a documented no-op preserves the version
numbering audit trail and pins the decision in git history. The
matching down migration is also a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): migration 142 add audiobooks.enabled flag

Server-settings row that gates the absorbed audiobooks feature.
Defaults to 'false' so sub-plan 1 lands as a strict no-op; subsequent
sub-plans branch on this flag and operators flip it to 'true' at
cutover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): scaffold internal/audiobooks package

Empty-but-compiling Service that reads the audiobooks.enabled feature
flag from server_settings. Wired into cmd/silo so the package is
referenced from the binary; no routes mounted, no scheduled tasks
registered, no DB writes. Subsequent sub-plans hang scanner branches,
ABS handlers, Socket.io, podcast refresher, and SPA pages off this
Service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(audiobooks): cosmetic cleanups in scaffolded package

Two pre-emptive cleanups flagged by code review before sub-plan 2
copies the patterns:

  1. Sort the internal/audiobooks import after internal/adminjob in
     cmd/silo/main.go (alphabetical).
  2. Drop the redundant "audiobooks: " prefix from the Enabled() error
     wrap; matches how every other top-level service package
     (watchstate, scanqueue, metadata, etc.) formats errors.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): implementation plan sub-plan 2 (scanner)

Second of six sub-plans. 10 tasks: PersonKind constants for Author and
Narrator, audio-extension recognizer, library-type helpers, a
walkLogicalTree refactor (movieLibrary bool -> typed walkMode), chapter
extraction via ffprobe, single-file and multi-file audiobook parsers,
scanner write path producing media_items.type='audiobook', and a
filesystem podcast parser (RSS deferred to sub-plan 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): add Author and Narrator PersonKind constants

Discovery audit confirmed item_people.kind is unconstrained smallint
with values 1-6 in use. Reserve 7 = Author, 8 = Narrator for audiobook
people-links written by the upcoming scanner branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): add audio-extension recognizer for scanner

Mirrors the existing videoExtensions/SupportsVideoFile pair. Used by
upcoming audiobook and podcast scanner branches to filter directory
walks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): library-type recognizers for scanner dispatch

isAudiobookLibraryType and isPodcastLibraryType match singular and
plural forms case-insensitively, mirroring isMovieLibraryType. Used by
upcoming scanner walk branches (Task 4) that filter audio files into
audiobook and podcast libraries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(scanner): replace movieLibrary bool with typed walkMode

Lets walkLogicalTree dispatch on multiple library shapes (video, movie,
audiobook, podcast) without proliferating boolean flags. Behavior for
existing video and movie libraries is unchanged; audiobook and podcast
modes will be consumed by the upcoming audiobook.go and podcast.go
parsers in later tasks of this sub-plan.

walkModeFor() derives the mode from a media_folders.type string;
unknown types default to walkModeVideo to preserve prior behavior for
any caller still passing a raw type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): expose ffprobe format tags on ProbeData

The audiobook scanner needs format-level tags (title, artist, album,
date) for media_items metadata; ffprobe already parses them in
ffprobeFormat.Tags but ProbeData previously discarded them. Add
FormatTags map[string]string to ProbeData, populate it in
convertProbeData via a new normalizeFormatTags helper that lowercases
keys and trims values.

Adds a fixture audiobook .m4b with embedded chapters (Intro/Outro) and
format tags, and a test that verifies ProbeFile() returns both
correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): parser for single-file audiobook folders

parseAudiobookFolder reads tags + chapters via the existing ProbeFile
(now that Task 5 exposes FormatTags on ProbeData) and produces a
parsedAudiobook struct. Title falls back from "title" tag to "album";
author from "artist" -> "album_artist" -> "composer"; series from
"album" -> "series" -> "mvnm" (Movement Name, used by some MP4 tools).
Year parsed from "date" or "year" tags, tolerating ISO dates and
parenthesized forms.

Single-file case only; multi-file folders (one audio file per chapter)
return a placeholder error and arrive in Task 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): multi-file audiobook folder support

Folders containing N audio files (one per chapter/part) get one
parsedAudiobookFile per file; each file's chapter list is synthesized
as a single chapter with title = filename stem. Title/author/series/
year come from the first file's tags.

Also drops the duplicate pickFirstNonEmpty helper added in Task 6 in
favor of the existing firstNonEmpty already in probe.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): scanner write path produces audiobook media_items

ScanAudiobookFolder walks an audiobooks-typed media folder and treats
each immediate subdirectory as one audiobook. For each parsed audiobook
it upserts:
  - one media_items row with type='audiobook'
  - one media_files row per audio file (with chapters JSONB)
  - author/narrator links in item_people (kind=7, kind=8)

Adds itemRepo and personRepo to the Scanner struct, wired from
fileRepo.Pool() in NewScanner — no constructor signature change needed.

ScanFolder dispatches to this path when folder.Type='audiobooks',
bypassing the per-file movie/TV pipeline because audiobooks are
folder-scoped entities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): filesystem podcast scanner

ScanPodcastFolder walks a podcasts-typed media folder, treating each
subdirectory as a podcast show and each audio file inside as an
episode. Writes media_items.type='podcast' + episodes rows + media_files
rows. RSS-subscribed feeds (podcast_feeds table) arrive in sub-plan 5;
this task covers filesystem-only ingestion.

ScanFolder dispatches to this path when folder.Type='podcasts'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): implementation plan sub-plan 5 (podcasts)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): expose audiobooks/podcasts library types in admin UI

Adds 'Audiobooks' and 'Podcasts' options to the library-type dropdown
in the admin libraries page so operators can flag a folder as an
audiobook or podcast library. Extends contentLevelsForType() so the
admin UI's downstream filtering treats those types correctly
(audiobook -> ['audiobook'], podcasts -> ['podcast',
'podcast_episode']).

Backend scanner branches for these types were already wired in
sub-plan 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 139_abs_sessions to 147 for origin/main merge

origin/main adds 139_media_requests at the same number our local
audiobook branch had used for abs_sessions. Renumber ours to 147 to
free up 139 for the upstream migration. The schema_versions row is
updated in lockstep on the running database so the migrator sees the
abs_sessions migration as already applied at its new version.

Migrations 140-146 (podcast feeds, media_folders kind noop, audiobook
feature flag, abs playback sessions, podcast episode guid, audiobook
series, audiobook title cleanup) stay where they are — they don't
collide with anything on origin/main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 140_podcast_feeds to 157 for origin/main merge

origin/main added 140_user_permissions at the same version this branch
had used for podcast_feeds. Renumber ours to 157 (next free above the
collections-unify migration at 156) so 140 is free for the upstream
migration. schema_versions on the running database is updated in lockstep
so the migrator sees podcast_feeds as already applied at its new version.

Same pattern as d59c1cb (renumber 139_abs_sessions to 147 for the prior
main merge). Pending migrations after this rename: 132 (downloaded
subtitles admin index, main), 140 (user_permissions, main), and 156
(unify_user_collections, this branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 141_media_folders_kind_noop to 159 for origin/main merge

Same shape as eb8f67d (the 140→157 renumber from the previous main
merge). origin/main added 141_episode_title_sort_index at the same
version this branch had used for media_folders_kind_noop. Renumber
ours to 159 (next free above the audiobook_series truncate at 158) so
141 is open for the upstream migration. schema_versions on the
running database is updated in lockstep so the migrator sees
media_folders_kind_noop as already applied at its new version.

Pending migrations on silo-prod after this rename: 141
(episode_title_sort_index, main) and any other newer ones from main
that the branch hasn't picked up yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 142_audiobooks_feature_flag to 160 for origin/main merge

Companion to 3c6f062's 141 renumber — origin/main also added
142_episode_catalog_entries (alongside 141_episode_title_sort_index)
at a version this branch had used for the audiobooks feature flag.
Renumber ours to 160 so 142 is open for the upstream migration;
schema_versions on silo-prod is updated in lockstep so the migrator
sees audiobooks_feature_flag as already applied at its new version.

This was the only remaining collision (verified by checking for
duplicate version prefixes across migrations/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audiobooks): address foundation review comments

* fix(audiobooks): tighten scanner identity handling

* fix(audiobooks): propagate scanner cancellation

* chore(audiobooks): adopt goose migration layout

* docs(audiobooks): implementation plan sub-plan 3 (API + frontend MVP)

Third of six sub-plans. 9 tasks: three REST endpoints (list/detail/
progress), TanStack Query hooks + types, three React pages
(Library/Detail/Player), and navigation integration. Scoped to MVP —
author/series indices, smart collections, share links, and other
nice-to-haves from the spec are deferred. Streaming reuses silo's
existing /api/v1/stream/{session_id}; no new transcode code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): list endpoint at GET /api/v1/audiobooks

Paginated list of media_items with type='audiobook' scoped to the
caller's accessible libraries via the existing access filter.
Mirrors silo's existing list-style handlers for movies and series.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): detail endpoint at GET /api/v1/audiobooks/{id}

Returns the media_items row, its media_files (with chapters JSONB),
author/narrator extracted from item_people (kinds 7/8), and the
caller's per-profile listening progress from user_watch_progress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): progress endpoint at POST /api/v1/audiobooks/{id}/progress

UPSERTs user_watch_progress for the caller's (user_id, profile_id,
content_id). Body carries position_seconds; clients are expected to
post every 5-10s during playback plus on pause/seek (matching silo's
existing video progress cadence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): frontend types and TanStack Query hooks

TypeScript types match the JSON shapes from the new
/api/v1/audiobooks endpoints (list, detail, progress). Three hooks:
useAudiobookLibrary (list), useAudiobook (detail), and
useReportAudiobookProgress (mutation that invalidates the detail
query on success so progress updates reflect immediately).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): library grid page at /audiobooks

Renders a paginated grid of audiobook cards using the
useAudiobookLibrary hook. Each card links to /audiobooks/book/{id}.
Cards show poster, title, and year; falls back to a "No cover"
placeholder when the audiobook has no poster_url. Empty state hints
to operators that they need to set a library's type to 'audiobooks'.

Routes themselves are wired in Task 8 (navigation integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): detail page with chapter list

Renders cover, title, author, narrator, year, and overview alongside a
chapter list. Clicking a chapter opens an inline sticky
AudiobookPlayer at that chapter's start. A "Resume" button restarts
playback at the saved progress position if present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): HTML5 audio player with chapter navigation

Single-file audiobook playback for MVP. Multi-file queuing arrives in
a follow-up. Streams via the existing /api/v1/direct-download GET
endpoint. Position is reported to /api/v1/audiobooks/{id}/progress
every 10s while playing plus on pause/seek/end. Skip-30s, playback
rate select, chapter list panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): wire navigation and routes

Adds an Audiobooks entry to the sidebar and registers the two new
routes (/audiobooks for the library grid, /audiobooks/book/:id for
detail). The player renders inline inside the detail page; no
dedicated player route is required for MVP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audiobooks): address native API review comments

* feat(audiobooks): add ABS compatibility and polish

* fix(audiobooks): stabilize ABS playback progress reporting

* fix(audiobooks): clean up ABS branch review fixes

* chore(audiobooks): adopt goose layout for ABS migrations

* fix(audiobooks): align player seek bar props

* feat(audiobooks): make libraries first-class catalog items

* feat(admin): add server restart endpoint

* fix(audiobooks): address review comment findings

---------

Co-authored-by: RXWatcher <14085001+RXWatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-07 15:57:05 -04:00
committed by GitHub
co-authored by RXWatcher Claude Opus 4.7
parent c469550fa0
commit eb6024573e
253 changed files with 50703 additions and 446 deletions
@@ -0,0 +1,192 @@
import { useMemo, useState } from "react";
import { FolderPlus, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useCollections, useAddItemToCollection } from "@/hooks/queries/collections";
import { useUserLibraries } from "@/hooks/queries/libraries";
import { useQueries } from "@tanstack/react-query";
import { api } from "@/api/client";
import { libraryCollectionKeys } from "@/hooks/queries/keys";
import { useOptionalAuth } from "@/hooks/useAuth";
import type { LibraryCollection } from "@/api/types";
interface AddToCollectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mediaItemId: string;
/** Optional label shown so the user knows what they're adding. */
itemTitle?: string;
}
interface CollectionPick {
id: string;
title: string;
source: "user" | "library";
group: string;
}
/**
* Add-to-collection picker. Lists the caller's manual user collections
* plus — for admins — every manual library collection grouped by library.
* Synced/smart collections are excluded since they overwrite manual edits
* on the next sync. Selecting a row commits the add via the existing
* PUT collections/{id}/items/{itemId} endpoint (or the /admin variant).
*/
export default function AddToCollectionDialog({
open,
onOpenChange,
mediaItemId,
itemTitle,
}: AddToCollectionDialogProps) {
const auth = useOptionalAuth();
const isAdmin = auth?.user?.role === "admin";
const { data: userCollections, isLoading: userLoading } = useCollections();
const { data: libraries } = useUserLibraries();
const addItem = useAddItemToCollection();
const [selectedId, setSelectedId] = useState<string | null>(null);
// Admins fetch each library's collections so we can offer adding to admin
// manual collections too. Non-admins skip these queries entirely.
const libraryQueries = useQueries({
queries: (isAdmin ? (libraries ?? []) : []).map((lib) => ({
queryKey: libraryCollectionKeys.list(lib.id),
queryFn: () =>
api<{ collections: LibraryCollection[] }>(`/library/${lib.id}/collections`).then(
(data) => data.collections ?? [],
),
enabled: Number.isFinite(lib.id) && lib.id > 0,
})),
});
const libraryLoading = isAdmin && libraryQueries.some((q) => q.isLoading);
const picks = useMemo<CollectionPick[]>(() => {
const out: CollectionPick[] = [];
for (const c of userCollections ?? []) {
if (c.collection_type === "manual") {
out.push({ id: c.id, title: c.name, source: "user", group: "My Collections" });
}
}
if (isAdmin && libraries) {
for (let i = 0; i < libraries.length; i++) {
const lib = libraries[i]!;
const res = libraryQueries[i];
if (res?.data) {
for (const c of res.data) {
if (c.collection_type === "manual") {
out.push({ id: c.id, title: c.title, source: "library", group: lib.name });
}
}
}
}
}
return out;
}, [userCollections, libraries, libraryQueries, isAdmin]);
const groups = useMemo(() => {
const m = new Map<string, CollectionPick[]>();
for (const p of picks) {
if (!m.has(p.group)) m.set(p.group, []);
m.get(p.group)!.push(p);
}
return Array.from(m.entries());
}, [picks]);
const isLoading = userLoading || libraryLoading;
function handleConfirm() {
if (!selectedId) return;
const pick = picks.find((p) => p.id === selectedId);
if (!pick) return;
addItem.mutate(
{ collectionId: pick.id, mediaItemId, source: pick.source },
{
onSuccess: () => {
setSelectedId(null);
onOpenChange(false);
},
},
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add to Collection</DialogTitle>
<DialogDescription>
{itemTitle
? `Pick a manual collection to add "${itemTitle}" to.`
: "Pick a manual collection."}
</DialogDescription>
</DialogHeader>
<div className="max-h-80 overflow-y-auto rounded-md border">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading collections…
</div>
) : groups.length === 0 ? (
<div className="text-muted-foreground px-4 py-10 text-center text-sm">
You don&apos;t have any manual collections yet. Create one in{" "}
<span className="text-foreground">Collections</span> first.
</div>
) : (
<ul className="divide-border divide-y">
{groups.map(([group, list]) => (
<li key={group}>
<div className="bg-muted/50 text-muted-foreground px-3 py-1.5 text-[11px] font-semibold tracking-[0.12em] uppercase">
{group}
</div>
{list.map((p) => {
const isSelected = p.id === selectedId;
return (
<button
key={p.id}
type="button"
onClick={() => setSelectedId(p.id)}
data-selected={isSelected ? "true" : undefined}
className={`hover:bg-muted/50 flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors ${
isSelected ? "bg-muted/60" : ""
}`}
>
<FolderPlus className="text-muted-foreground h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{p.title}</span>
{p.source === "library" && (
<span className="text-muted-foreground text-[10px] tracking-[0.1em] uppercase">
Library
</span>
)}
</button>
);
})}
</li>
))}
</ul>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={addItem.isPending}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedId || addItem.isPending}
className="gap-2"
>
{addItem.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+4
View File
@@ -51,6 +51,7 @@ import {
PinOff,
LayoutGrid,
Puzzle,
BookHeadphones,
Send,
} from "lucide-react";
import { useTheme } from "@/hooks/useTheme";
@@ -63,6 +64,9 @@ function getLibraryIcon(type: string) {
return <Film className="h-[18px] w-[18px]" />;
case "series":
return <Tv className="h-[18px] w-[18px]" />;
case "audiobook":
case "audiobooks":
return <BookHeadphones className="h-[18px] w-[18px]" />;
default:
return <Library className="h-[18px] w-[18px]" />;
}
+12 -3
View File
@@ -143,10 +143,19 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
const containerWidth = isPoster
? "w-[140px] shrink-0 sm:w-[160px] lg:w-[185px]"
: "w-[260px] shrink-0 sm:w-[315px]";
const imageAspect = isPoster ? "aspect-[2/3]" : "aspect-video";
// Audiobook covers are square (Audible-style); use 1:1 for the poster
// variant so they don't get top/bottom-cropped into a 2:3 frame.
const imageAspect = isPoster
? card.type === "audiobook"
? "aspect-square"
: "aspect-[2/3]"
: "aspect-video";
const isSectionEpisode = "sectionItem" in props && props.sectionItem?.type === "episode";
// Section episode payloads use poster_url for vertical season/series artwork
// and backdrop_url for the episode still used by wide cards.
// Episodes store the horizontal still in poster_url (see
// episode_catalog_source.go); wide-variant movies/series/seasons need the
// backdrop for the 16:9 card. Poster variant always wants the vertical
// poster, except section-episode payloads which use poster_url for the
// vertical season/series artwork and backdrop_url for the episode still.
const imagePrimary = isPoster
? card.posterUrl
: isSectionEpisode
+3 -1
View File
@@ -154,7 +154,9 @@ export default function ItemCard({
className="block overflow-hidden rounded-xl"
>
<div
className="media-card-image relative aspect-[2/3]"
className={`media-card-image relative ${
item.type === "audiobook" ? "aspect-square" : "aspect-[2/3]"
}`}
style={
thumbhashUrl
? {
+10 -1
View File
@@ -9,6 +9,7 @@ import { useOverlayPrefs } from "@/hooks/useOverlayPrefs";
interface SharedItemGridProps {
loading?: boolean;
sortField?: string;
libraryId?: number;
selectionMode?: boolean;
selectedIds?: ReadonlySet<string>;
onToggleSelect?: (item: BrowseItem) => void;
@@ -42,7 +43,14 @@ const GRID_CLASSES =
"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3";
export default function ItemGrid(props: ItemGridProps) {
const { loading, sortField, selectionMode = false, selectedIds, onToggleSelect } = props;
const {
loading,
sortField,
libraryId,
selectionMode = false,
selectedIds,
onToggleSelect,
} = props;
const { prefs: overlayPrefs } = useOverlayPrefs();
const totalItems = hasStaticItems(props) ? props.items.length : props.totalItems;
const pages = hasStaticItems(props)
@@ -147,6 +155,7 @@ export default function ItemGrid(props: ItemGridProps) {
<div key={item.content_id} role="listitem">
<ItemCard
item={item}
libraryId={libraryId}
sortField={sortField}
overlayPrefs={overlayPrefs}
selectionMode={selectionMode}
@@ -104,13 +104,18 @@ function sortJobs(jobs: AdminJob[]) {
const leftTime = Date.parse(left.requested_at);
const rightTime = Date.parse(right.requested_at);
if (Number.isNaN(leftTime) || Number.isNaN(rightTime)) {
return right.id.localeCompare(left.id);
const leftID = left.id ?? "";
const rightID = right.id ?? "";
return rightID.localeCompare(leftID);
}
return rightTime - leftTime;
});
}
function upsertJob(existing: AdminJob[] | undefined, nextJob: AdminJob, limit = 50) {
if (!nextJob || !nextJob.id) {
return existing ?? [];
}
const jobs = existing ? [...existing] : [];
const index = jobs.findIndex((job) => job.id === nextJob.id);
if (index >= 0) {
+3 -1
View File
@@ -36,7 +36,9 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp
<div className="relative">
<ViewTransitionLink to={itemHref} className="block overflow-hidden rounded-xl">
<div
className="media-card-image relative aspect-[2/3]"
className={`media-card-image relative ${
item.type === "audiobook" ? "aspect-square" : "aspect-[2/3]"
}`}
style={
thumbhashUrl
? {
@@ -67,6 +67,10 @@ function contentLevelsForType(libraryType: string): string[] {
return ["movie"];
case "mixed":
return ["movie", "series", "season", "episode"];
case "audiobooks":
return ["audiobook"];
case "podcasts":
return ["podcast", "podcast_episode"];
default:
return [];
}
@@ -131,7 +135,10 @@ function buildLevelChainsFromServer(
}
function contentLevelLabel(level: string): string {
return level.charAt(0).toUpperCase() + level.slice(1);
return level
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function buildProviderChainBody(activeLevelChains: Record<string, LevelChainItem[]>) {
@@ -466,6 +473,8 @@ export function LibraryForm({
<SelectItem value="movies">Movies</SelectItem>
<SelectItem value="series">Series</SelectItem>
<SelectItem value="mixed">Mixed</SelectItem>
<SelectItem value="audiobooks">Audiobooks</SelectItem>
<SelectItem value="podcasts">Podcasts</SelectItem>
</SelectContent>
</Select>
</div>
@@ -76,6 +76,7 @@ export default function CatalogFilterBar({
<SelectItem value="movie">Movies</SelectItem>
<SelectItem value="series">Series</SelectItem>
<SelectItem value="episode">Episodes</SelectItem>
<SelectItem value="audiobook">Audiobooks</SelectItem>
</SelectContent>
</Select>
) : null}
@@ -5,6 +5,7 @@ import type { GuidedFormState } from "@/components/collections/CollectionGuidedR
import CollectionGuidedRulesEditor from "@/components/collections/CollectionGuidedRulesEditor";
import CollectionRulesEditor from "@/components/collections/CollectionRulesEditor";
import type { QuerySortRelevanceScope } from "@/lib/querySortOptions";
import type { CatalogSearchState } from "@/pages/catalogSearchParams";
import { Button } from "@/components/ui/button";
import { PortalContainerContext } from "@/components/ui/portal-container-context";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -33,6 +34,9 @@ const EMPTY_SECONDARY_FILTERS: Partial<GuidedFormState> = {
director: "",
writer: "",
producer: "",
author: "",
narrator: "",
series: "",
studio: "",
network: "",
country: "",
@@ -62,6 +66,10 @@ interface CatalogFilterSheetProps {
onQueryDefinitionChange: (qd: QueryDefinition) => void;
filters?: CatalogFiltersResponse;
filtersLoading?: boolean;
libraryType?: string;
// Forwarded into the editor so audiobook-native facet sections can
// typeahead-search /api/v1/catalog/filters/search at the same scope.
catalogState?: CatalogSearchState;
}
export default function CatalogFilterSheet({
@@ -80,6 +88,8 @@ export default function CatalogFilterSheet({
onQueryDefinitionChange,
filters,
filtersLoading = false,
libraryType,
catalogState,
}: CatalogFilterSheetProps) {
// Portal container inside the Sheet's DOM so that react-remove-scroll
// (activated by the Dialog/Sheet) does not block scroll events inside
@@ -152,6 +162,8 @@ export default function CatalogFilterSheet({
showSortControls={false}
filters={filters}
filtersLoading={filtersLoading}
libraryType={libraryType}
catalogState={catalogState}
/>
)}
</div>
@@ -23,6 +23,9 @@ vi.mock("@/components/collections/CollectionGuidedRulesEditor", () => ({
director: "",
writer: "",
producer: "",
author: "",
narrator: "",
series: "",
studio: "",
network: "",
country: "",
@@ -133,6 +136,9 @@ describe("CatalogFiltersPanel", () => {
director: "",
writer: "",
producer: "",
author: "",
narrator: "",
series: "",
studio: "",
network: "",
country: "",
@@ -26,6 +26,7 @@ export interface CatalogFiltersPanelProps {
sortRelevanceScope?: QuerySortRelevanceScope;
resultCountLabel?: string;
resultCountLoading?: boolean;
libraryType?: string;
}
export default function CatalogFiltersPanel({
@@ -39,6 +40,7 @@ export default function CatalogFiltersPanel({
sortRelevanceScope,
resultCountLabel,
resultCountLoading = false,
libraryType,
}: CatalogFiltersPanelProps) {
const [sheetOpen, setSheetOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"guided" | "advanced">("guided");
@@ -107,6 +109,7 @@ export default function CatalogFiltersPanel({
onQueryDefinitionChange={(nextQd) =>
onStateChange({ ...state, query_definition: nextQd })
}
libraryType={libraryType}
/>
) : null}
</div>
@@ -129,6 +132,7 @@ export function CatalogFilterSheetContainer({
onEditorModeChange,
queryDefinition,
onQueryDefinitionChange,
libraryType,
}: {
state: CatalogSearchState;
open: boolean;
@@ -145,6 +149,7 @@ export function CatalogFilterSheetContainer({
onEditorModeChange: (mode: "guided" | "advanced") => void;
queryDefinition: QueryDefinition;
onQueryDefinitionChange: (nextQd: QueryDefinition) => void;
libraryType?: string;
}) {
const filtersQuery = useCatalogFilters(state, {
enabled: editorMode === "guided",
@@ -169,6 +174,8 @@ export function CatalogFilterSheetContainer({
onQueryDefinitionChange={onQueryDefinitionChange}
filters={filtersQuery.data}
filtersLoading={filtersQuery.isLoading}
libraryType={libraryType}
catalogState={state}
/>
);
}
@@ -104,6 +104,30 @@ export function getActiveFilterBadges(state: GuidedFormState): ActiveFilterBadge
});
}
if (state.author) {
badges.push({
key: "author",
label: `Author: ${state.author}`,
clearPatch: { author: "" },
});
}
if (state.narrator) {
badges.push({
key: "narrator",
label: `Narrator: ${state.narrator}`,
clearPatch: { narrator: "" },
});
}
if (state.series) {
badges.push({
key: "series",
label: `Series: ${state.series}`,
clearPatch: { series: "" },
});
}
// Studio
if (state.studio) {
badges.push({
@@ -46,6 +46,9 @@ function emptyState(): GuidedFormState {
director: "",
writer: "",
producer: "",
author: "",
narrator: "",
series: "",
studio: "",
network: "",
country: "",
@@ -150,6 +153,9 @@ describe("guidedStateToQueryDefinition", () => {
director: "Denis Villeneuve",
writer: "Tony Gilroy",
producer: "Emma Thomas",
author: "",
narrator: "",
series: "",
studio: "",
originalLanguages: ["Japanese"],
network: "HBO",
@@ -259,6 +265,36 @@ describe("guidedStateToQueryDefinition", () => {
]);
});
it("round-trips audiobook-native fields (author/narrator/series)", () => {
const original: QueryDefinition = {
library_ids: [9],
media_scope: "audiobook",
match: "all",
groups: [
{
match: "all",
rules: [
{ field: "author", op: "is", value: "Brandon Sanderson" },
{ field: "narrator", op: "is", value: "Michael Kramer" },
{ field: "series", op: "is", value: "Mistborn" },
],
},
],
sort: { field: "title", order: "asc" },
};
const state = queryDefinitionToGuidedState(original);
expect(state.author).toBe("Brandon Sanderson");
expect(state.narrator).toBe("Michael Kramer");
expect(state.series).toBe("Mistborn");
const rebuilt = guidedStateToQueryDefinition(state, original);
const rules = rebuilt.groups[0]!.rules;
expect(rules).toContainEqual({ field: "author", op: "is", value: "Brandon Sanderson" });
expect(rules).toContainEqual({ field: "narrator", op: "is", value: "Michael Kramer" });
expect(rules).toContainEqual({ field: "series", op: "is", value: "Mistborn" });
});
it("round-trips last_air_date sort through parse and serialize", () => {
const original: QueryDefinition = {
library_ids: [1],
@@ -10,6 +10,7 @@ import {
} from "@/api/types";
import LibraryMultiSelect from "@/components/LibraryMultiSelect";
import { Button } from "@/components/ui/button";
import { FacetSearchSelect } from "@/components/ui/facet-search-select";
import { PersonSearchSelect } from "@/components/ui/person-search-select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -23,6 +24,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useCatalogMetadataFilters } from "@/hooks/queries/catalog";
import type { CatalogSearchState } from "@/pages/catalogSearchParams";
import {
getDefaultQuerySortOrder,
normalizeQuerySortForScope,
@@ -38,7 +40,7 @@ const DECADE_OPTIONS = Array.from({ length: 15 }, (_, index) => 2030 - index * 1
/** Flat form state that maps 1-to-1 with friendly form fields. */
export interface GuidedFormState {
mediaScope: "all" | "movie" | "series" | "episode";
mediaScope: "all" | "movie" | "series" | "episode" | "audiobook";
libraryIds: number[];
genres: string[];
decade: string;
@@ -51,6 +53,9 @@ export interface GuidedFormState {
director: string;
writer: string;
producer: string;
author: string;
narrator: string;
series: string;
studio: string;
network: string;
country: string;
@@ -103,6 +108,9 @@ export function queryDefinitionToGuidedState(
director: "",
writer: "",
producer: "",
author: "",
narrator: "",
series: "",
studio: "",
network: "",
country: "",
@@ -177,6 +185,15 @@ export function queryDefinitionToGuidedState(
case "producer":
if (rule.op === "is") state.producer = String(rule.value);
break;
case "author":
if (rule.op === "is") state.author = String(rule.value);
break;
case "narrator":
if (rule.op === "is") state.narrator = String(rule.value);
break;
case "series":
if (rule.op === "is") state.series = String(rule.value);
break;
case "studio":
if (rule.op === "is") state.studio = String(rule.value);
break;
@@ -278,6 +295,15 @@ export function guidedStateToQueryDefinition(
if (state.producer) {
rules.push({ field: "producer", op: "is", value: state.producer });
}
if (state.author) {
rules.push({ field: "author", op: "is", value: state.author });
}
if (state.narrator) {
rules.push({ field: "narrator", op: "is", value: state.narrator });
}
if (state.series) {
rules.push({ field: "series", op: "is", value: state.series });
}
if (state.studio) {
rules.push({ field: "studio", op: "is", value: state.studio });
}
@@ -358,6 +384,16 @@ interface CollectionGuidedRulesEditorProps {
showSortControls?: boolean;
filters?: CatalogFiltersResponse;
filtersLoading?: boolean;
// When the editor is scoped to a single library (e.g. on the library
// browse page) pass its type so video-only filter sections (Director,
// Studio, Network, Content Rating, Video Quality, etc.) can be hidden
// for audiobook libraries.
libraryType?: string;
// When set, the audiobook-native facet sections (Author / Narrator /
// Series) switch to typeahead-backed FacetSearchSelect, querying
// /api/v1/catalog/filters/search scoped to this state. Without it
// they fall back to the bulk filters payload (top 1000 alphabetical).
catalogState?: CatalogSearchState;
}
export default function CollectionGuidedRulesEditor({
@@ -373,11 +409,17 @@ export default function CollectionGuidedRulesEditor({
showSortControls = true,
filters: providedFilters,
filtersLoading: providedFiltersLoading,
libraryType,
catalogState,
}: CollectionGuidedRulesEditorProps) {
const state = useMemo(() => queryDefinitionToGuidedState(value), [value]);
const metadataFiltersQuery = useCatalogMetadataFilters();
const filters = providedFilters ?? metadataFiltersQuery.data;
const filtersLoading = providedFiltersLoading ?? metadataFiltersQuery.isLoading;
// Audiobook-only libraries should hide video-only filter sections.
// Backend stores type as the plural "audiobooks"; the singular form
// shows up in some API surfaces — accept either.
const isAudiobookLibrary = libraryType === "audiobook" || libraryType === "audiobooks";
const sortOptions = getCollectionSortOptions(allowPersonalizedSorts, sortRelevanceScope);
const selectedSort = normalizeQuerySortForScope(
{ field: state.sortField, order: state.sortOrder },
@@ -518,32 +560,34 @@ export default function CollectionGuidedRulesEditor({
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Minimum IMDb Rating</Label>
<Input
type="number"
min={0}
max={10}
step={0.1}
value={state.minRating}
onChange={(e) => update({ minRating: e.target.value })}
placeholder="e.g. 7.0"
disabled={readOnly}
/>
{isAudiobookLibrary ? null : (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Minimum IMDb Rating</Label>
<Input
type="number"
min={0}
max={10}
step={0.1}
value={state.minRating}
onChange={(e) => update({ minRating: e.target.value })}
placeholder="e.g. 7.0"
disabled={readOnly}
/>
</div>
<div className="space-y-2">
<Label>Content Rating</Label>
<SearchableSelect
options={filters?.content_ratings ?? []}
value={state.contentRating}
onChange={(contentRating) => update({ contentRating })}
placeholder="Select rating..."
disabled={readOnly}
isLoading={filtersLoading}
/>
</div>
</div>
<div className="space-y-2">
<Label>Content Rating</Label>
<SearchableSelect
options={filters?.content_ratings ?? []}
value={state.contentRating}
onChange={(contentRating) => update({ contentRating })}
placeholder="Select rating..."
disabled={readOnly}
isLoading={filtersLoading}
/>
</div>
</div>
)}
<div className="space-y-2">
<Label>Original Language</Label>
@@ -558,68 +602,147 @@ export default function CollectionGuidedRulesEditor({
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Actor</Label>
<PersonSearchSelect
value={state.actor}
onChange={(actor) => update({ actor })}
disabled={readOnly}
/>
</div>
<div className="space-y-2">
<Label>Director</Label>
<PersonSearchSelect
value={state.director}
onChange={(director) => update({ director })}
disabled={readOnly}
/>
</div>
</div>
{isAudiobookLibrary ? null : (
<>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Actor</Label>
<PersonSearchSelect
value={state.actor}
onChange={(actor) => update({ actor })}
disabled={readOnly}
/>
</div>
<div className="space-y-2">
<Label>Director</Label>
<PersonSearchSelect
value={state.director}
onChange={(director) => update({ director })}
disabled={readOnly}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Writer</Label>
<PersonSearchSelect
value={state.writer}
onChange={(writer) => update({ writer })}
disabled={readOnly}
/>
</div>
<div className="space-y-2">
<Label>Producer</Label>
<PersonSearchSelect
value={state.producer}
onChange={(producer) => update({ producer })}
disabled={readOnly}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Writer</Label>
<PersonSearchSelect
value={state.writer}
onChange={(writer) => update({ writer })}
disabled={readOnly}
/>
</div>
<div className="space-y-2">
<Label>Producer</Label>
<PersonSearchSelect
value={state.producer}
onChange={(producer) => update({ producer })}
disabled={readOnly}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Studio</Label>
<SearchableSelect
options={filters?.studios ?? []}
value={state.studio}
onChange={(studio) => update({ studio })}
placeholder="Select studio..."
disabled={readOnly}
isLoading={filtersLoading}
/>
</div>
<div className="space-y-2">
<Label>Network</Label>
<SearchableSelect
options={filters?.networks ?? []}
value={state.network}
onChange={(network) => update({ network })}
placeholder="Select network..."
disabled={readOnly}
isLoading={filtersLoading}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Studio</Label>
<SearchableSelect
options={filters?.studios ?? []}
value={state.studio}
onChange={(studio) => update({ studio })}
placeholder="Select studio..."
disabled={readOnly}
isLoading={filtersLoading}
/>
</div>
<div className="space-y-2">
<Label>Network</Label>
<SearchableSelect
options={filters?.networks ?? []}
value={state.network}
onChange={(network) => update({ network })}
placeholder="Select network..."
disabled={readOnly}
isLoading={filtersLoading}
/>
</div>
</div>
</>
)}
{isAudiobookLibrary ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Author</Label>
{catalogState ? (
<FacetSearchSelect
facet="author"
state={catalogState}
value={state.author}
onChange={(author) => update({ author })}
placeholder="Search authors..."
disabled={readOnly}
/>
) : (
<SearchableSelect
options={filters?.authors ?? []}
value={state.author}
onChange={(author) => update({ author })}
placeholder="Select author..."
disabled={readOnly}
isLoading={filtersLoading}
/>
)}
</div>
<div className="space-y-2">
<Label>Narrator</Label>
{catalogState ? (
<FacetSearchSelect
facet="narrator"
state={catalogState}
value={state.narrator}
onChange={(narrator) => update({ narrator })}
placeholder="Search narrators..."
disabled={readOnly}
/>
) : (
<SearchableSelect
options={filters?.narrators ?? []}
value={state.narrator}
onChange={(narrator) => update({ narrator })}
placeholder="Select narrator..."
disabled={readOnly}
isLoading={filtersLoading}
/>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Series</Label>
{catalogState ? (
<FacetSearchSelect
facet="series"
state={catalogState}
value={state.series}
onChange={(series) => update({ series })}
placeholder="Search series..."
disabled={readOnly}
/>
) : (
<SearchableSelect
options={filters?.series ?? []}
value={state.series}
onChange={(series) => update({ series })}
placeholder="Select series..."
disabled={readOnly}
isLoading={filtersLoading}
/>
)}
</div>
</div>
</>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@@ -681,34 +804,36 @@ export default function CollectionGuidedRulesEditor({
) : null}
</div>
<div className="space-y-2">
<Label>Video Quality</Label>
<div className="flex flex-wrap gap-2">
{[
{ key: "fourK", label: "4K" },
{ key: "hdr", label: "HDR" },
{ key: "dolbyVision", label: "DOVI" },
].map((option) => {
const selected = state[option.key as keyof GuidedFormState] === true;
return (
<Button
key={option.key}
type="button"
variant={selected ? "default" : "outline"}
size="sm"
onClick={() =>
update({
[option.key]: !selected,
} as Partial<GuidedFormState>)
}
disabled={readOnly}
>
{option.label}
</Button>
);
})}
{isAudiobookLibrary ? null : (
<div className="space-y-2">
<Label>Video Quality</Label>
<div className="flex flex-wrap gap-2">
{[
{ key: "fourK", label: "4K" },
{ key: "hdr", label: "HDR" },
{ key: "dolbyVision", label: "DOVI" },
].map((option) => {
const selected = state[option.key as keyof GuidedFormState] === true;
return (
<Button
key={option.key}
type="button"
variant={selected ? "default" : "outline"}
size="sm"
onClick={() =>
update({
[option.key]: !selected,
} as Partial<GuidedFormState>)
}
disabled={readOnly}
>
{option.label}
</Button>
);
})}
</div>
</div>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@@ -68,7 +68,9 @@ export default function CollectionRulesEditor({
onChange({
...value,
media_scope:
next === "all" ? undefined : (next as "movie" | "series" | "episode"),
next === "all"
? undefined
: (next as "movie" | "series" | "episode" | "audiobook"),
sort: nextSort,
});
}}
@@ -82,6 +84,7 @@ export default function CollectionRulesEditor({
<SelectItem value="movie">Movies</SelectItem>
<SelectItem value="series">Series</SelectItem>
<SelectItem value="episode">Episodes</SelectItem>
<SelectItem value="audiobook">Audiobooks</SelectItem>
</SelectContent>
</Select>
</div>
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { GripVertical, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { GripVertical, Loader2, Plus, Search, Trash2 } from "lucide-react";
import { DndContext } from "@dnd-kit/core";
import {
@@ -9,29 +10,149 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { CollectionItem } from "@/api/types";
import type { BrowseItem, CollectionItem } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
useAddItemToCollection,
useCollectionItems,
useRemoveCollectionItem,
useReorderCollectionItems,
} from "@/hooks/queries/collections";
import { createCatalogSearchState, fetchCatalogPage } from "@/hooks/queries/catalog";
import { catalogKeys } from "@/hooks/queries/keys";
import { useSortableList } from "@/hooks/useSortableList";
import { useDebounce } from "@/hooks/useDebounce";
interface ManualCollectionItemsEditorProps {
collectionId: string;
readOnly?: boolean;
/**
* Whether this collection lives under the admin /admin/collections/* route
* (library collection) or the user /collections/* route (personal). The
* add-item mutation needs to know to hit the right endpoint.
*/
source?: "user" | "library";
}
const SEARCH_LIMIT = 12;
const DEBOUNCE_MS = 250;
function AddItemPanel({
collectionId,
source,
existingIds,
}: {
collectionId: string;
source: "user" | "library";
existingIds: Set<string>;
}) {
const [query, setQuery] = useState("");
const debounced = useDebounce(query.trim(), DEBOUNCE_MS);
const addItem = useAddItemToCollection();
const searchState = useMemo(
() => createCatalogSearchState("query", { q: debounced || undefined }),
[debounced],
);
const results = useQuery({
queryKey: [
"manualCollectionPicker",
collectionId,
catalogKeys.list({
source: searchState.source,
q: searchState.q,
limit: SEARCH_LIMIT,
offset: 0,
}),
],
queryFn: ({ signal }) => fetchCatalogPage(searchState, SEARCH_LIMIT, 0, { signal }),
enabled: debounced.length > 0,
staleTime: 30 * 1000,
});
const items: BrowseItem[] = results.data?.items ?? [];
return (
<div className="space-y-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search the catalog to add titles…"
className="pl-9"
autoComplete="off"
/>
</div>
{debounced.length === 0 ? null : results.isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 px-3 py-3 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Searching…
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-3 text-sm">
No matches.
</div>
) : (
<ul className="border-border/60 divide-border/60 max-h-72 divide-y overflow-y-auto rounded-md border">
{items.map((item) => {
const already = existingIds.has(item.content_id);
return (
<li key={item.content_id} className="flex items-center gap-3 px-3 py-2">
{item.poster_url ? (
<img
src={item.poster_url}
alt=""
className="bg-muted h-10 w-10 shrink-0 rounded object-cover"
/>
) : (
<div className="bg-muted h-10 w-10 shrink-0 rounded" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{item.title}</div>
<div className="text-muted-foreground text-xs">
{item.year ? `${item.year} · ` : ""}
{item.type}
</div>
</div>
<Button
size="sm"
variant={already ? "ghost" : "outline"}
disabled={already || addItem.isPending}
onClick={() =>
addItem.mutate({
collectionId,
mediaItemId: item.content_id,
source,
})
}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
{already ? "Added" : "Add"}
</Button>
</li>
);
})}
</ul>
)}
</div>
);
}
export function ManualCollectionItemsEditor({
collectionId,
readOnly = false,
source = "user",
}: ManualCollectionItemsEditorProps) {
const { data, isLoading } = useCollectionItems(collectionId);
const items = useMemo(() => data ?? [], [data]);
const reorderMutation = useReorderCollectionItems(collectionId);
const removeMutation = useRemoveCollectionItem(collectionId);
const existingIds = useMemo(() => new Set(items.map((i) => i.media_item_id)), [items]);
const { sensors, collisionDetection, handleDragEnd } = useSortableList(
items,
@@ -49,37 +170,40 @@ export function ManualCollectionItemsEditor({
);
}
if (items.length === 0) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-5 text-sm">
No items yet. Items added to this collection will appear here.
</div>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragEnd={readOnly ? undefined : handleDragEnd}
>
<SortableContext
items={items.map((item) => item.media_item_id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{items.map((item, index) => (
<SortableItemRow
key={item.media_item_id}
item={item}
index={index}
readOnly={readOnly}
onRemove={() => removeMutation.mutate(item.media_item_id)}
/>
))}
<div className="space-y-4">
{!readOnly && (
<AddItemPanel collectionId={collectionId} source={source} existingIds={existingIds} />
)}
{items.length === 0 ? (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-5 text-sm">
No items yet. Search above to add titles.
</div>
</SortableContext>
</DndContext>
) : (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragEnd={readOnly ? undefined : handleDragEnd}
>
<SortableContext
items={items.map((item) => item.media_item_id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{items.map((item, index) => (
<SortableItemRow
key={item.media_item_id}
item={item}
index={index}
readOnly={readOnly}
onRemove={() => removeMutation.mutate(item.media_item_id)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
);
}
@@ -61,8 +61,9 @@ export function SectionSummaryBadges({
<Badge variant="secondary">{recipeLabel(catalog, section.sectionType)}</Badge>
{queryDefinition.media_scope === "movie" ? <Badge variant="outline">Movies</Badge> : null}
{queryDefinition.media_scope === "series" ? <Badge variant="outline">Series</Badge> : null}
{queryDefinition.media_scope === "episode" ? (
<Badge variant="outline">Episodes</Badge>
{queryDefinition.media_scope === "episode" ? <Badge variant="outline">Episodes</Badge> : null}
{queryDefinition.media_scope === "audiobook" ? (
<Badge variant="outline">Audiobooks</Badge>
) : null}
{libraries
? queryDefinition.library_ids.map((libraryId) => {
@@ -477,7 +477,9 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) {
setQueryDefinition({
...queryDefinition,
media_scope:
value === "all" ? undefined : (value as "movie" | "series" | "episode"),
value === "all"
? undefined
: (value as "movie" | "series" | "episode" | "audiobook"),
})
}
>
@@ -489,6 +491,7 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) {
<SelectItem value="movie">Movies</SelectItem>
<SelectItem value="series">Series</SelectItem>
<SelectItem value="episode">Episodes</SelectItem>
<SelectItem value="audiobook">Audiobooks</SelectItem>
</SelectContent>
</Select>
</div>
@@ -0,0 +1,228 @@
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { Popover as PopoverPrimitive } from "radix-ui";
import { Check, ChevronsUpDown } from "lucide-react";
import { fetchCatalogFacetSearch, type CatalogFacetName } from "@/hooks/queries/catalog";
import { useDebounce } from "@/hooks/useDebounce";
import type { CatalogSearchState } from "@/pages/catalogSearchParams";
import { cn } from "@/lib/utils";
import { PortalContainerContext } from "./portal-container-context";
interface FacetSearchSelectProps {
/** The catalog facet to typeahead-search (e.g. "author", "series"). */
facet: CatalogFacetName;
/** The catalog scope — usually the same state passed to CatalogFiltersPanel. */
state: CatalogSearchState;
/** Currently selected value (or empty for "any"). */
value: string;
onChange: (next: string) => void;
placeholder?: string;
disabled?: boolean;
/** Max matches to fetch per query. Defaults to 20. */
limit?: number;
}
/**
* FacetSearchSelect is the typeahead counterpart to SearchableSelect for
* high-cardinality catalog facets (Author, Narrator, Series). It debounces
* the user's input then queries /api/v1/catalog/filters/search with the
* given facet + prefix. The dropdown lists matches in alphabetical order
* with the currently selected value pinned first so users can clear it
* without re-searching.
*
* The component intentionally does not render an "All N items" indicator —
* the typeahead pattern relies on the user narrowing rather than scrolling.
* When more matches are available than were returned, a small "Keep typing
* to narrow further" hint shows below the list.
*/
export function FacetSearchSelect({
facet,
state,
value,
onChange,
placeholder = "Search...",
disabled = false,
limit = 20,
}: FacetSearchSelectProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const [focusedIndex, setFocusedIndex] = React.useState(-1);
const listboxId = React.useId();
const portalContainer = React.useContext(PortalContainerContext);
const debouncedQuery = useDebounce(search.trim(), 200);
const query = useQuery({
queryKey: ["catalogFacetSearch", facet, debouncedQuery, limit, state] as const,
queryFn: ({ signal }) =>
fetchCatalogFacetSearch(state, facet, debouncedQuery, limit, { signal }),
enabled: open && debouncedQuery.length > 0,
staleTime: 60 * 1000,
});
const matches = query.data?.matches ?? [];
const hasMore = query.data?.has_more ?? false;
const options = React.useMemo(() => {
const names = new Set<string>();
if (value) {
names.add(value);
}
for (const name of matches) {
if (name) {
names.add(name);
}
}
return Array.from(names);
}, [matches, value]);
const allItems = React.useMemo(() => ["", ...options], [options]);
React.useEffect(() => {
if (!open) {
setFocusedIndex(-1);
setSearch("");
}
}, [open]);
function handleSearchKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === "ArrowDown") {
event.preventDefault();
setFocusedIndex((prev) => (prev < allItems.length - 1 ? prev + 1 : prev));
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
return;
}
if (event.key === "Enter") {
event.preventDefault();
if (focusedIndex >= 0 && focusedIndex < allItems.length) {
const nextValue = allItems[focusedIndex];
if (nextValue !== undefined) {
onChange(nextValue);
setOpen(false);
}
}
}
}
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal={false}>
<PopoverPrimitive.Trigger asChild disabled={disabled}>
<button
type="button"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
className={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-2 text-sm shadow-xs focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
!value && "text-muted-foreground",
)}
>
<span className="truncate">{value || placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
<PopoverPrimitive.Content
align="start"
sideOffset={4}
collisionPadding={8}
className="bg-popover text-popover-foreground z-50 w-[var(--radix-popover-trigger-width)] rounded-md border shadow-md"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="p-2">
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setFocusedIndex(-1);
}}
onKeyDown={handleSearchKeyDown}
placeholder="Type to search..."
aria-label="Search"
aria-controls={listboxId}
aria-activedescendant={
focusedIndex >= 0 ? `${listboxId}-opt-${focusedIndex}` : undefined
}
className="border-input bg-background placeholder:text-muted-foreground flex h-8 w-full rounded-md border px-2 text-sm outline-none"
autoFocus
/>
</div>
<div
id={listboxId}
role="listbox"
className="max-h-60 overflow-y-auto overscroll-contain p-1"
>
{query.isLoading ? (
<p className="text-muted-foreground py-4 text-center text-sm">Loading...</p>
) : options.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{debouncedQuery ? "No matches" : "Start typing to search"}
</p>
) : (
<>
<button
id={`${listboxId}-opt-0`}
type="button"
role="option"
aria-selected={!value}
className={cn(
"hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm select-none",
!value && "font-medium",
focusedIndex === 0 && "bg-accent text-accent-foreground",
)}
onClick={() => {
onChange("");
setOpen(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4 shrink-0", value ? "opacity-0" : "opacity-100")}
/>
<span className="text-muted-foreground italic">Any</span>
</button>
{options.map((option, index) => (
<button
id={`${listboxId}-opt-${index + 1}`}
key={option}
type="button"
role="option"
aria-selected={value === option}
className={cn(
"hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm select-none",
value === option && "font-medium",
focusedIndex === index + 1 && "bg-accent text-accent-foreground",
)}
onClick={() => {
onChange(option);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4 shrink-0",
value === option ? "opacity-100" : "opacity-0",
)}
/>
{option}
</button>
))}
{hasMore ? (
<p className="text-muted-foreground px-2 py-1.5 text-xs italic">
Keep typing to narrow further...
</p>
) : null}
</>
)}
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}