From 187cd064fb40ad27dcc380c1079c80487df68eed Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 19 Mar 2026 23:57:05 +0100 Subject: [PATCH] docs: add library management implementation plan --- .../plans/2026-03-19-library-management.md | 2886 +++++++++++++++++ 1 file changed, 2886 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-19-library-management.md diff --git a/docs/superpowers/plans/2026-03-19-library-management.md b/docs/superpowers/plans/2026-03-19-library-management.md new file mode 100644 index 0000000..385cfaa --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-library-management.md @@ -0,0 +1,2886 @@ +# Library Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a synced in-house library that stores media metadata from all providers in SQLite and exposes it through a `/library` page where users can browse, filter, multi-select, and schedule media directly onto channels. + +**Architecture:** Full-sync approach: a background tokio task periodically fetches all items from each provider and truncates+reinserts into a `library_items` SQLite table. Domain layer defines traits (`LibrarySyncAdapter`, `ILibraryRepository`); infra implements them. Existing `GET /library/*` routes are replaced with DB-backed equivalents (same API contract). Frontend gains a new `/library` route with sidebar filters, paginated grid, and scheduling dialogs. + +**Tech Stack:** Rust (Axum, SQLx, async-trait, tokio), SQLite, Next.js 16, React 19, TanStack Query v5, shadcn/ui, Tailwind v4. + +**Spec:** `docs/superpowers/specs/2026-03-19-library-management-design.md` + +--- + +## File Map + +**New backend files:** +- `k-tv-backend/migrations_sqlite/20260319000002_add_library_tables.sql` +- `k-tv-backend/migrations_sqlite/20260319000003_add_app_settings.sql` +- `k-tv-backend/domain/src/library.rs` — domain types + traits +- `k-tv-backend/infra/src/library_repository.rs` — SqliteLibraryRepository +- `k-tv-backend/infra/src/app_settings_repository.rs` — SqliteAppSettingsRepository +- `k-tv-backend/infra/src/library_sync.rs` — FullSyncAdapter +- `k-tv-backend/api/src/library_scheduler.rs` — background sync task + +**Modified backend files:** +- `k-tv-backend/domain/src/entities.rs` — add `thumbnail_url`, `collection_id` to `MediaItem` +- `k-tv-backend/domain/src/lib.rs` — add `library` module + re-exports +- `k-tv-backend/domain/src/repositories.rs` — add `IAppSettingsRepository` trait +- `k-tv-backend/infra/src/lib.rs` — add new module re-exports +- `k-tv-backend/infra/src/factory.rs` — add build functions +- `k-tv-backend/api/src/routes/library.rs` — replace live-provider handlers with DB-backed; add sync/admin routes +- `k-tv-backend/api/src/state.rs` — add `library_repo`, `library_sync_adapter`, `app_settings_repo` +- `k-tv-backend/api/src/main.rs` — wire repos + start scheduler + +**New frontend files:** +- `k-tv-frontend/hooks/use-library-search.ts` +- `k-tv-frontend/hooks/use-library-sync.ts` +- `k-tv-frontend/hooks/use-admin-settings.ts` +- `k-tv-frontend/app/(main)/library/page.tsx` +- `k-tv-frontend/app/(main)/library/components/library-sidebar.tsx` +- `k-tv-frontend/app/(main)/library/components/library-grid.tsx` +- `k-tv-frontend/app/(main)/library/components/library-item-card.tsx` +- `k-tv-frontend/app/(main)/library/components/sync-status-bar.tsx` +- `k-tv-frontend/app/(main)/library/components/schedule-from-library-dialog.tsx` +- `k-tv-frontend/app/(main)/library/components/add-to-block-dialog.tsx` + +**Modified frontend files:** +- `k-tv-frontend/lib/types.ts` — add new types +- `k-tv-frontend/lib/api.ts` — add new API methods +- `k-tv-frontend/app/(main)/layout.tsx` — add Library nav link + +--- + +## Task 1: SQLite Migrations + +**Files:** +- Create: `k-tv-backend/migrations_sqlite/20260319000002_add_library_tables.sql` +- Create: `k-tv-backend/migrations_sqlite/20260319000003_add_app_settings.sql` + +- [ ] **Step 1: Write migration 1 — library tables** + +```sql +-- 20260319000002_add_library_tables.sql +CREATE TABLE IF NOT EXISTS library_items ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + external_id TEXT NOT NULL, + title TEXT NOT NULL, + content_type TEXT NOT NULL, + duration_secs INTEGER NOT NULL DEFAULT 0, + series_name TEXT, + season_number INTEGER, + episode_number INTEGER, + year INTEGER, + genres TEXT NOT NULL DEFAULT '[]', + tags TEXT NOT NULL DEFAULT '[]', + collection_id TEXT, + collection_name TEXT, + collection_type TEXT, + thumbnail_url TEXT, + synced_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_library_items_provider ON library_items(provider_id); +CREATE INDEX IF NOT EXISTS idx_library_items_content_type ON library_items(content_type); +CREATE INDEX IF NOT EXISTS idx_library_items_series ON library_items(series_name); + +CREATE TABLE IF NOT EXISTS library_sync_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + items_found INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'running', + error_msg TEXT +); +``` + +- [ ] **Step 2: Write migration 2 — app_settings** + +```sql +-- 20260319000003_add_app_settings.sql +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +INSERT OR IGNORE INTO app_settings(key, value) VALUES ('library_sync_interval_hours', '6'); +``` + +- [ ] **Step 3: Verify migrations run** + +```bash +cd k-tv-backend && cargo run -- 2>&1 | grep -E "migration|error" | head -20 +``` + +Expected: no migration errors, server starts. + +- [ ] **Step 4: Commit** + +```bash +git add k-tv-backend/migrations_sqlite/20260319000002_add_library_tables.sql \ + k-tv-backend/migrations_sqlite/20260319000003_add_app_settings.sql +git commit -m "feat(db): add library_items, library_sync_log, app_settings migrations" +``` + +--- + +## Task 2: Domain Types — library.rs + +**Files:** +- Create: `k-tv-backend/domain/src/library.rs` +- Modify: `k-tv-backend/domain/src/lib.rs` +- Modify: `k-tv-backend/domain/src/repositories.rs` + +- [ ] **Step 1: Write failing test for `LibraryItem` construction** + +Add to the end of `domain/src/library.rs` (create the file): + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn library_item_id_uses_double_colon_separator() { + let item = LibraryItem { + id: "jellyfin::abc123".to_string(), + provider_id: "jellyfin".to_string(), + external_id: "abc123".to_string(), + title: "Test Movie".to_string(), + content_type: crate::ContentType::Movie, + duration_secs: 7200, + series_name: None, + season_number: None, + episode_number: None, + year: Some(2020), + genres: vec!["Action".to_string()], + tags: vec![], + collection_id: None, + collection_name: None, + collection_type: None, + thumbnail_url: None, + synced_at: "2026-03-19T00:00:00Z".to_string(), + }; + assert!(item.id.contains("::")); + assert_eq!(item.provider_id, "jellyfin"); + } + + #[test] + fn library_search_filter_defaults_are_empty() { + let f = LibrarySearchFilter::default(); + assert!(f.genres.is_empty()); + assert!(f.series_names.is_empty()); + assert_eq!(f.offset, 0); + assert_eq!(f.limit, 50); + } +} +``` + +- [ ] **Step 2: Run test to see it fail** + +```bash +cd k-tv-backend && cargo test -p domain library 2>&1 | tail -20 +``` + +Expected: compile error — `library` module not found. + +- [ ] **Step 3: Extend `MediaItem` in domain/src/entities.rs** + +Open `k-tv-backend/domain/src/entities.rs` and add two optional fields to `MediaItem`: + +```rust +// In the MediaItem struct — add after existing fields: +pub thumbnail_url: Option, // provider-served image URL, populated if available +pub collection_id: Option, // provider-specific collection this item belongs to +``` + +Update every place that constructs a `MediaItem` literal (search for `MediaItem {` in the codebase, typically in Jellyfin/local-files adapters and tests) to include: +```rust +thumbnail_url: None, +collection_id: None, +``` + +For Jellyfin (`infra/src/jellyfin.rs`), populate `thumbnail_url` with the Jellyfin image URL format: +```rust +thumbnail_url: Some(format!("{}/Items/{}/Images/Primary?api_key={}", base_url, raw_id, api_key)), +collection_id: item.parent_id.clone(), // Jellyfin ParentId = collection +``` + +Run `cargo build` after this step to catch all `MediaItem` literal sites that need updating. + +- [ ] **Step 4: Implement domain/src/library.rs** + +```rust +//! Library domain types and ports. + +use async_trait::async_trait; + +use crate::{ContentType, DomainResult, IMediaProvider}; + +/// A media item stored in the local library cache. +#[derive(Debug, Clone)] +pub struct LibraryItem { + pub id: String, + pub provider_id: String, + pub external_id: String, + pub title: String, + pub content_type: ContentType, + pub duration_secs: u32, + pub series_name: Option, + pub season_number: Option, + pub episode_number: Option, + pub year: Option, + pub genres: Vec, + pub tags: Vec, + pub collection_id: Option, + pub collection_name: Option, + pub collection_type: Option, + pub thumbnail_url: Option, + pub synced_at: String, +} + +/// A collection summary derived from synced library items. +#[derive(Debug, Clone)] +pub struct LibraryCollection { + pub id: String, + pub name: String, + pub collection_type: Option, +} + +/// Result of a single provider sync run. +#[derive(Debug, Clone)] +pub struct LibrarySyncResult { + pub provider_id: String, + pub items_found: u32, + pub duration_ms: u64, + pub error: Option, +} + +/// Log entry from library_sync_log table. +#[derive(Debug, Clone)] +pub struct LibrarySyncLogEntry { + pub id: i64, + pub provider_id: String, + pub started_at: String, + pub finished_at: Option, + pub items_found: u32, + pub status: String, + pub error_msg: Option, +} + +/// Filter for searching the local library. +#[derive(Debug, Clone, Default)] +pub struct LibrarySearchFilter { + pub provider_id: Option, + pub content_type: Option, + pub series_names: Vec, + pub collection_id: Option, + pub genres: Vec, + pub decade: Option, + pub min_duration_secs: Option, + pub max_duration_secs: Option, + pub search_term: Option, + pub offset: u32, + pub limit: u32, +} + +/// Port: sync one provider's items into the library repo. +/// DB writes are handled entirely inside implementations — no pool in the trait. +#[async_trait] +pub trait LibrarySyncAdapter: Send + Sync { + async fn sync_provider( + &self, + provider: &dyn IMediaProvider, + provider_id: &str, + ) -> LibrarySyncResult; +} + +/// Port: read/write access to the persisted library. +#[async_trait] +pub trait ILibraryRepository: Send + Sync { + async fn search(&self, filter: &LibrarySearchFilter) -> DomainResult<(Vec, u32)>; + async fn get_by_id(&self, id: &str) -> DomainResult>; + async fn list_collections(&self, provider_id: Option<&str>) -> DomainResult>; + async fn list_series(&self, provider_id: Option<&str>) -> DomainResult>; + async fn list_genres(&self, content_type: Option<&ContentType>, provider_id: Option<&str>) -> DomainResult>; + async fn upsert_items(&self, provider_id: &str, items: Vec) -> DomainResult<()>; + async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>; + async fn log_sync_start(&self, provider_id: &str) -> DomainResult; + async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>; + async fn latest_sync_status(&self) -> DomainResult>; + async fn is_sync_running(&self, provider_id: &str) -> DomainResult; +} +``` + +- [ ] **Step 4: Add `IAppSettingsRepository` to domain/src/repositories.rs** + +Append to the end of `repositories.rs`: + +```rust +/// Repository port for general admin settings (app_settings table). +#[async_trait] +pub trait IAppSettingsRepository: Send + Sync { + /// Get a setting value by key. Returns None if not set. + async fn get(&self, key: &str) -> DomainResult>; + /// Set a setting value (upsert). + async fn set(&self, key: &str, value: &str) -> DomainResult<()>; + /// Get all settings as (key, value) pairs. + async fn get_all(&self) -> DomainResult>; +} +``` + +- [ ] **Step 5: Register in domain/src/lib.rs** + +Add to the module list: +```rust +pub mod library; +``` + +Add to the re-exports: +```rust +pub use library::{ + ILibraryRepository, LibraryCollection, LibraryItem, LibrarySearchFilter, + LibrarySyncAdapter, LibrarySyncLogEntry, LibrarySyncResult, +}; +pub use repositories::IAppSettingsRepository; +``` + +- [ ] **Step 6: Run tests** + +```bash +cd k-tv-backend && cargo test -p domain library 2>&1 | tail -20 +``` + +Expected: 2 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add k-tv-backend/domain/src/library.rs \ + k-tv-backend/domain/src/lib.rs \ + k-tv-backend/domain/src/repositories.rs +git commit -m "feat(domain): add library types, LibrarySyncAdapter, ILibraryRepository, IAppSettingsRepository" +``` + +--- + +## Task 3: Infra — SqliteLibraryRepository + +**Files:** +- Create: `k-tv-backend/infra/src/library_repository.rs` + +- [ ] **Step 1: Write failing tests** + +At the bottom of the new `library_repository.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use sqlx::SqlitePool; + use domain::{LibraryItem, LibrarySearchFilter, ContentType}; + + async fn setup() -> SqlitePool { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + sqlx::query( + "CREATE TABLE library_items ( + id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, external_id TEXT NOT NULL, + title TEXT NOT NULL, content_type TEXT NOT NULL, duration_secs INTEGER NOT NULL DEFAULT 0, + series_name TEXT, season_number INTEGER, episode_number INTEGER, year INTEGER, + genres TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]', + collection_id TEXT, collection_name TEXT, collection_type TEXT, + thumbnail_url TEXT, synced_at TEXT NOT NULL + )" + ).execute(&pool).await.unwrap(); + sqlx::query( + "CREATE TABLE library_sync_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, + started_at TEXT NOT NULL, finished_at TEXT, items_found INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'running', error_msg TEXT + )" + ).execute(&pool).await.unwrap(); + pool + } + + fn make_item(id: &str, provider: &str, title: &str) -> LibraryItem { + LibraryItem { + id: id.to_string(), provider_id: provider.to_string(), external_id: id.to_string(), + title: title.to_string(), content_type: ContentType::Movie, + duration_secs: 3600, series_name: None, season_number: None, episode_number: None, + year: Some(2020), genres: vec!["Action".to_string()], tags: vec![], + collection_id: None, collection_name: None, collection_type: None, + thumbnail_url: None, synced_at: "2026-03-19T00:00:00Z".to_string(), + } + } + + #[tokio::test] + async fn upsert_then_search_returns_items() { + let pool = setup().await; + let repo = SqliteLibraryRepository::new(pool); + let items = vec![make_item("jellyfin::1", "jellyfin", "Movie A")]; + repo.upsert_items("jellyfin", items).await.unwrap(); + + let (results, total) = repo.search(&LibrarySearchFilter { limit: 50, ..Default::default() }).await.unwrap(); + assert_eq!(total, 1); + assert_eq!(results[0].title, "Movie A"); + } + + #[tokio::test] + async fn clear_provider_removes_only_that_provider() { + let pool = setup().await; + let repo = SqliteLibraryRepository::new(pool); + repo.upsert_items("jellyfin", vec![make_item("jellyfin::1", "jellyfin", "Jelly Movie")]).await.unwrap(); + repo.upsert_items("local", vec![make_item("local::1", "local", "Local Movie")]).await.unwrap(); + repo.clear_provider("jellyfin").await.unwrap(); + + let (results, _) = repo.search(&LibrarySearchFilter { limit: 50, ..Default::default() }).await.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].provider_id, "local"); + } + + #[tokio::test] + async fn is_sync_running_reflects_status() { + let pool = setup().await; + let repo = SqliteLibraryRepository::new(pool); + assert!(!repo.is_sync_running("jellyfin").await.unwrap()); + let log_id = repo.log_sync_start("jellyfin").await.unwrap(); + assert!(repo.is_sync_running("jellyfin").await.unwrap()); + let result = domain::LibrarySyncResult { + provider_id: "jellyfin".to_string(), items_found: 5, duration_ms: 100, error: None, + }; + repo.log_sync_finish(log_id, &result).await.unwrap(); + assert!(!repo.is_sync_running("jellyfin").await.unwrap()); + } +} +``` + +- [ ] **Step 2: Run to verify compile failure** + +```bash +cd k-tv-backend && cargo test -p infra library_repository 2>&1 | tail -10 +``` + +Expected: compile error — `library_repository` not in infra. + +- [ ] **Step 3: Implement SqliteLibraryRepository** + +```rust +//! SQLite implementation of ILibraryRepository. + +use std::sync::Arc; +use async_trait::async_trait; +use sqlx::SqlitePool; + +use domain::{ + ContentType, DomainError, DomainResult, ILibraryRepository, + LibraryCollection, LibraryItem, LibrarySearchFilter, LibrarySyncLogEntry, LibrarySyncResult, +}; + +pub struct SqliteLibraryRepository { + pool: SqlitePool, +} + +impl SqliteLibraryRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +fn content_type_str(ct: &ContentType) -> &'static str { + match ct { + ContentType::Movie => "movie", + ContentType::Episode => "episode", + ContentType::Short => "short", + } +} + +fn parse_content_type(s: &str) -> ContentType { + match s { + "episode" => ContentType::Episode, + "short" => ContentType::Short, + _ => ContentType::Movie, + } +} + +#[async_trait] +impl ILibraryRepository for SqliteLibraryRepository { + async fn search(&self, filter: &LibrarySearchFilter) -> DomainResult<(Vec, u32)> { + // Build a dynamic query with WHERE clauses + let mut conditions: Vec = vec![]; + if let Some(ref p) = filter.provider_id { + conditions.push(format!("provider_id = '{}'", p.replace('\'', "''"))); + } + if let Some(ref ct) = filter.content_type { + conditions.push(format!("content_type = '{}'", content_type_str(ct))); + } + if let Some(ref st) = filter.search_term { + conditions.push(format!("title LIKE '%{}%'", st.replace('\'', "''"))); + } + if let Some(ref cid) = filter.collection_id { + conditions.push(format!("collection_id = '{}'", cid.replace('\'', "''"))); + } + if let Some(decade) = filter.decade { + let end = decade + 10; + conditions.push(format!("year >= {} AND year < {}", decade, end)); + } + if let Some(min) = filter.min_duration_secs { + conditions.push(format!("duration_secs >= {}", min)); + } + if let Some(max) = filter.max_duration_secs { + conditions.push(format!("duration_secs <= {}", max)); + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM library_items {}", where_clause); + let total: i64 = sqlx::query_scalar(&count_sql) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Infrastructure(e.to_string()))?; + + let items_sql = format!( + "SELECT * FROM library_items {} ORDER BY title ASC LIMIT {} OFFSET {}", + where_clause, filter.limit, filter.offset + ); + + let rows = sqlx::query_as::<_, LibraryItemRow>(&items_sql) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Infrastructure(e.to_string()))?; + + Ok((rows.into_iter().map(Into::into).collect(), total as u32)) + } + + async fn get_by_id(&self, id: &str) -> DomainResult> { + let row = sqlx::query_as::<_, LibraryItemRow>( + "SELECT * FROM library_items WHERE id = ?" + ) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Infrastructure(e.to_string()))?; + Ok(row.map(Into::into)) + } + + async fn list_collections(&self, provider_id: Option<&str>) -> DomainResult> { + let rows = if let Some(p) = provider_id { + sqlx::query_as::<_, (String, Option, Option)>( + "SELECT DISTINCT collection_id, collection_name, collection_type + FROM library_items WHERE collection_id IS NOT NULL AND provider_id = ? + ORDER BY collection_name ASC" + ).bind(p).fetch_all(&self.pool).await + } else { + sqlx::query_as::<_, (String, Option, Option)>( + "SELECT DISTINCT collection_id, collection_name, collection_type + FROM library_items WHERE collection_id IS NOT NULL + ORDER BY collection_name ASC" + ).fetch_all(&self.pool).await + }.map_err(|e| DomainError::Infrastructure(e.to_string()))?; + + Ok(rows.into_iter().map(|(id, name, ct)| LibraryCollection { + id, + name: name.unwrap_or_default(), + collection_type: ct, + }).collect()) + } + + async fn list_series(&self, provider_id: Option<&str>) -> DomainResult> { + let rows: Vec<(String,)> = if let Some(p) = provider_id { + sqlx::query_as( + "SELECT DISTINCT series_name FROM library_items + WHERE series_name IS NOT NULL AND provider_id = ? ORDER BY series_name ASC" + ).bind(p).fetch_all(&self.pool).await + } else { + sqlx::query_as( + "SELECT DISTINCT series_name FROM library_items + WHERE series_name IS NOT NULL ORDER BY series_name ASC" + ).fetch_all(&self.pool).await + }.map_err(|e| DomainError::Infrastructure(e.to_string()))?; + + Ok(rows.into_iter().map(|(s,)| s).collect()) + } + + async fn list_genres(&self, content_type: Option<&ContentType>, provider_id: Option<&str>) -> DomainResult> { + // Genres are stored as JSON arrays; extract them via json_each + let sql = match (content_type, provider_id) { + (Some(ct), Some(p)) => format!( + "SELECT DISTINCT value FROM library_items, json_each(genres) + WHERE content_type = '{}' AND provider_id = '{}' ORDER BY value ASC", + content_type_str(ct), p + ), + (Some(ct), None) => format!( + "SELECT DISTINCT value FROM library_items, json_each(genres) + WHERE content_type = '{}' ORDER BY value ASC", + content_type_str(ct) + ), + (None, Some(p)) => format!( + "SELECT DISTINCT value FROM library_items, json_each(genres) + WHERE provider_id = '{}' ORDER BY value ASC", + p + ), + (None, None) => "SELECT DISTINCT value FROM library_items, json_each(genres) ORDER BY value ASC".to_string(), + }; + let rows: Vec<(String,)> = sqlx::query_as(&sql) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Infrastructure(e.to_string()))?; + Ok(rows.into_iter().map(|(s,)| s).collect()) + } + + async fn upsert_items(&self, _provider_id: &str, items: Vec) -> DomainResult<()> { + let mut tx = self.pool.begin().await.map_err(|e| DomainError::Infrastructure(e.to_string()))?; + for item in items { + sqlx::query( + "INSERT OR REPLACE INTO library_items + (id, provider_id, external_id, title, content_type, duration_secs, + series_name, season_number, episode_number, year, genres, tags, + collection_id, collection_name, collection_type, thumbnail_url, synced_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ) + .bind(&item.id).bind(&item.provider_id).bind(&item.external_id) + .bind(&item.title).bind(content_type_str(&item.content_type)) + .bind(item.duration_secs as i64) + .bind(&item.series_name).bind(item.season_number.map(|n| n as i64)) + .bind(item.episode_number.map(|n| n as i64)) + .bind(item.year.map(|n| n as i64)) + .bind(serde_json::to_string(&item.genres).unwrap_or_default()) + .bind(serde_json::to_string(&item.tags).unwrap_or_default()) + .bind(&item.collection_id).bind(&item.collection_name) + .bind(&item.collection_type).bind(&item.thumbnail_url) + .bind(&item.synced_at) + .execute(&mut *tx) + .await + .map_err(|e| DomainError::Infrastructure(e.to_string()))?; + } + tx.commit().await.map_err(|e| DomainError::Infrastructure(e.to_string())) + } + + async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> { + sqlx::query("DELETE FROM library_items WHERE provider_id = ?") + .bind(provider_id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(|e| DomainError::Infrastructure(e.to_string())) + } + + async fn log_sync_start(&self, provider_id: &str) -> DomainResult { + let now = chrono::Utc::now().to_rfc3339(); + let id = sqlx::query_scalar::<_, i64>( + "INSERT INTO library_sync_log (provider_id, started_at, status) + VALUES (?, ?, 'running') RETURNING id" + ) + .bind(provider_id).bind(&now) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Infrastructure(e.to_string()))?; + Ok(id) + } + + async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + let status = if result.error.is_none() { "done" } else { "error" }; + sqlx::query( + "UPDATE library_sync_log + SET finished_at = ?, items_found = ?, status = ?, error_msg = ? + WHERE id = ?" + ) + .bind(&now).bind(result.items_found as i64) + .bind(status).bind(&result.error).bind(log_id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(|e| DomainError::Infrastructure(e.to_string())) + } + + async fn latest_sync_status(&self) -> DomainResult> { + let rows = sqlx::query_as::<_, SyncLogRow>( + "SELECT * FROM library_sync_log + WHERE id IN ( + SELECT MAX(id) FROM library_sync_log GROUP BY provider_id + ) + ORDER BY started_at DESC" + ) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Infrastructure(e.to_string()))?; + + Ok(rows.into_iter().map(|r| LibrarySyncLogEntry { + id: r.id, provider_id: r.provider_id, started_at: r.started_at, + finished_at: r.finished_at, items_found: r.items_found as u32, + status: r.status, error_msg: r.error_msg, + }).collect()) + } + + async fn is_sync_running(&self, provider_id: &str) -> DomainResult { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM library_sync_log WHERE provider_id = ? AND status = 'running'" + ) + .bind(provider_id) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Infrastructure(e.to_string()))?; + Ok(count > 0) + } +} + +// ── SQLx row types ───────────────────────────────────────────────────────── + +#[derive(sqlx::FromRow)] +struct LibraryItemRow { + id: String, provider_id: String, external_id: String, title: String, + content_type: String, duration_secs: i64, + series_name: Option, season_number: Option, episode_number: Option, + year: Option, genres: String, tags: String, + collection_id: Option, collection_name: Option, collection_type: Option, + thumbnail_url: Option, synced_at: String, +} + +impl From for LibraryItem { + fn from(r: LibraryItemRow) -> Self { + Self { + id: r.id, provider_id: r.provider_id, external_id: r.external_id, + title: r.title, content_type: parse_content_type(&r.content_type), + duration_secs: r.duration_secs as u32, + series_name: r.series_name, + season_number: r.season_number.map(|n| n as u32), + episode_number: r.episode_number.map(|n| n as u32), + year: r.year.map(|n| n as u16), + genres: serde_json::from_str(&r.genres).unwrap_or_default(), + tags: serde_json::from_str(&r.tags).unwrap_or_default(), + collection_id: r.collection_id, collection_name: r.collection_name, + collection_type: r.collection_type, thumbnail_url: r.thumbnail_url, + synced_at: r.synced_at, + } + } +} + +#[derive(sqlx::FromRow)] +struct SyncLogRow { + id: i64, provider_id: String, started_at: String, finished_at: Option, + items_found: i64, status: String, error_msg: Option, +} +``` + +- [ ] **Step 4: Register in infra/src/lib.rs** + +Add: +```rust +mod library_repository; +#[cfg(feature = "sqlite")] +pub use library_repository::SqliteLibraryRepository; +``` + +- [ ] **Step 5: Run tests** + +```bash +cd k-tv-backend && cargo test -p infra library_repository 2>&1 | tail -20 +``` + +Expected: 3 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add k-tv-backend/infra/src/library_repository.rs k-tv-backend/infra/src/lib.rs +git commit -m "feat(infra): add SqliteLibraryRepository" +``` + +--- + +## Task 4: Infra — SqliteAppSettingsRepository + +**Files:** +- Create: `k-tv-backend/infra/src/app_settings_repository.rs` + +- [ ] **Step 1: Write failing tests** + +In the new file: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use sqlx::SqlitePool; + use domain::IAppSettingsRepository; + + async fn setup() -> SqlitePool { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + sqlx::query( + "CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)" + ).execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO app_settings VALUES ('library_sync_interval_hours', '6')") + .execute(&pool).await.unwrap(); + pool + } + + #[tokio::test] + async fn get_returns_seeded_value() { + let repo = SqliteAppSettingsRepository::new(setup().await); + let val = repo.get("library_sync_interval_hours").await.unwrap(); + assert_eq!(val, Some("6".to_string())); + } + + #[tokio::test] + async fn set_then_get() { + let repo = SqliteAppSettingsRepository::new(setup().await); + repo.set("library_sync_interval_hours", "12").await.unwrap(); + let val = repo.get("library_sync_interval_hours").await.unwrap(); + assert_eq!(val, Some("12".to_string())); + } + + #[tokio::test] + async fn get_all_returns_all_keys() { + let repo = SqliteAppSettingsRepository::new(setup().await); + let all = repo.get_all().await.unwrap(); + assert!(!all.is_empty()); + assert!(all.iter().any(|(k, _)| k == "library_sync_interval_hours")); + } +} +``` + +- [ ] **Step 2: Implement SqliteAppSettingsRepository** + +```rust +//! SQLite implementation of IAppSettingsRepository. + +use async_trait::async_trait; +use sqlx::SqlitePool; +use domain::{DomainError, DomainResult, IAppSettingsRepository}; + +pub struct SqliteAppSettingsRepository { + pool: SqlitePool, +} + +impl SqliteAppSettingsRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl IAppSettingsRepository for SqliteAppSettingsRepository { + async fn get(&self, key: &str) -> DomainResult> { + sqlx::query_scalar::<_, String>("SELECT value FROM app_settings WHERE key = ?") + .bind(key) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Infrastructure(e.to_string())) + } + + async fn set(&self, key: &str, value: &str) -> DomainResult<()> { + sqlx::query("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)") + .bind(key) + .bind(value) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(|e| DomainError::Infrastructure(e.to_string())) + } + + async fn get_all(&self) -> DomainResult> { + sqlx::query_as::<_, (String, String)>("SELECT key, value FROM app_settings ORDER BY key") + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Infrastructure(e.to_string())) + } +} +``` + +- [ ] **Step 3: Register in infra/src/lib.rs** + +```rust +mod app_settings_repository; +#[cfg(feature = "sqlite")] +pub use app_settings_repository::SqliteAppSettingsRepository; +``` + +- [ ] **Step 4: Run tests** + +```bash +cd k-tv-backend && cargo test -p infra app_settings 2>&1 | tail -10 +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add k-tv-backend/infra/src/app_settings_repository.rs k-tv-backend/infra/src/lib.rs +git commit -m "feat(infra): add SqliteAppSettingsRepository" +``` + +--- + +## Task 5: Infra — FullSyncAdapter + +**Files:** +- Create: `k-tv-backend/infra/src/library_sync.rs` + +- [ ] **Step 1: Write failing test** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + use async_trait::async_trait; + use domain::*; + + struct MockProvider { + items: Vec, + } + + #[async_trait] + impl IMediaProvider for MockProvider { + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { collections: true, ..Default::default() } + } + async fn fetch_items(&self, _filter: &MediaFilter) -> DomainResult> { + Ok(self.items.clone()) + } + async fn fetch_by_id(&self, _id: &MediaItemId) -> DomainResult> { Ok(None) } + async fn get_stream_url(&self, _id: &MediaItemId, _q: &StreamQuality) -> DomainResult { Ok(String::new()) } + async fn list_collections(&self) -> DomainResult> { Ok(vec![]) } + async fn list_series(&self, _col: Option<&str>) -> DomainResult> { Ok(vec![]) } + async fn list_genres(&self, _ct: Option<&ContentType>) -> DomainResult> { Ok(vec![]) } + } + + struct SpyRepo { + upserted: Arc>>, + cleared: Arc>>, + } + + #[async_trait] + impl ILibraryRepository for SpyRepo { + async fn search(&self, _f: &LibrarySearchFilter) -> DomainResult<(Vec, u32)> { Ok((vec![], 0)) } + async fn get_by_id(&self, _id: &str) -> DomainResult> { Ok(None) } + async fn list_collections(&self, _p: Option<&str>) -> DomainResult> { Ok(vec![]) } + async fn list_series(&self, _p: Option<&str>) -> DomainResult> { Ok(vec![]) } + async fn list_genres(&self, _ct: Option<&ContentType>, _p: Option<&str>) -> DomainResult> { Ok(vec![]) } + async fn upsert_items(&self, _pid: &str, items: Vec) -> DomainResult<()> { + self.upserted.lock().unwrap().extend(items); + Ok(()) + } + async fn clear_provider(&self, pid: &str) -> DomainResult<()> { + self.cleared.lock().unwrap().push(pid.to_string()); + Ok(()) + } + async fn log_sync_start(&self, _pid: &str) -> DomainResult { Ok(1) } + async fn log_sync_finish(&self, _id: i64, _r: &LibrarySyncResult) -> DomainResult<()> { Ok(()) } + async fn latest_sync_status(&self) -> DomainResult> { Ok(vec![]) } + async fn is_sync_running(&self, _pid: &str) -> DomainResult { Ok(false) } + } + + #[tokio::test] + async fn sync_clears_then_upserts_items() { + let upserted = Arc::new(Mutex::new(vec![])); + let cleared = Arc::new(Mutex::new(vec![])); + let repo: Arc = Arc::new(SpyRepo { + upserted: Arc::clone(&upserted), + cleared: Arc::clone(&cleared), + }); + let adapter = FullSyncAdapter::new(Arc::clone(&repo)); + let provider = MockProvider { + items: vec![MediaItem { + id: MediaItemId::new("abc".to_string()), + title: "Test Movie".to_string(), + content_type: ContentType::Movie, + duration_secs: 3600, + series_name: None, season_number: None, episode_number: None, + year: None, genres: vec![], tags: vec![], + }], + }; + + let result = adapter.sync_provider(&provider, "jellyfin").await; + assert!(result.error.is_none()); + assert_eq!(result.items_found, 1); + assert_eq!(cleared.lock().unwrap().as_slice(), &["jellyfin"]); + assert_eq!(upserted.lock().unwrap().len(), 1); + } +} +``` + +- [ ] **Step 2: Implement FullSyncAdapter** + +```rust +//! Full-sync library sync adapter: truncate + re-insert all provider items. + +use std::sync::Arc; +use std::time::Instant; + +use async_trait::async_trait; +use domain::{ + Collection, ContentType, ILibraryRepository, IMediaProvider, LibraryItem, + LibrarySearchFilter, LibrarySyncAdapter, LibrarySyncResult, MediaFilter, MediaItemId, +}; + +pub struct FullSyncAdapter { + repo: Arc, +} + +impl FullSyncAdapter { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[async_trait] +impl LibrarySyncAdapter for FullSyncAdapter { + async fn sync_provider( + &self, + provider: &dyn IMediaProvider, + provider_id: &str, + ) -> LibrarySyncResult { + let start = Instant::now(); + + // Check for running sync first + match self.repo.is_sync_running(provider_id).await { + Ok(true) => { + return LibrarySyncResult { + provider_id: provider_id.to_string(), + items_found: 0, + duration_ms: 0, + error: Some("sync already running".to_string()), + }; + } + Err(e) => { + return LibrarySyncResult { + provider_id: provider_id.to_string(), + items_found: 0, + duration_ms: 0, + error: Some(e.to_string()), + }; + } + Ok(false) => {} + } + + let log_id = match self.repo.log_sync_start(provider_id).await { + Ok(id) => id, + Err(e) => { + return LibrarySyncResult { + provider_id: provider_id.to_string(), + items_found: 0, + duration_ms: start.elapsed().as_millis() as u64, + error: Some(e.to_string()), + }; + } + }; + + // Fetch collections for name/type enrichment — build a lookup map + let collections: Vec = provider.list_collections().await.unwrap_or_default(); + let collection_map: std::collections::HashMap = + collections.iter().map(|c| (c.id.clone(), c)).collect(); + + // Fetch all items + let media_items = match provider.fetch_items(&MediaFilter::default()).await { + Ok(items) => items, + Err(e) => { + let result = LibrarySyncResult { + provider_id: provider_id.to_string(), + items_found: 0, + duration_ms: start.elapsed().as_millis() as u64, + error: Some(e.to_string()), + }; + let _ = self.repo.log_sync_finish(log_id, &result).await; + return result; + } + }; + + let items_found = media_items.len() as u32; + let now = chrono::Utc::now().to_rfc3339(); + + let library_items: Vec = media_items + .into_iter() + .map(|item| { + let raw_id = item.id.into_inner(); + let id = format!("{}::{}", provider_id, raw_id); + // Enrich with collection name/type using the lookup map. + // item.collection_id is populated by providers that support collections + // (Jellyfin sets it from ParentId; local-files sets it from top-level directory). + let (col_name, col_type) = item.collection_id.as_deref() + .and_then(|cid| collection_map.get(cid)) + .map(|c| (c.name.clone(), c.collection_type.clone())) + .unwrap_or((None, None)); + LibraryItem { + id, + provider_id: provider_id.to_string(), + external_id: raw_id, + title: item.title, + content_type: item.content_type, + duration_secs: item.duration_secs, + series_name: item.series_name, + season_number: item.season_number, + episode_number: item.episode_number, + year: item.year, + genres: item.genres, + tags: item.tags, + collection_id: item.collection_id, + collection_name: col_name, + collection_type: col_type, + thumbnail_url: item.thumbnail_url, // populated by provider (e.g. Jellyfin image URL) + synced_at: now.clone(), + } + }) + .collect(); + + // Truncate + insert + if let Err(e) = self.repo.clear_provider(provider_id).await { + let result = LibrarySyncResult { + provider_id: provider_id.to_string(), + items_found: 0, + duration_ms: start.elapsed().as_millis() as u64, + error: Some(e.to_string()), + }; + let _ = self.repo.log_sync_finish(log_id, &result).await; + return result; + } + + let result = match self.repo.upsert_items(provider_id, library_items).await { + Ok(()) => LibrarySyncResult { + provider_id: provider_id.to_string(), + items_found, + duration_ms: start.elapsed().as_millis() as u64, + error: None, + }, + Err(e) => LibrarySyncResult { + provider_id: provider_id.to_string(), + items_found: 0, + duration_ms: start.elapsed().as_millis() as u64, + error: Some(e.to_string()), + }, + }; + + let _ = self.repo.log_sync_finish(log_id, &result).await; + result + } +} +``` + +- [ ] **Step 3: Register in infra/src/lib.rs** + +```rust +mod library_sync; +pub use library_sync::FullSyncAdapter; +``` + +- [ ] **Step 4: Run tests** + +```bash +cd k-tv-backend && cargo test -p infra library_sync 2>&1 | tail -10 +``` + +Expected: 1 test passes. + +- [ ] **Step 5: Commit** + +```bash +git add k-tv-backend/infra/src/library_sync.rs k-tv-backend/infra/src/lib.rs +git commit -m "feat(infra): add FullSyncAdapter for library sync" +``` + +--- + +## Task 6: API — Library Sync Scheduler + +**Files:** +- Create: `k-tv-backend/api/src/library_scheduler.rs` + +- [ ] **Step 1: Ensure `get_provider` exists on ProviderRegistry** + +Open `k-tv-backend/infra/src/provider_registry.rs`. Check if `get_provider(&str) -> Option>` exists. If not, add it: + +```rust +pub fn get_provider(&self, id: &str) -> Option> { + self.providers.get(id).cloned() +} +``` + +The internal `providers` map is likely `HashMap>` — confirm the field name and adjust accordingly. Run `cargo build` to verify. + +- [ ] **Step 2: Implement library_scheduler.rs** + +```rust +//! Background library sync task. +//! Fires 10 seconds after startup, then every N hours (read from app_settings). + +use std::sync::Arc; +use std::time::Duration; + +use domain::{IAppSettingsRepository, ILibraryRepository, IProviderRegistry, LibrarySyncAdapter}; + +const STARTUP_DELAY_SECS: u64 = 10; +const DEFAULT_INTERVAL_HOURS: u64 = 6; + +// Note: registry uses the concrete infra type to match what AppState holds +pub async fn run_library_sync( + sync_adapter: Arc, + registry: Arc>>, + app_settings_repo: Arc, +) { + tokio::time::sleep(Duration::from_secs(STARTUP_DELAY_SECS)).await; + + loop { + tick(&sync_adapter, ®istry).await; + + let interval_hours = load_interval_hours(&app_settings_repo).await; + tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await; + } +} + +async fn load_interval_hours(repo: &Arc) -> u64 { + repo.get("library_sync_interval_hours") + .await + .ok() + .flatten() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_INTERVAL_HOURS) +} + +async fn tick( + sync_adapter: &Arc, + registry: &Arc>>, +) { + let reg = registry.read().await; + let provider_ids = reg.provider_ids(); + drop(reg); + + for provider_id in provider_ids { + let reg = registry.read().await; + let provider = match reg.get_provider(&provider_id) { + Some(p) => p, + None => continue, + }; + + tracing::info!("library-sync: syncing provider '{}'", provider_id); + let result = sync_adapter.sync_provider(provider.as_ref(), &provider_id).await; + + if let Some(ref err) = result.error { + tracing::warn!("library-sync: provider '{}' failed: {}", provider_id, err); + } else { + tracing::info!( + "library-sync: provider '{}' done — {} items in {}ms", + provider_id, result.items_found, result.duration_ms + ); + } + } +} +``` + +> **Note:** `ProviderRegistry::get_provider` may not exist yet. Check `infra/src/provider_registry.rs` — if missing, add `pub fn get_provider(&self, id: &str) -> Option>`. The registry already holds providers internally. + +- [ ] **Step 2: Add to main.rs module list** + +In `api/src/main.rs`, add `mod library_scheduler;` + +- [ ] **Step 3: Compile check** + +```bash +cd k-tv-backend && cargo build 2>&1 | grep error | head -20 +``` + +Fix any errors before continuing. + +- [ ] **Step 4: Commit** + +```bash +git add k-tv-backend/api/src/library_scheduler.rs k-tv-backend/api/src/main.rs +git commit -m "feat(api): add library sync background task" +``` + +--- + +## Task 7: Wire Factory, AppState, and Main + +**Files:** +- Modify: `k-tv-backend/infra/src/factory.rs` +- Modify: `k-tv-backend/api/src/state.rs` +- Modify: `k-tv-backend/api/src/main.rs` + +- [ ] **Step 1: Add factory functions to infra/src/factory.rs** + +```rust +pub async fn build_library_repository( + pool: &DatabasePool, +) -> FactoryResult> { + match pool { + #[cfg(feature = "sqlite")] + DatabasePool::Sqlite(pool) => Ok(Arc::new( + crate::library_repository::SqliteLibraryRepository::new(pool.clone()), + )), + #[allow(unreachable_patterns)] + _ => Err(FactoryError::NotImplemented( + "LibraryRepository not implemented for this database".to_string(), + )), + } +} + +pub async fn build_app_settings_repository( + pool: &DatabasePool, +) -> FactoryResult> { + match pool { + #[cfg(feature = "sqlite")] + DatabasePool::Sqlite(pool) => Ok(Arc::new( + crate::app_settings_repository::SqliteAppSettingsRepository::new(pool.clone()), + )), + #[allow(unreachable_patterns)] + _ => Err(FactoryError::NotImplemented( + "AppSettingsRepository not implemented for this database".to_string(), + )), + } +} +``` + +Also add the import: `use domain::{IAppSettingsRepository, ILibraryRepository};` + +- [ ] **Step 2: Add fields to AppState** + +In `api/src/state.rs`, add to the `AppState` struct: + +```rust +pub library_repo: Arc, +pub library_sync_adapter: Arc, +pub app_settings_repo: Arc, +``` + +Add the imports at the top: +```rust +use domain::{IAppSettingsRepository, ILibraryRepository, LibrarySyncAdapter}; +``` + +Add corresponding params to `AppState::new()` and assign them in the `Ok(Self { ... })` block. + +- [ ] **Step 3: Wire in main.rs** + +In `api/src/main.rs`, after building other repos: + +```rust +let library_repo = infra::factory::build_library_repository(&db_pool).await?; +let app_settings_repo = infra::factory::build_app_settings_repository(&db_pool).await?; +let library_sync_adapter: Arc = + Arc::new(infra::FullSyncAdapter::new(Arc::clone(&library_repo))); +``` + +Pass them to `AppState::new()`. After building state, spawn the scheduler: + +```rust +tokio::spawn(library_scheduler::run_library_sync( + Arc::clone(&state.library_sync_adapter), + Arc::clone(&state.provider_registry), + Arc::clone(&state.app_settings_repo), +)); +``` + +- [ ] **Step 4: Build check** + +```bash +cd k-tv-backend && cargo build 2>&1 | grep error | head -20 +``` + +- [ ] **Step 5: Commit** + +```bash +git add k-tv-backend/infra/src/factory.rs \ + k-tv-backend/api/src/state.rs \ + k-tv-backend/api/src/main.rs +git commit -m "feat(api): wire library_repo, app_settings_repo, library_sync_adapter into AppState" +``` + +--- + +## Task 8: Backend API — DB-backed Library Routes + Admin + +**Files:** +- Modify: `k-tv-backend/api/src/routes/library.rs` +- Modify: `k-tv-backend/api/src/routes/mod.rs` (or wherever routes are registered) + +- [ ] **Step 1: Replace existing handlers + add new routes** + +Replace the entire content of `api/src/routes/library.rs` with: + +```rust +//! Library routes — DB-backed (synced from providers). +//! GET /library/collections +//! GET /library/series +//! GET /library/genres +//! GET /library/items (paginated, ?offset=&limit=&provider=&type=&series[]=&...) +//! GET /library/items/:id +//! GET /library/sync/status +//! POST /library/sync (admin only) +//! GET /admin/settings (admin only) +//! PUT /admin/settings (admin only) + +use axum::{ + Json, Router, + extract::{Path, Query, RawQuery, State}, + http::StatusCode, + routing::{get, post, put}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +use domain::{ContentType, IAppSettingsRepository, ILibraryRepository, LibrarySearchFilter, LibrarySyncAdapter}; + +use crate::{error::ApiError, extractors::{AdminUser, CurrentUser}, state::AppState}; + +pub fn router() -> Router { + Router::new() + .route("/collections", get(list_collections)) + .route("/series", get(list_series)) + .route("/genres", get(list_genres)) + .route("/items", get(search_items)) + .route("/items/:id", get(get_item)) + .route("/sync/status", get(sync_status)) + .route("/sync", post(trigger_sync)) +} + +pub fn admin_router() -> Router { + Router::new() + .route("/settings", get(get_settings)) + .route("/settings", put(update_settings)) +} + +// ── DTOs ─────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct CollectionResponse { id: String, name: String, collection_type: Option } + +#[derive(Serialize)] +struct LibraryItemResponse { + id: String, title: String, content_type: String, duration_secs: u32, + series_name: Option, season_number: Option, episode_number: Option, + year: Option, genres: Vec, thumbnail_url: Option, +} + +#[derive(Serialize)] +struct SyncLogResponse { + id: i64, provider_id: String, started_at: String, finished_at: Option, + items_found: u32, status: String, error_msg: Option, +} + +#[derive(Serialize)] +struct PagedResponse { items: Vec, total: u32 } + +// ── Query params ─────────────────────────────────────────────────────────── + +#[derive(Deserialize, Default)] +struct CollectionsQuery { provider: Option } + +#[derive(Deserialize, Default)] +struct SeriesQuery { provider: Option } + +#[derive(Deserialize, Default)] +struct GenresQuery { + #[serde(rename = "type")] + content_type: Option, + provider: Option, +} + +// ── Handlers ─────────────────────────────────────────────────────────────── + +async fn list_collections( + State(state): State, + CurrentUser(_u): CurrentUser, + Query(params): Query, +) -> Result>, ApiError> { + let cols = state.library_repo + .list_collections(params.provider.as_deref()) + .await?; + Ok(Json(cols.into_iter().map(|c| CollectionResponse { + id: c.id, name: c.name, collection_type: c.collection_type, + }).collect())) +} + +async fn list_series( + State(state): State, + CurrentUser(_u): CurrentUser, + Query(params): Query, +) -> Result>, ApiError> { + let series = state.library_repo + .list_series(params.provider.as_deref()) + .await?; + Ok(Json(series)) +} + +async fn list_genres( + State(state): State, + CurrentUser(_u): CurrentUser, + Query(params): Query, +) -> Result>, ApiError> { + let ct = parse_content_type(params.content_type.as_deref())?; + let genres = state.library_repo + .list_genres(ct.as_ref(), params.provider.as_deref()) + .await?; + Ok(Json(genres)) +} + +async fn search_items( + State(state): State, + CurrentUser(_u): CurrentUser, + RawQuery(raw): RawQuery, +) -> Result>, ApiError> { + let qs = serde_qs::Config::new(2, false); + #[derive(Deserialize, Default)] + struct ItemsQuery { + q: Option, + #[serde(rename = "type")] content_type: Option, + #[serde(default)] series: Vec, + collection: Option, + provider: Option, + decade: Option, + min_duration: Option, + max_duration: Option, + #[serde(default = "default_limit")] limit: u32, + #[serde(default)] offset: u32, + } + fn default_limit() -> u32 { 50 } + + let params: ItemsQuery = raw.as_deref() + .map(|q| qs.deserialize_str(q)) + .transpose() + .map_err(|e| ApiError::validation(e.to_string()))? + .unwrap_or_default(); + + let filter = LibrarySearchFilter { + provider_id: params.provider, + content_type: parse_content_type(params.content_type.as_deref())?, + series_names: params.series, + collection_id: params.collection, + search_term: params.q, + decade: params.decade, + min_duration_secs: params.min_duration, + max_duration_secs: params.max_duration, + limit: params.limit.min(200), + offset: params.offset, + ..Default::default() + }; + + let (items, total) = state.library_repo.search(&filter).await?; + Ok(Json(PagedResponse { + items: items.into_iter().map(item_to_dto).collect(), + total, + })) +} + +async fn get_item( + State(state): State, + CurrentUser(_u): CurrentUser, + Path(id): Path, +) -> Result, ApiError> { + state.library_repo.get_by_id(&id).await? + .map(|i| Json(item_to_dto(i))) + .ok_or_else(|| ApiError::not_found("item not found")) +} + +async fn sync_status( + State(state): State, + CurrentUser(_u): CurrentUser, +) -> Result>, ApiError> { + let logs = state.library_repo.latest_sync_status().await?; + Ok(Json(logs.into_iter().map(|l| SyncLogResponse { + id: l.id, provider_id: l.provider_id, started_at: l.started_at, + finished_at: l.finished_at, items_found: l.items_found, + status: l.status, error_msg: l.error_msg, + }).collect())) +} + +async fn trigger_sync( + State(state): State, + AdminUser(_u): AdminUser, +) -> Result { + // Check synchronously if any provider is already syncing — return 409 before spawning + let provider_ids = state.provider_registry.read().await.provider_ids(); + for pid in &provider_ids { + if state.library_repo.is_sync_running(pid).await? { + return Err(ApiError::conflict(format!("sync already running for provider '{}'", pid))); + } + } + + let adapter = state.library_sync_adapter.clone(); + let registry = state.provider_registry.clone(); + + tokio::spawn(async move { + for pid in provider_ids { + let reg = registry.read().await; + if let Some(provider) = reg.get_provider(&pid) { + drop(reg); + adapter.sync_provider(provider.as_ref(), &pid).await; + } + } + }); + + Ok(StatusCode::ACCEPTED) +} + +async fn get_settings( + State(state): State, + AdminUser(_u): AdminUser, +) -> Result, ApiError> { + let pairs = state.app_settings_repo.get_all().await?; + let mut map = serde_json::Map::new(); + for (k, v) in pairs { + // Parse as JSON value if possible, else leave as string + let val: Value = serde_json::from_str(&v).unwrap_or(Value::String(v)); + map.insert(k, val); + } + Ok(Json(Value::Object(map))) +} + +async fn update_settings( + State(state): State, + AdminUser(_u): AdminUser, + Json(body): Json>, +) -> Result, ApiError> { + for (k, v) in &body { + let val_str = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + state.app_settings_repo.set(k, &val_str).await?; + } + // Return updated settings + let pairs = state.app_settings_repo.get_all().await?; + let mut map = serde_json::Map::new(); + for (k, v) in pairs { + let val: Value = serde_json::from_str(&v).unwrap_or(Value::String(v)); + map.insert(k, val); + } + Ok(Json(Value::Object(map))) +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +fn parse_content_type(s: Option<&str>) -> Result, ApiError> { + match s { + None | Some("") => Ok(None), + Some("movie") => Ok(Some(ContentType::Movie)), + Some("episode") => Ok(Some(ContentType::Episode)), + Some("short") => Ok(Some(ContentType::Short)), + Some(other) => Err(ApiError::validation(format!("Unknown content type '{}'", other))), + } +} + +fn item_to_dto(item: domain::LibraryItem) -> LibraryItemResponse { + LibraryItemResponse { + id: item.id, + title: item.title, + content_type: match item.content_type { + ContentType::Movie => "movie".into(), + ContentType::Episode => "episode".into(), + ContentType::Short => "short".into(), + }, + duration_secs: item.duration_secs, + series_name: item.series_name, + season_number: item.season_number, + episode_number: item.episode_number, + year: item.year, + genres: item.genres, + thumbnail_url: item.thumbnail_url, + } +} +``` + +- [ ] **Step 2: Register admin_router in routes** + +In whatever file registers routes (check `api/src/routes/mod.rs` or `server.rs`), add: + +```rust +.nest("/admin", library::admin_router()) +``` + +alongside the existing `/library` nesting. + +- [ ] **Step 3: Build** + +```bash +cd k-tv-backend && cargo build 2>&1 | grep error | head -20 +``` + +- [ ] **Step 4: Manual smoke test** + +```bash +# Start server +cargo run & +sleep 3 + +# Trigger sync +curl -s -X POST http://localhost:3000/api/v1/library/sync \ + -H "Authorization: Bearer $TOKEN" | jq . + +# Check status +curl -s http://localhost:3000/api/v1/library/sync/status \ + -H "Authorization: Bearer $TOKEN" | jq . + +# Query items +curl -s "http://localhost:3000/api/v1/library/items?limit=5" \ + -H "Authorization: Bearer $TOKEN" | jq .total +``` + +- [ ] **Step 5: Commit** + +```bash +git add k-tv-backend/api/src/routes/library.rs +git commit -m "feat(api): replace live-provider library routes with DB-backed routes; add sync + admin settings endpoints" +``` + +--- + +## Task 9: Frontend Types and API Client + +**Files:** +- Modify: `k-tv-frontend/lib/types.ts` +- Modify: `k-tv-frontend/lib/api.ts` + +- [ ] **Step 1: Add types to lib/types.ts** + +Append to the end of `types.ts`: + +```typescript +// Library management +// Note: LibraryItemResponse is already defined in this file (search for it above). +// LibraryItemFull extends it with the extra fields returned by the DB-backed endpoint. + +export interface LibraryItemFull extends LibraryItemResponse { + thumbnail_url?: string | null; + collection_id?: string | null; + collection_name?: string | null; +} + +export interface PagedLibraryResponse { + items: LibraryItemFull[]; + total: number; +} + +export interface LibrarySyncLogEntry { + id: number; + provider_id: string; + started_at: string; + finished_at?: string | null; + items_found: number; + status: 'running' | 'done' | 'error'; + error_msg?: string | null; +} + +export interface AdminSettings { + library_sync_interval_hours: number; + [key: string]: unknown; +} +``` + +- [ ] **Step 2: Add API methods to lib/api.ts** + +In the `api` object, extend `api.library` and add `api.admin`: + +```typescript +// In api.library — add these methods: + +syncStatus: (token: string): Promise => + request('/library/sync/status', { token }), + +triggerSync: (token: string): Promise => + request('/library/sync', { method: 'POST', token }), + +itemsPage: ( + token: string, + filter: Partial<{ + q: string; type: string; series: string[]; genres: string[]; collection: string; + provider: string; decade: number; min_duration: number; max_duration: number; + offset: number; limit: number; + }> +): Promise => { + const params = new URLSearchParams(); + if (filter.q) params.set('q', filter.q); + if (filter.type) params.set('type', filter.type); + if (filter.series) filter.series.forEach(s => params.append('series[]', s)); + if (filter.genres) filter.genres.forEach(g => params.append('genres[]', g)); + if (filter.collection) params.set('collection', filter.collection); + if (filter.provider) params.set('provider', filter.provider); + if (filter.decade != null) params.set('decade', String(filter.decade)); + if (filter.min_duration != null) params.set('min_duration', String(filter.min_duration)); + if (filter.max_duration != null) params.set('max_duration', String(filter.max_duration)); + params.set('offset', String(filter.offset ?? 0)); + params.set('limit', String(filter.limit ?? 50)); + return request(`/library/items?${params}`, { token }); +}, + +// New api.admin namespace: +admin: { + getSettings: (token: string): Promise => + request('/admin/settings', { token }), + + updateSettings: (token: string, patch: Partial): Promise => + request('/admin/settings', { + method: 'PUT', + token, + body: JSON.stringify(patch), + headers: { 'Content-Type': 'application/json' }, + }), +}, +``` + +- [ ] **Step 3: TypeScript check** + +```bash +cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -30 +``` + +Fix any type errors. + +- [ ] **Step 4: Commit** + +```bash +git add k-tv-frontend/lib/types.ts k-tv-frontend/lib/api.ts +git commit -m "feat(frontend): add library paged types, syncStatus/triggerSync/admin API methods" +``` + +--- + +## Task 10: Frontend Hooks + +**Files:** +- Create: `k-tv-frontend/hooks/use-library-search.ts` +- Create: `k-tv-frontend/hooks/use-library-sync.ts` +- Create: `k-tv-frontend/hooks/use-admin-settings.ts` + +- [ ] **Step 1: Write use-library-search.ts** + +```typescript +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { useAuthContext } from "@/context/auth-context"; + +export interface LibrarySearchParams { + q?: string; + type?: string; + series?: string[]; + collection?: string; + provider?: string; + decade?: number; + min_duration?: number; + max_duration?: number; + genres?: string[]; + offset?: number; + limit?: number; +} + +/** + * Paginated library search — always enabled, DB-backed (fast). + * Separate from useLibraryItems in use-library.ts which is snapshot-based + * and used only for block editor filter preview. + */ +export function useLibrarySearch(params: LibrarySearchParams) { + const { token } = useAuthContext(); + return useQuery({ + queryKey: ["library", "search", params], + queryFn: () => api.library.itemsPage(token!, params), + enabled: !!token, + staleTime: 2 * 60 * 1000, + }); +} +``` + +- [ ] **Step 2: Write use-library-sync.ts** + +```typescript +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { useAuthContext } from "@/context/auth-context"; + +export function useLibrarySyncStatus() { + const { token } = useAuthContext(); + return useQuery({ + queryKey: ["library", "sync"], + queryFn: () => api.library.syncStatus(token!), + enabled: !!token, + staleTime: 30 * 1000, // 30s — sync status can change frequently + refetchInterval: 10 * 1000, // poll while page is open + }); +} + +export function useTriggerSync() { + const { token } = useAuthContext(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => api.library.triggerSync(token!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["library", "search"] }); + queryClient.invalidateQueries({ queryKey: ["library", "sync"] }); + }, + }); +} +``` + +- [ ] **Step 3: Write use-admin-settings.ts** + +```typescript +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { useAuthContext } from "@/context/auth-context"; +import type { AdminSettings } from "@/lib/types"; + +export function useAdminSettings() { + const { token } = useAuthContext(); + return useQuery({ + queryKey: ["admin", "settings"], + queryFn: () => api.admin.getSettings(token!), + enabled: !!token, + staleTime: 5 * 60 * 1000, + }); +} + +export function useUpdateAdminSettings() { + const { token } = useAuthContext(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (patch: Partial) => + api.admin.updateSettings(token!, patch), + onSuccess: (data) => { + queryClient.setQueryData(["admin", "settings"], data); + }, + }); +} +``` + +- [ ] **Step 4: TypeScript check** + +```bash +cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -20 +``` + +- [ ] **Step 5: Commit** + +```bash +git add k-tv-frontend/hooks/use-library-search.ts \ + k-tv-frontend/hooks/use-library-sync.ts \ + k-tv-frontend/hooks/use-admin-settings.ts +git commit -m "feat(frontend): add useLibrarySearch, useLibrarySyncStatus, useTriggerSync, useAdminSettings hooks" +``` + +--- + +## Task 11: Frontend — Library Page Shell + Nav + +**Files:** +- Create: `k-tv-frontend/app/(main)/library/page.tsx` +- Modify: `k-tv-frontend/app/(main)/layout.tsx` + +- [ ] **Step 1: Add Library to nav** + +In `app/(main)/layout.tsx`, add to `NAV_LINKS`: + +```typescript +{ href: "/library", label: "Library" }, +``` + +Place it between Guide and Dashboard. + +- [ ] **Step 2: Create the library page shell** + +```typescript +// app/(main)/library/page.tsx +"use client"; + +import { useState } from "react"; +import { useLibrarySearch, type LibrarySearchParams } from "@/hooks/use-library-search"; +import { LibrarySidebar } from "./components/library-sidebar"; +import { LibraryGrid } from "./components/library-grid"; +import { SyncStatusBar } from "./components/sync-status-bar"; +import type { LibraryItemFull } from "@/lib/types"; + +const PAGE_SIZE = 50; + +export default function LibraryPage() { + const [filter, setFilter] = useState({ limit: PAGE_SIZE, offset: 0 }); + const [selected, setSelected] = useState>(new Set()); + const [page, setPage] = useState(0); + + const { data, isLoading } = useLibrarySearch({ ...filter, offset: page * PAGE_SIZE }); + + function handleFilterChange(next: Partial) { + setFilter(f => ({ ...f, ...next, offset: 0 })); + setPage(0); + setSelected(new Set()); + } + + function toggleSelect(id: string) { + setSelected(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + const selectedItems = data?.items.filter(i => selected.has(i.id)) ?? []; + + return ( +
+ +
+ + +
+
+ ); +} +``` + +- [ ] **Step 3: Verify page renders** + +```bash +cd k-tv-frontend && npm run dev & +``` + +Navigate to `http://localhost:3001/library` — page should render without errors (grid will be empty until sync runs). + +- [ ] **Step 4: Commit** + +```bash +git add k-tv-frontend/app/(main)/layout.tsx \ + k-tv-frontend/app/(main)/library/page.tsx +git commit -m "feat(frontend): add /library route and nav link" +``` + +--- + +## Task 12: Frontend — Library Components + +**Files:** +- Create: `k-tv-frontend/app/(main)/library/components/sync-status-bar.tsx` +- Create: `k-tv-frontend/app/(main)/library/components/library-sidebar.tsx` +- Create: `k-tv-frontend/app/(main)/library/components/library-item-card.tsx` +- Create: `k-tv-frontend/app/(main)/library/components/library-grid.tsx` + +- [ ] **Step 1: SyncStatusBar** + +```typescript +// sync-status-bar.tsx +"use client"; + +import { useLibrarySyncStatus } from "@/hooks/use-library-sync"; +import { formatDistanceToNow } from "date-fns"; + +export function SyncStatusBar() { + const { data: statuses } = useLibrarySyncStatus(); + + if (!statuses || statuses.length === 0) return null; + + return ( +
+
+ {statuses.map(s => ( + + {s.provider_id}:{" "} + {s.status === "running" ? ( + syncing… + ) : s.status === "error" ? ( + error + ) : ( + + {s.items_found.toLocaleString()} items ·{" "} + {s.finished_at + ? formatDistanceToNow(new Date(s.finished_at), { addSuffix: true }) + : ""} + + )} + + ))} +
+
+ ); +} +``` + +> Note: `date-fns` is likely already a dependency. Check `package.json`; if not, run `npm install date-fns`. + +- [ ] **Step 2: LibrarySidebar** + +```typescript +// library-sidebar.tsx +"use client"; + +import { useCollections, useGenres } from "@/hooks/use-library"; +import { useConfig } from "@/hooks/use-config"; +import type { LibrarySearchParams } from "@/hooks/use-library-search"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; + +interface Props { + filter: LibrarySearchParams; + onFilterChange: (next: Partial) => void; +} + +const CONTENT_TYPES = [ + { value: "", label: "All types" }, + { value: "movie", label: "Movies" }, + { value: "episode", label: "Episodes" }, + { value: "short", label: "Shorts" }, +]; + +export function LibrarySidebar({ filter, onFilterChange }: Props) { + const { data: config } = useConfig(); + const { data: collections } = useCollections(filter.provider); + const { data: genres } = useGenres(filter.type, { provider: filter.provider }); + + return ( + + ); +} +``` + +- [ ] **Step 3: LibraryItemCard** + +```typescript +// library-item-card.tsx +"use client"; + +import { useState } from "react"; +import { Checkbox } from "@/components/ui/checkbox"; +import type { LibraryItemFull } from "@/lib/types"; + +interface Props { + item: LibraryItemFull; + selected: boolean; + onToggle: () => void; +} + +export function LibraryItemCard({ item, selected, onToggle }: Props) { + const [imgError, setImgError] = useState(false); + const mins = Math.ceil(item.duration_secs / 60); + + return ( +
+ {/* Thumbnail */} +
+ {item.thumbnail_url && !imgError ? ( + {item.title} setImgError(true)} + /> + ) : ( +
+ No image +
+ )} +
+ + {/* Selection checkbox */} +
{ e.stopPropagation(); onToggle(); }}> + +
+ + {/* Info */} +
+

{item.title}

+

+ {item.content_type === "episode" && item.series_name + ? `${item.series_name} S${item.season_number ?? "?"}E${item.episode_number ?? "?"}` + : item.content_type} + {" · "}{mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`} +

+
+
+ ); +} +``` + +- [ ] **Step 4: LibraryGrid + action bar** + +```typescript +// library-grid.tsx +"use client"; + +import { LibraryItemCard } from "./library-item-card"; +import { ScheduleFromLibraryDialog } from "./schedule-from-library-dialog"; +import { AddToBlockDialog } from "./add-to-block-dialog"; +import { Button } from "@/components/ui/button"; +import type { LibraryItemFull } from "@/lib/types"; + +interface Props { + items: LibraryItemFull[]; + total: number; + page: number; + pageSize: number; + isLoading: boolean; + selected: Set; + onToggleSelect: (id: string) => void; + onPageChange: (page: number) => void; + selectedItems: LibraryItemFull[]; +} + +export function LibraryGrid({ + items, total, page, pageSize, isLoading, + selected, onToggleSelect, onPageChange, selectedItems, +}: Props) { + const totalPages = Math.ceil(total / pageSize); + + return ( +
+
+ {isLoading ? ( +

Loading…

+ ) : items.length === 0 ? ( +

+ No items found. Run a library sync to populate the library. +

+ ) : ( +
+ {items.map(item => ( + onToggleSelect(item.id)} + /> + ))} +
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

{total.toLocaleString()} items total

+
+ + + {page + 1} / {totalPages} + + +
+
+ )} + + {/* Floating action bar */} + {selected.size > 0 && ( +
+ {selected.size} selected + + +
+ )} +
+ ); +} +``` + +- [ ] **Step 5: TypeScript check** + +```bash +cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -30 +``` + +Fix errors before continuing. + +- [ ] **Step 6: Commit** + +```bash +git add k-tv-frontend/app/(main)/library/components/ +git commit -m "feat(frontend): add library page components (sidebar, grid, card, sync bar)" +``` + +--- + +## Task 13: Frontend — Schedule From Library Dialog + +**Files:** +- Create: `k-tv-frontend/app/(main)/library/components/schedule-from-library-dialog.tsx` + +- [ ] **Step 1: Implement dialog** + +```typescript +// schedule-from-library-dialog.tsx +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useChannels, useUpdateChannel } from "@/hooks/use-channels"; +import type { LibraryItemFull, Weekday, ProgrammingBlock } from "@/lib/types"; +import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types"; + +interface Props { + selectedItems: LibraryItemFull[]; +} + +const DAYS = WEEKDAYS; + +export function ScheduleFromLibraryDialog({ selectedItems }: Props) { + const [open, setOpen] = useState(false); + const [channelId, setChannelId] = useState(""); + const [selectedDays, setSelectedDays] = useState>(new Set()); + const [startTime, setStartTime] = useState("20:00"); + const [durationMins, setDurationMins] = useState(() => { + if (selectedItems.length === 1) { + return Math.ceil(selectedItems[0].duration_secs / 60); + } + return 60; + }); + const [strategy, setStrategy] = useState<"sequential" | "random" | "best_fit">("sequential"); + + const { data: channels } = useChannels(); + const updateChannel = useUpdateChannel(); + + const selectedChannel = channels?.find(c => c.id === channelId); + const isEpisodic = selectedItems.every(i => i.content_type === "episode"); + const allSameSeries = isEpisodic && new Set(selectedItems.map(i => i.series_name)).size === 1; + + function toggleDay(day: Weekday) { + setSelectedDays(prev => { + const next = new Set(prev); + if (next.has(day)) next.delete(day); + else next.add(day); + return next; + }); + } + + async function handleConfirm() { + if (!selectedChannel || selectedDays.size === 0) return; + + const config = { ...selectedChannel.schedule_config }; + const startTimeFull = startTime.length === 5 ? `${startTime}:00` : startTime; + + // Build new block + const newBlock: ProgrammingBlock = allSameSeries + ? { + id: globalThis.crypto.randomUUID(), + name: `${selectedItems[0].series_name} — ${startTime}`, + start_time: startTimeFull, + duration_mins: durationMins, + content: { + type: "algorithmic", + filter: { + content_type: "episode", + series_names: [selectedItems[0].series_name!], + genres: [], tags: [], collections: [], + }, + strategy, + provider_id: selectedItems[0].id.split("::")[0], + }, + } + : { + id: globalThis.crypto.randomUUID(), + name: `${selectedItems.length} items — ${startTime}`, + start_time: startTimeFull, + duration_mins: durationMins, + content: { + type: "manual", + items: selectedItems.map(i => i.id), + }, + }; + + // Add to each selected day + const updatedDayBlocks = { ...config.day_blocks }; + for (const day of selectedDays) { + updatedDayBlocks[day] = [...(updatedDayBlocks[day] ?? []), newBlock]; + } + + await updateChannel.mutateAsync({ + id: channelId, + schedule_config: { day_blocks: updatedDayBlocks }, + }); + + setOpen(false); + } + + const canConfirm = !!channelId && selectedDays.size > 0; + const daysLabel = [...selectedDays].map(d => WEEKDAY_LABELS[d]).join(", "); + const preview = canConfirm + ? `${[...selectedDays].length} block(s) will be created on ${selectedChannel?.name} — ${daysLabel} at ${startTime}, ${strategy}` + : null; + + return ( + <> + + + + + Schedule on channel + + +
+
+

Channel

+ +
+ +
+

Days

+
+ {DAYS.map(day => ( + + ))} +
+
+ +
+
+

+ Start time + {selectedChannel?.timezone + ? ` (${selectedChannel.timezone})` + : ""} +

+ setStartTime(e.target.value)} + disabled={!channelId} + /> +
+
+

Duration (mins)

+ setDurationMins(Number(e.target.value))} + disabled={!channelId} + /> +
+
+ +
+

Fill strategy

+ +
+ + {preview && ( +

+ {preview} +

+ )} +
+ + + + + +
+
+ + ); +} +``` + +- [ ] **Step 2: TypeScript check** + +```bash +cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -20 +``` + +- [ ] **Step 3: Commit** + +```bash +git add k-tv-frontend/app/(main)/library/components/schedule-from-library-dialog.tsx +git commit -m "feat(frontend): add ScheduleFromLibraryDialog" +``` + +--- + +## Task 14: Frontend — Add To Block Dialog + +**Files:** +- Create: `k-tv-frontend/app/(main)/library/components/add-to-block-dialog.tsx` + +- [ ] **Step 1: Implement dialog** + +```typescript +// add-to-block-dialog.tsx +"use client"; + +import { useState, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useChannels, useChannel, useUpdateChannel } from "@/hooks/use-channels"; +import type { LibraryItemFull, Weekday } from "@/lib/types"; +import { WEEKDAYS } from "@/lib/types"; + +interface Props { + selectedItems: LibraryItemFull[]; +} + +export function AddToBlockDialog({ selectedItems }: Props) { + const [open, setOpen] = useState(false); + const [channelId, setChannelId] = useState(""); + const [blockId, setBlockId] = useState(""); + + const { data: channels } = useChannels(); + const { data: channel } = useChannel(channelId); + const updateChannel = useUpdateChannel(); + + // Collect all manual blocks, deduplicated by block id + const manualBlocks = useMemo(() => { + if (!channel) return []; + const seen = new Set(); + const result: { id: string; name: string; day: Weekday }[] = []; + for (const day of WEEKDAYS) { + for (const block of channel.schedule_config.day_blocks[day] ?? []) { + if (block.content.type === "manual" && !seen.has(block.id)) { + seen.add(block.id); + result.push({ id: block.id, name: block.name, day }); + } + } + } + return result; + }, [channel]); + + async function handleConfirm() { + if (!channel || !blockId) return; + + // Update all day entries that contain this block id + const updatedDayBlocks = { ...channel.schedule_config.day_blocks }; + for (const day of WEEKDAYS) { + updatedDayBlocks[day] = (updatedDayBlocks[day] ?? []).map(block => { + if (block.id !== blockId || block.content.type !== "manual") return block; + return { + ...block, + content: { + ...block.content, + items: [...block.content.items, ...selectedItems.map(i => i.id)], + }, + }; + }); + } + + await updateChannel.mutateAsync({ + id: channelId, + schedule_config: { day_blocks: updatedDayBlocks }, + }); + + setOpen(false); + } + + return ( + <> + + + + + Add to existing block + + +
+
+

Channel

+ +
+ + {channelId && ( +
+

Manual block

+ {manualBlocks.length === 0 ? ( +

No manual blocks in this channel.

+ ) : ( + + )} +
+ )} + +

+ Adding {selectedItems.length} item(s) to selected block. +

+
+ + + + + +
+
+ + ); +} +``` + +- [ ] **Step 2: TypeScript check + dev server smoke test** + +```bash +cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -20 +``` + +Navigate to `/library`, select items, verify both action bar dialogs open. + +- [ ] **Step 3: Commit** + +```bash +git add k-tv-frontend/app/(main)/library/components/add-to-block-dialog.tsx +git commit -m "feat(frontend): add AddToBlockDialog for adding library items to existing manual blocks" +``` + +--- + +## Task 15: Admin Settings UI (Sync Interval) + +**Files:** +- Modify: find the existing dashboard settings/cog panel (check `app/(main)/dashboard/` for a transcode settings dialog or settings button) + +- [ ] **Step 1: Locate the existing settings panel** + +```bash +grep -r "transcode-settings\|TranscodeSettings\|Settings2" k-tv-frontend/app --include="*.tsx" -l +``` + +Find the component that renders the transcode settings button. + +- [ ] **Step 2: Add library sync section to settings panel** + +In the settings component (e.g. `TranscodeSettingsDialog` or wherever settings live), add a "Library sync" section: + +```typescript +// Import at the top: +import { useAdminSettings, useUpdateAdminSettings } from "@/hooks/use-admin-settings"; +import { useTriggerSync, useLibrarySyncStatus } from "@/hooks/use-library-sync"; + +// Inside the component: +const { data: adminSettings } = useAdminSettings(); +const updateAdminSettings = useUpdateAdminSettings(); +const triggerSync = useTriggerSync(); +const { data: syncStatuses } = useLibrarySyncStatus(); +// Derive from query data directly to avoid stale useState initial value +const syncInterval = adminSettings?.library_sync_interval_hours ?? 6; +const [syncIntervalInput, setSyncIntervalInput] = useState(null); +const displayInterval = syncIntervalInput ?? syncInterval; + +// In the JSX, add a new section: +
+

Library sync

+
+ + setSyncIntervalInput(Number(e.target.value))} + className="h-8 w-24 text-xs" + /> + +
+ + {syncStatuses?.map(s => ( +

+ {s.provider_id}: {s.status} — {s.items_found} items +

+ ))} +
+``` + +- [ ] **Step 3: TypeScript check** + +```bash +cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -20 +``` + +- [ ] **Step 4: Manual end-to-end test** + +1. Open dashboard → settings panel → Library sync section +2. Click "Sync now" → status shows "syncing…" +3. Wait for sync to complete → status shows item count +4. Navigate to `/library` → items appear in grid +5. Select items → action bar appears +6. Test schedule dialog: pick channel, days, time → confirm → check Dashboard blocks +7. Test add-to-block dialog: select movie, pick manual block → confirm → check block items + +- [ ] **Step 5: Full test suite** + +```bash +cd k-tv-backend && cargo test 2>&1 | tail -20 +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add k-tv-frontend/ +git commit -m "feat(frontend): add library sync interval + sync now to admin settings panel" +``` + +--- + +## Final Verification Checklist + +- [ ] `cargo test` — all backend tests pass +- [ ] `npx tsc --noEmit` — no TypeScript errors +- [ ] Migrations run cleanly on fresh DB +- [ ] `POST /library/sync` returns 202; second call while running returns 409 +- [ ] `GET /library/sync/status` shows correct status and item count +- [ ] `GET /library/items?offset=0&limit=10` returns 10 items + `total` +- [ ] `GET /library/items?provider=jellyfin` filters correctly +- [ ] `GET /library/collections` returns `{ id, name, collection_type }` objects +- [ ] `PUT /admin/settings` with non-admin token returns 403 +- [ ] `/library` page loads, sidebar filters update grid, pagination works +- [ ] Broken thumbnail URL shows placeholder image +- [ ] Multi-select → action bar with both buttons +- [ ] Schedule dialog: time input disabled until channel selected +- [ ] Schedule dialog: confirm creates blocks visible in Dashboard +- [ ] Add to block: only manual blocks listed; selecting updates all days that have that block