Files
k-tv/docs/superpowers/specs/2026-03-19-library-management-design.md

14 KiB
Raw Blame History

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, LibrarySyncAdapter trait, and ILibraryRepository trait live in domain/src/library.rs
  • FullSyncAdapter (impl) and SqliteLibraryRepository (impl) live in infra/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 — returns app_settings rows as parsed JSON object. Requires is_admin = true (AdminUser extractor).
  • PUT /admin/settings — partial update (only provided keys updated). Requires is_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

  1. User selects one or more items → floating action bar at bottom
  2. "Schedule on channel" → ScheduleFromLibraryDialog modal
  3. Modal fields (in order — time/days/strategy disabled until channel is selected):
    • Channel picker (required; enables remaining fields once selected)
    • Days: MonSun checkboxes
    • Time: NaiveTime input 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
  4. Preview: "3 blocks will be created on [Channel] — Mon/Wed/Fri at 20:00 [Europe/Warsaw], Sequential"
  5. Confirm → PUT /channels/:id merging new ProgrammingBlock entries into schedule_config.day_blocks:
    • Series / episodic: Algorithmic block with series_names: [series]
    • Specific item(s): Manual block with those item IDs

Add To Block Flow

  1. User selects items → "Add to block" from action bar
  2. AddToBlockDialog:
    • Pick channel
    • Pick existing manual block: populated from useChannel(id) by collecting all blocks across all days with content.type === "manual", deduplicated by block id (same block appearing Mon + Wed shown once)
  3. Confirm → appends item IDs to that block. Since the same block object (by id) may appear in multiple days in schedule_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 — add library module
  • domain/src/library.rs — new: LibraryItem, LibraryCollection, LibrarySyncResult, LibrarySyncAdapter trait, ILibraryRepository trait, LibrarySearchFilter, LibrarySyncLogEntry
  • infra/src/library/full_sync.rsFullSyncAdapter impl
  • infra/src/library/repository.rsSqliteLibraryRepository impl
  • infra/src/library/scheduler.rs — tokio interval task, 10s startup delay
  • api/src/routes/library.rs — DB-backed handlers + sync/admin routes
  • api/src/routes/mod.rs — wire admin settings routes
  • api/src/main.rs — start sync scheduler task
  • api/src/state.rs — add library_sync_adapter: Arc<dyn LibrarySyncAdapter>, library_repo: Arc<dyn ILibraryRepository>
  • migrations_sqlite/20260319000002_add_library_tables.sql
  • migrations_sqlite/20260319000003_add_app_settings.sql

Frontend:

  • lib/types.ts — add LibraryItem, LibraryCollection, SyncLogEntry, AdminSettings
  • lib/api.ts — add api.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

  1. Sync: POST /library/sync → 200. GET /library/sync/status shows done with item count. library_items rows in DB have collection_name and thumbnail_url populated.
  2. Sync dedup: Second POST /library/sync while first is running → 409 Conflict.
  3. Library API pagination: GET /library/items?offset=0&limit=10 returns 10 items + total. ?offset=10&limit=10 returns next page.
  4. Provider filter: GET /library/items?provider=jellyfin returns only Jellyfin items.
  5. Collections: GET /library/collections returns { id, name, collection_type } objects.
  6. Admin guard: POST /library/sync and PUT /admin/settings with non-admin user → 403.
  7. Admin settings: PUT /admin/settings { "library_sync_interval_hours": 2 }GET /admin/settings returns { "library_sync_interval_hours": 2 } (number). Scheduler uses new interval.
  8. Library UI: /library page loads, sidebar filters update grid, pagination controls work. sync-status-bar shows last sync time.
  9. Broken thumbnail: Item with a broken thumbnail_url shows fallback placeholder in library-item-card.
  10. Multi-select action bar: Select 3 items → action bar appears with "Schedule on channel" and "Add to block".
  11. Schedule flow — time gating: Time input is disabled until channel is selected; timezone shown next to input after channel selected.
  12. Schedule flow — rounding: Single-item selection shows rounded duration with note in dialog.
  13. Schedule flow — confirm: Series scheduled → Dashboard shows Algorithmic blocks on correct days with series_names filter.
  14. Add to block — dedup: Block appearing on Mon+Wed shown once in picker. Confirming updates both days.
  15. Cache invalidation: After useTriggerSync() resolves, ["library", "search"] and ["library", "sync"] query keys are invalidated, grid refreshes.
  16. Block editor unchanged: AlgorithmicFilterEditor works; useLibraryItems in use-library.ts unchanged.
  17. Regression: cargo test passes.