# 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