14 KiB
Library Management — Design Spec
Date: 2026-03-19 Status: Approved
Context
K-TV currently has ephemeral library browsing: metadata is always fetched live from providers (Jellyfin, local files) on demand, only accessible through the block editor filter UI. There is no persistent library, no cross-provider browsing, and no way to schedule directly from browsing media.
This feature introduces an in-house library that syncs and stores media metadata from all providers into k-tv's own DB, then surfaces it through a first-class /library page where users can browse, filter, multi-select, and schedule media directly onto channels.
Data Model
Migration 20260319000002_add_library_tables.sql
library_items table
| Column | Type | Notes |
|---|---|---|
id |
TEXT PK | "{provider_id}::{raw_item_id}" — double-colon, matches existing registry prefix format |
provider_id |
TEXT | "jellyfin", "local", etc. |
external_id |
TEXT | Raw ID from provider (for re-fetching) |
title |
TEXT | |
content_type |
TEXT | "movie" | "episode" | "short" |
duration_secs |
INTEGER | |
series_name |
TEXT | NULL for movies |
season_number |
INTEGER | NULL for movies |
episode_number |
INTEGER | NULL for movies |
year |
INTEGER | |
genres |
TEXT | JSON array |
tags |
TEXT | JSON array |
collection_id |
TEXT | Provider-specific collection ID |
collection_name |
TEXT | Human-readable name (synced from provider) |
collection_type |
TEXT | e.g. "movies", "tvshows" |
thumbnail_url |
TEXT | Provider-served image URL; re-fetched on every sync |
synced_at |
TEXT | ISO8601 timestamp |
thumbnail_url is refreshed on every full sync. Frontend must handle broken image URLs gracefully (show a placeholder on load error) since URLs may break if provider URL or API key changes between syncs.
library_sync_log table
| Column | Type | Notes |
|---|---|---|
id |
INTEGER PK AUTOINCREMENT | |
provider_id |
TEXT | |
started_at |
TEXT | ISO8601 |
finished_at |
TEXT | ISO8601, NULL while running |
items_found |
INTEGER | |
status |
TEXT | "running" | "done" | "error" |
error_msg |
TEXT | NULL on success |
Migration 20260319000003_add_app_settings.sql
app_settings table — general-purpose key-value store for admin-configurable settings. Co-exists with the existing transcode_settings singleton table (that table is not modified). Seeded with: INSERT OR IGNORE INTO app_settings(key, value) VALUES ('library_sync_interval_hours', '6').
| Column | Type | Notes |
|---|---|---|
key |
TEXT PK | |
value |
TEXT | Bare JSON scalar stored as text (e.g. 6, not "6") |
GET /admin/settings returns parsed values: { "library_sync_interval_hours": 6 } (number, not string). Backend parses with serde_json::Value on read; frontend receives typed JSON.
Backend Architecture
Sync Engine
Layer placement:
LibraryItem,LibrarySyncResult,LibrarySyncAdaptertrait, andILibraryRepositorytrait live indomain/src/library.rsFullSyncAdapter(impl) andSqliteLibraryRepository(impl) live ininfra/src/library/
The LibrarySyncAdapter domain trait does not take a DB pool — DB writes are an infra concern handled entirely inside the impl:
// domain/src/library.rs
#[async_trait]
pub trait LibrarySyncAdapter: Send + Sync {
async fn sync_provider(
&self,
provider: &dyn IMediaProvider,
provider_id: &str,
) -> LibrarySyncResult;
}
#[async_trait]
pub trait ILibraryRepository: Send + Sync {
async fn search(&self, filter: LibrarySearchFilter) -> Vec<LibraryItem>;
async fn get_by_id(&self, id: &str) -> Option<LibraryItem>;
async fn list_collections(&self, provider_id: Option<&str>) -> Vec<LibraryCollection>;
async fn list_series(&self, provider_id: Option<&str>) -> Vec<String>;
async fn list_genres(&self, content_type: Option<ContentType>, provider_id: Option<&str>) -> Vec<String>;
async fn upsert_items(&self, provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()>;
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>;
async fn log_sync_start(&self, provider_id: &str) -> i64; // returns log row id
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult);
async fn latest_sync_status(&self) -> Vec<LibrarySyncLogEntry>;
async fn is_sync_running(&self, provider_id: &str) -> bool;
}
FullSyncAdapter in infra holds Arc<dyn ILibraryRepository> and calls repo methods internally — no DB pool leaks into domain.
infra/src/library/
mod.rs
full_sync.rs -- FullSyncAdapter impl: calls list_collections for names/types,
fetch_items(&MediaFilter::default()), repo.clear_provider + repo.upsert_items
repository.rs -- SqliteLibraryRepository impl of ILibraryRepository
scheduler.rs -- tokio interval task; 10s startup delay (hardcoded); reads interval from
app_settings on each tick via AppSettingsRepository
AppState gains:
library_sync_adapter: Arc<dyn LibrarySyncAdapter>,
library_repo: Arc<dyn ILibraryRepository>,
Sync Concurrency Guard
Before starting a sync for a provider, the scheduler and POST /library/sync handler both call repo.is_sync_running(provider_id). If true, the scheduler skips that provider for this tick; the HTTP endpoint returns 409 Conflict with body { "error": "sync already running for provider" }. This prevents the truncate+insert race.
Admin Settings
GET /admin/settings— returnsapp_settingsrows as parsed JSON object. Requiresis_admin = true(AdminUserextractor).PUT /admin/settings— partial update (only provided keys updated). Requiresis_admin = true. Scheduler reads new value on next tick.
Library API Routes (all require authenticated user)
| Endpoint | Notes |
|---|---|
GET /library/items?type=&series[]=&collection=&genre=&decade=&min_duration=&max_duration=&search=&provider=&offset=0&limit=50 |
DB-backed; returns { items: LibraryItemResponse[], total: u32 } |
GET /library/items/:id |
Single item |
GET /library/collections?provider= |
{ id, name, collection_type }[] from DB |
GET /library/series?provider= |
String[] from DB |
GET /library/genres?type=&provider= |
String[] from DB |
GET /library/sync/status |
LibrarySyncLogEntry[] (latest per provider) |
POST /library/sync |
Fires sync; 409 if already running; requires is_admin = true |
GET /admin/settings |
{ key: value } map (parsed); requires is_admin = true |
PUT /admin/settings |
Partial update; requires is_admin = true |
Existing library route API contract is unchanged for all params except offset/limit (new). Frontend use-library.ts hooks continue working without modification.
Frontend Architecture
New route: /library
Added to main nav alongside Dashboard and TV.
app/(main)/library/
page.tsx -- layout, search/filter state, pagination state, multi-select state
components/
library-sidebar.tsx -- provider picker, type, genre chips, series picker, decade, duration range
library-grid.tsx -- paginated grid of LibraryItemCard
library-item-card.tsx -- thumbnail (with broken-image fallback placeholder), title,
duration badge, content type, checkbox
schedule-from-library-dialog.tsx -- modal (see flow below)
add-to-block-dialog.tsx -- modal (see flow below)
sync-status-bar.tsx -- "Last synced 2h ago · Jellyfin" strip at top
New hooks
hooks/use-library-search.ts -- useLibrarySearch(filter, page): wraps GET /library/items with
offset/limit pagination. Query key: ["library", "search", filter, page].
onSuccess of useTriggerSync: invalidate ["library", "search"] and ["library", "sync"].
hooks/use-library-sync.ts -- useLibrarySyncStatus() → ["library", "sync"],
useTriggerSync() → POST /library/sync; on success invalidates
["library", "search"] and ["library", "sync"]
hooks/use-admin-settings.ts -- useAdminSettings(), useUpdateAdminSettings()
Existing use-library.ts and its four hooks (useCollections, useSeries, useGenres, useLibraryItems) are unchanged — still used by AlgorithmicFilterEditor in the block editor.
Schedule From Library Flow
- User selects one or more items → floating action bar at bottom
- "Schedule on channel" →
ScheduleFromLibraryDialogmodal - Modal fields (in order — time/days/strategy disabled until channel is selected):
- Channel picker (required; enables remaining fields once selected)
- Days: Mon–Sun checkboxes
- Time:
NaiveTimeinput interpreted in the selected channel's timezone. Timezone label displayed inline (e.g. "20:00 Europe/Warsaw"). Disabled until channel is selected. - Duration: For single item, defaults to
ceil(duration_secs / 60)minutes shown in UI. For multi-item, user sets manually. Rounding to nearest minute shown explicitly (e.g. "1h 35m (rounded from 1h 34m 47s)"). - Fill strategy: Sequential (default for episodic) | Random | Best Fit
- Preview: "3 blocks will be created on [Channel] — Mon/Wed/Fri at 20:00 [Europe/Warsaw], Sequential"
- Confirm →
PUT /channels/:idmerging newProgrammingBlockentries intoschedule_config.day_blocks:- Series / episodic: Algorithmic block with
series_names: [series] - Specific item(s): Manual block with those item IDs
- Series / episodic: Algorithmic block with
Add To Block Flow
- User selects items → "Add to block" from action bar
AddToBlockDialog:- Pick channel
- Pick existing manual block: populated from
useChannel(id)by collecting all blocks across all days withcontent.type === "manual", deduplicated by blockid(same block appearing Mon + Wed shown once)
- Confirm → appends item IDs to that block. Since the same block object (by
id) may appear in multiple days inschedule_config.day_blocks, the PUT updates all day entries that contain that block id — the block is mutated wherever it appears, consistently.
Admin Settings UI
Settings panel (cog icon in dashboard header, alongside existing transcode settings) gains a "Library sync" section:
- Number input: "Sync interval (hours)"
- "Sync now" button (visible to admin users only; calls
POST /library/sync; disabled + shows spinner while running) - Status: "Last synced: [time] · [N] items" per provider from
GET /library/sync/status
Key Files Modified
Backend:
domain/src/lib.rs— addlibrarymoduledomain/src/library.rs— new:LibraryItem,LibraryCollection,LibrarySyncResult,LibrarySyncAdaptertrait,ILibraryRepositorytrait,LibrarySearchFilter,LibrarySyncLogEntryinfra/src/library/full_sync.rs—FullSyncAdapterimplinfra/src/library/repository.rs—SqliteLibraryRepositoryimplinfra/src/library/scheduler.rs— tokio interval task, 10s startup delayapi/src/routes/library.rs— DB-backed handlers + sync/admin routesapi/src/routes/mod.rs— wire admin settings routesapi/src/main.rs— start sync scheduler taskapi/src/state.rs— addlibrary_sync_adapter: Arc<dyn LibrarySyncAdapter>,library_repo: Arc<dyn ILibraryRepository>migrations_sqlite/20260319000002_add_library_tables.sqlmigrations_sqlite/20260319000003_add_app_settings.sql
Frontend:
lib/types.ts— addLibraryItem,LibraryCollection,SyncLogEntry,AdminSettingslib/api.ts— addapi.library.items(filter, page),api.library.syncStatus(),api.library.triggerSync(),api.admin.getSettings(),api.admin.updateSettings(partial)app/(main)/layout.tsx— add Library nav link- New files per structure above
Verification
- Sync:
POST /library/sync→ 200.GET /library/sync/statusshowsdonewith item count.library_itemsrows in DB havecollection_nameandthumbnail_urlpopulated. - Sync dedup: Second
POST /library/syncwhile first is running → 409 Conflict. - Library API pagination:
GET /library/items?offset=0&limit=10returns 10 items +total.?offset=10&limit=10returns next page. - Provider filter:
GET /library/items?provider=jellyfinreturns only Jellyfin items. - Collections:
GET /library/collectionsreturns{ id, name, collection_type }objects. - Admin guard:
POST /library/syncandPUT /admin/settingswith non-admin user → 403. - Admin settings:
PUT /admin/settings { "library_sync_interval_hours": 2 }→GET /admin/settingsreturns{ "library_sync_interval_hours": 2 }(number). Scheduler uses new interval. - Library UI:
/librarypage loads, sidebar filters update grid, pagination controls work.sync-status-barshows last sync time. - Broken thumbnail: Item with a broken
thumbnail_urlshows fallback placeholder inlibrary-item-card. - Multi-select action bar: Select 3 items → action bar appears with "Schedule on channel" and "Add to block".
- Schedule flow — time gating: Time input is disabled until channel is selected; timezone shown next to input after channel selected.
- Schedule flow — rounding: Single-item selection shows rounded duration with note in dialog.
- Schedule flow — confirm: Series scheduled → Dashboard shows Algorithmic blocks on correct days with
series_namesfilter. - Add to block — dedup: Block appearing on Mon+Wed shown once in picker. Confirming updates both days.
- Cache invalidation: After
useTriggerSync()resolves,["library", "search"]and["library", "sync"]query keys are invalidated, grid refreshes. - Block editor unchanged:
AlgorithmicFilterEditorworks;useLibraryItemsinuse-library.tsunchanged. - Regression:
cargo testpasses.