96 KiB
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.sqlk-tv-backend/migrations_sqlite/20260319000003_add_app_settings.sqlk-tv-backend/domain/src/library.rs— domain types + traitsk-tv-backend/infra/src/library_repository.rs— SqliteLibraryRepositoryk-tv-backend/infra/src/app_settings_repository.rs— SqliteAppSettingsRepositoryk-tv-backend/infra/src/library_sync.rs— FullSyncAdapterk-tv-backend/api/src/library_scheduler.rs— background sync task
Modified backend files:
k-tv-backend/domain/src/entities.rs— addthumbnail_url,collection_idtoMediaItemk-tv-backend/domain/src/lib.rs— addlibrarymodule + re-exportsk-tv-backend/domain/src/repositories.rs— addIAppSettingsRepositorytraitk-tv-backend/infra/src/lib.rs— add new module re-exportsk-tv-backend/infra/src/factory.rs— add build functionsk-tv-backend/api/src/routes/library.rs— replace live-provider handlers with DB-backed; add sync/admin routesk-tv-backend/api/src/state.rs— addlibrary_repo,library_sync_adapter,app_settings_repok-tv-backend/api/src/main.rs— wire repos + start scheduler
New frontend files:
k-tv-frontend/hooks/use-library-search.tsk-tv-frontend/hooks/use-library-sync.tsk-tv-frontend/hooks/use-admin-settings.tsk-tv-frontend/app/(main)/library/page.tsxk-tv-frontend/app/(main)/library/components/library-sidebar.tsxk-tv-frontend/app/(main)/library/components/library-grid.tsxk-tv-frontend/app/(main)/library/components/library-item-card.tsxk-tv-frontend/app/(main)/library/components/sync-status-bar.tsxk-tv-frontend/app/(main)/library/components/schedule-from-library-dialog.tsxk-tv-frontend/app/(main)/library/components/add-to-block-dialog.tsx
Modified frontend files:
k-tv-frontend/lib/types.ts— add new typesk-tv-frontend/lib/api.ts— add new API methodsk-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
-- 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
-- 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
cd k-tv-backend && cargo run -- 2>&1 | grep -E "migration|error" | head -20
Expected: no migration errors, server starts.
- Step 4: Commit
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
LibraryItemconstruction
Add to the end of domain/src/library.rs (create the file):
#[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
cd k-tv-backend && cargo test -p domain library 2>&1 | tail -20
Expected: compile error — library module not found.
- Step 3: Extend
MediaItemin domain/src/entities.rs
Open k-tv-backend/domain/src/entities.rs and add two optional fields to MediaItem:
// In the MediaItem struct — add after existing fields:
pub thumbnail_url: Option<String>, // provider-served image URL, populated if available
pub collection_id: Option<String>, // 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:
thumbnail_url: None,
collection_id: None,
For Jellyfin (infra/src/jellyfin.rs), populate thumbnail_url with the Jellyfin image URL format:
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
//! 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<String>,
pub season_number: Option<u32>,
pub episode_number: Option<u32>,
pub year: Option<u16>,
pub genres: Vec<String>,
pub tags: Vec<String>,
pub collection_id: Option<String>,
pub collection_name: Option<String>,
pub collection_type: Option<String>,
pub thumbnail_url: Option<String>,
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<String>,
}
/// 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<String>,
}
/// 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<String>,
pub items_found: u32,
pub status: String,
pub error_msg: Option<String>,
}
/// Filter for searching the local library.
#[derive(Debug, Clone, Default)]
pub struct LibrarySearchFilter {
pub provider_id: Option<String>,
pub content_type: Option<ContentType>,
pub series_names: Vec<String>,
pub collection_id: Option<String>,
pub genres: Vec<String>,
pub decade: Option<u16>,
pub min_duration_secs: Option<u32>,
pub max_duration_secs: Option<u32>,
pub search_term: Option<String>,
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<LibraryItem>, u32)>;
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>>;
async fn list_collections(&self, provider_id: Option<&str>) -> DomainResult<Vec<LibraryCollection>>;
async fn list_series(&self, provider_id: Option<&str>) -> DomainResult<Vec<String>>;
async fn list_genres(&self, content_type: Option<&ContentType>, provider_id: Option<&str>) -> DomainResult<Vec<String>>;
async fn upsert_items(&self, provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()>;
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>;
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64>;
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>;
async fn latest_sync_status(&self) -> DomainResult<Vec<LibrarySyncLogEntry>>;
async fn is_sync_running(&self, provider_id: &str) -> DomainResult<bool>;
}
- Step 4: Add
IAppSettingsRepositoryto domain/src/repositories.rs
Append to the end of repositories.rs:
/// 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<Option<String>>;
/// 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<Vec<(String, String)>>;
}
- Step 5: Register in domain/src/lib.rs
Add to the module list:
pub mod library;
Add to the re-exports:
pub use library::{
ILibraryRepository, LibraryCollection, LibraryItem, LibrarySearchFilter,
LibrarySyncAdapter, LibrarySyncLogEntry, LibrarySyncResult,
};
pub use repositories::IAppSettingsRepository;
- Step 6: Run tests
cd k-tv-backend && cargo test -p domain library 2>&1 | tail -20
Expected: 2 tests pass.
- Step 7: Commit
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:
#[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
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
//! 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<LibraryItem>, u32)> {
// Build a dynamic query with WHERE clauses
let mut conditions: Vec<String> = 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<Option<LibraryItem>> {
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<Vec<LibraryCollection>> {
let rows = if let Some(p) = provider_id {
sqlx::query_as::<_, (String, Option<String>, Option<String>)>(
"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<String>, Option<String>)>(
"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<Vec<String>> {
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<Vec<String>> {
// 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<LibraryItem>) -> 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<i64> {
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<Vec<LibrarySyncLogEntry>> {
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<bool> {
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<String>, season_number: Option<i64>, episode_number: Option<i64>,
year: Option<i64>, genres: String, tags: String,
collection_id: Option<String>, collection_name: Option<String>, collection_type: Option<String>,
thumbnail_url: Option<String>, synced_at: String,
}
impl From<LibraryItemRow> 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<String>,
items_found: i64, status: String, error_msg: Option<String>,
}
- Step 4: Register in infra/src/lib.rs
Add:
mod library_repository;
#[cfg(feature = "sqlite")]
pub use library_repository::SqliteLibraryRepository;
- Step 5: Run tests
cd k-tv-backend && cargo test -p infra library_repository 2>&1 | tail -20
Expected: 3 tests pass.
- Step 6: Commit
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:
#[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
//! 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<Option<String>> {
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<Vec<(String, String)>> {
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
mod app_settings_repository;
#[cfg(feature = "sqlite")]
pub use app_settings_repository::SqliteAppSettingsRepository;
- Step 4: Run tests
cd k-tv-backend && cargo test -p infra app_settings 2>&1 | tail -10
Expected: 3 tests pass.
- Step 5: Commit
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
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::*;
struct MockProvider {
items: Vec<MediaItem>,
}
#[async_trait]
impl IMediaProvider for MockProvider {
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities { collections: true, ..Default::default() }
}
async fn fetch_items(&self, _filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
Ok(self.items.clone())
}
async fn fetch_by_id(&self, _id: &MediaItemId) -> DomainResult<Option<MediaItem>> { Ok(None) }
async fn get_stream_url(&self, _id: &MediaItemId, _q: &StreamQuality) -> DomainResult<String> { Ok(String::new()) }
async fn list_collections(&self) -> DomainResult<Vec<Collection>> { Ok(vec![]) }
async fn list_series(&self, _col: Option<&str>) -> DomainResult<Vec<SeriesSummary>> { Ok(vec![]) }
async fn list_genres(&self, _ct: Option<&ContentType>) -> DomainResult<Vec<String>> { Ok(vec![]) }
}
struct SpyRepo {
upserted: Arc<Mutex<Vec<LibraryItem>>>,
cleared: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl ILibraryRepository for SpyRepo {
async fn search(&self, _f: &LibrarySearchFilter) -> DomainResult<(Vec<LibraryItem>, u32)> { Ok((vec![], 0)) }
async fn get_by_id(&self, _id: &str) -> DomainResult<Option<LibraryItem>> { Ok(None) }
async fn list_collections(&self, _p: Option<&str>) -> DomainResult<Vec<LibraryCollection>> { Ok(vec![]) }
async fn list_series(&self, _p: Option<&str>) -> DomainResult<Vec<String>> { Ok(vec![]) }
async fn list_genres(&self, _ct: Option<&ContentType>, _p: Option<&str>) -> DomainResult<Vec<String>> { Ok(vec![]) }
async fn upsert_items(&self, _pid: &str, items: Vec<LibraryItem>) -> 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<i64> { Ok(1) }
async fn log_sync_finish(&self, _id: i64, _r: &LibrarySyncResult) -> DomainResult<()> { Ok(()) }
async fn latest_sync_status(&self) -> DomainResult<Vec<LibrarySyncLogEntry>> { Ok(vec![]) }
async fn is_sync_running(&self, _pid: &str) -> DomainResult<bool> { 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<dyn ILibraryRepository> = 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
//! 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<dyn ILibraryRepository>,
}
impl FullSyncAdapter {
pub fn new(repo: Arc<dyn ILibraryRepository>) -> 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<Collection> = provider.list_collections().await.unwrap_or_default();
let collection_map: std::collections::HashMap<String, &Collection> =
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<LibraryItem> = 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
mod library_sync;
pub use library_sync::FullSyncAdapter;
- Step 4: Run tests
cd k-tv-backend && cargo test -p infra library_sync 2>&1 | tail -10
Expected: 1 test passes.
- Step 5: Commit
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_providerexists on ProviderRegistry
Open k-tv-backend/infra/src/provider_registry.rs. Check if get_provider(&str) -> Option<Arc<dyn IMediaProvider>> exists. If not, add it:
pub fn get_provider(&self, id: &str) -> Option<Arc<dyn IMediaProvider>> {
self.providers.get(id).cloned()
}
The internal providers map is likely HashMap<String, Arc<dyn IMediaProvider>> — confirm the field name and adjust accordingly. Run cargo build to verify.
- Step 2: Implement library_scheduler.rs
//! 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<dyn domain::LibrarySyncAdapter>,
registry: Arc<tokio::sync::RwLock<Arc<infra::ProviderRegistry>>>,
app_settings_repo: Arc<dyn domain::IAppSettingsRepository>,
) {
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<dyn IAppSettingsRepository>) -> u64 {
repo.get("library_sync_interval_hours")
.await
.ok()
.flatten()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_INTERVAL_HOURS)
}
async fn tick(
sync_adapter: &Arc<dyn LibrarySyncAdapter>,
registry: &Arc<tokio::sync::RwLock<Arc<infra::ProviderRegistry>>>,
) {
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_providermay not exist yet. Checkinfra/src/provider_registry.rs— if missing, addpub fn get_provider(&self, id: &str) -> Option<Arc<dyn IMediaProvider>>. 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
cd k-tv-backend && cargo build 2>&1 | grep error | head -20
Fix any errors before continuing.
- Step 4: Commit
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
pub async fn build_library_repository(
pool: &DatabasePool,
) -> FactoryResult<Arc<dyn domain::ILibraryRepository>> {
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<Arc<dyn domain::IAppSettingsRepository>> {
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:
pub library_repo: Arc<dyn domain::ILibraryRepository>,
pub library_sync_adapter: Arc<dyn domain::LibrarySyncAdapter>,
pub app_settings_repo: Arc<dyn domain::IAppSettingsRepository>,
Add the imports at the top:
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:
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<dyn domain::LibrarySyncAdapter> =
Arc::new(infra::FullSyncAdapter::new(Arc::clone(&library_repo)));
Pass them to AppState::new(). After building state, spawn the scheduler:
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
cd k-tv-backend && cargo build 2>&1 | grep error | head -20
- Step 5: Commit
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:
//! 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<AppState> {
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<AppState> {
Router::new()
.route("/settings", get(get_settings))
.route("/settings", put(update_settings))
}
// ── DTOs ───────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct CollectionResponse { id: String, name: String, collection_type: Option<String> }
#[derive(Serialize)]
struct LibraryItemResponse {
id: String, title: String, content_type: String, duration_secs: u32,
series_name: Option<String>, season_number: Option<u32>, episode_number: Option<u32>,
year: Option<u16>, genres: Vec<String>, thumbnail_url: Option<String>,
}
#[derive(Serialize)]
struct SyncLogResponse {
id: i64, provider_id: String, started_at: String, finished_at: Option<String>,
items_found: u32, status: String, error_msg: Option<String>,
}
#[derive(Serialize)]
struct PagedResponse<T: Serialize> { items: Vec<T>, total: u32 }
// ── Query params ───────────────────────────────────────────────────────────
#[derive(Deserialize, Default)]
struct CollectionsQuery { provider: Option<String> }
#[derive(Deserialize, Default)]
struct SeriesQuery { provider: Option<String> }
#[derive(Deserialize, Default)]
struct GenresQuery {
#[serde(rename = "type")]
content_type: Option<String>,
provider: Option<String>,
}
// ── Handlers ───────────────────────────────────────────────────────────────
async fn list_collections(
State(state): State<AppState>,
CurrentUser(_u): CurrentUser,
Query(params): Query<CollectionsQuery>,
) -> Result<Json<Vec<CollectionResponse>>, 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<AppState>,
CurrentUser(_u): CurrentUser,
Query(params): Query<SeriesQuery>,
) -> Result<Json<Vec<String>>, ApiError> {
let series = state.library_repo
.list_series(params.provider.as_deref())
.await?;
Ok(Json(series))
}
async fn list_genres(
State(state): State<AppState>,
CurrentUser(_u): CurrentUser,
Query(params): Query<GenresQuery>,
) -> Result<Json<Vec<String>>, 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<AppState>,
CurrentUser(_u): CurrentUser,
RawQuery(raw): RawQuery,
) -> Result<Json<PagedResponse<LibraryItemResponse>>, ApiError> {
let qs = serde_qs::Config::new(2, false);
#[derive(Deserialize, Default)]
struct ItemsQuery {
q: Option<String>,
#[serde(rename = "type")] content_type: Option<String>,
#[serde(default)] series: Vec<String>,
collection: Option<String>,
provider: Option<String>,
decade: Option<u16>,
min_duration: Option<u32>,
max_duration: Option<u32>,
#[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<AppState>,
CurrentUser(_u): CurrentUser,
Path(id): Path<String>,
) -> Result<Json<LibraryItemResponse>, 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<AppState>,
CurrentUser(_u): CurrentUser,
) -> Result<Json<Vec<SyncLogResponse>>, 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<AppState>,
AdminUser(_u): AdminUser,
) -> Result<StatusCode, ApiError> {
// 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<AppState>,
AdminUser(_u): AdminUser,
) -> Result<Json<serde_json::Value>, 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<AppState>,
AdminUser(_u): AdminUser,
Json(body): Json<HashMap<String, Value>>,
) -> Result<Json<serde_json::Value>, 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<Option<ContentType>, 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:
.nest("/admin", library::admin_router())
alongside the existing /library nesting.
- Step 3: Build
cd k-tv-backend && cargo build 2>&1 | grep error | head -20
- Step 4: Manual smoke test
# 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
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:
// 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:
// In api.library — add these methods:
syncStatus: (token: string): Promise<LibrarySyncLogEntry[]> =>
request('/library/sync/status', { token }),
triggerSync: (token: string): Promise<void> =>
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<PagedLibraryResponse> => {
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<AdminSettings> =>
request('/admin/settings', { token }),
updateSettings: (token: string, patch: Partial<AdminSettings>): Promise<AdminSettings> =>
request('/admin/settings', {
method: 'PUT',
token,
body: JSON.stringify(patch),
headers: { 'Content-Type': 'application/json' },
}),
},
- Step 3: TypeScript check
cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -30
Fix any type errors.
- Step 4: Commit
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
"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
"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
"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<AdminSettings>) =>
api.admin.updateSettings(token!, patch),
onSuccess: (data) => {
queryClient.setQueryData(["admin", "settings"], data);
},
});
}
- Step 4: TypeScript check
cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -20
- Step 5: Commit
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:
{ href: "/library", label: "Library" },
Place it between Guide and Dashboard.
- Step 2: Create the library page shell
// 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<LibrarySearchParams>({ limit: PAGE_SIZE, offset: 0 });
const [selected, setSelected] = useState<Set<string>>(new Set());
const [page, setPage] = useState(0);
const { data, isLoading } = useLibrarySearch({ ...filter, offset: page * PAGE_SIZE });
function handleFilterChange(next: Partial<LibrarySearchParams>) {
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 (
<div className="flex flex-1 flex-col">
<SyncStatusBar />
<div className="flex flex-1">
<LibrarySidebar filter={filter} onFilterChange={handleFilterChange} />
<LibraryGrid
items={data?.items ?? []}
total={data?.total ?? 0}
page={page}
pageSize={PAGE_SIZE}
isLoading={isLoading}
selected={selected}
onToggleSelect={toggleSelect}
onPageChange={setPage}
selectedItems={selectedItems}
/>
</div>
</div>
);
}
- Step 3: Verify page renders
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
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
// 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 (
<div className="border-b border-zinc-800 bg-zinc-900 px-6 py-1.5">
<div className="flex flex-wrap gap-4">
{statuses.map(s => (
<span key={s.id} className="text-xs text-zinc-500">
{s.provider_id}:{" "}
{s.status === "running" ? (
<span className="text-yellow-400">syncing…</span>
) : s.status === "error" ? (
<span className="text-red-400">error</span>
) : (
<span className="text-zinc-400">
{s.items_found.toLocaleString()} items ·{" "}
{s.finished_at
? formatDistanceToNow(new Date(s.finished_at), { addSuffix: true })
: ""}
</span>
)}
</span>
))}
</div>
</div>
);
}
Note:
date-fnsis likely already a dependency. Checkpackage.json; if not, runnpm install date-fns.
- Step 2: LibrarySidebar
// 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<LibrarySearchParams>) => 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 (
<aside className="w-56 shrink-0 border-r border-zinc-800 bg-zinc-950 p-4 flex flex-col gap-4">
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-zinc-500">Search</p>
<Input
placeholder="Search…"
value={filter.q ?? ""}
onChange={e => onFilterChange({ q: e.target.value || undefined })}
className="h-8 text-xs"
/>
</div>
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-zinc-500">Type</p>
<Select value={filter.type ?? ""} onValueChange={v => onFilterChange({ type: v || undefined })}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{CONTENT_TYPES.map(ct => (
<SelectItem key={ct.value} value={ct.value}>{ct.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{collections && collections.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-zinc-500">Collection</p>
<Select value={filter.collection ?? ""} onValueChange={v => onFilterChange({ collection: v || undefined })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="All" /></SelectTrigger>
<SelectContent>
<SelectItem value="">All</SelectItem>
{collections.map(c => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{genres && genres.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-zinc-500">Genre</p>
<div className="flex flex-wrap gap-1">
{genres.map(g => {
const active = filter.genres?.includes(g) ?? false;
return (
<Badge
key={g}
variant={active ? "default" : "outline"}
className="cursor-pointer text-xs"
onClick={() => {
const current = filter.genres ?? [];
onFilterChange({
genres: active ? current.filter(x => x !== g) : [...current, g],
});
}}
>
{g}
</Badge>
);
})}
</div>
</div>
)}
</aside>
);
}
- Step 3: LibraryItemCard
// 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 (
<div
className={`group relative cursor-pointer rounded-lg border transition-colors ${
selected
? "border-violet-500 bg-violet-950/30"
: "border-zinc-800 bg-zinc-900 hover:border-zinc-600"
}`}
onClick={onToggle}
>
{/* Thumbnail */}
<div className="aspect-video w-full overflow-hidden rounded-t-lg bg-zinc-800">
{item.thumbnail_url && !imgError ? (
<img
src={item.thumbnail_url}
alt={item.title}
className="h-full w-full object-cover"
onError={() => setImgError(true)}
/>
) : (
<div className="flex h-full items-center justify-center text-zinc-600 text-xs">
No image
</div>
)}
</div>
{/* Selection checkbox */}
<div className="absolute left-2 top-2" onClick={e => { e.stopPropagation(); onToggle(); }}>
<Checkbox checked={selected} className="border-white/50 bg-black/40" />
</div>
{/* Info */}
<div className="p-2">
<p className="truncate text-xs font-medium text-zinc-100">{item.title}</p>
<p className="mt-0.5 text-xs text-zinc-500">
{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`}
</p>
</div>
</div>
);
}
- Step 4: LibraryGrid + action bar
// 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<string>;
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 (
<div className="flex flex-1 flex-col min-h-0">
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<p className="text-sm text-zinc-500">Loading…</p>
) : items.length === 0 ? (
<p className="text-sm text-zinc-500">
No items found. Run a library sync to populate the library.
</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map(item => (
<LibraryItemCard
key={item.id}
item={item}
selected={selected.has(item.id)}
onToggle={() => onToggleSelect(item.id)}
/>
))}
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-3">
<p className="text-xs text-zinc-500">{total.toLocaleString()} items total</p>
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={page === 0} onClick={() => onPageChange(page - 1)}>
Prev
</Button>
<span className="flex items-center text-xs text-zinc-400">
{page + 1} / {totalPages}
</span>
<Button size="sm" variant="outline" disabled={page >= totalPages - 1} onClick={() => onPageChange(page + 1)}>
Next
</Button>
</div>
</div>
)}
{/* Floating action bar */}
{selected.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-3 rounded-full border border-zinc-700 bg-zinc-900 px-6 py-3 shadow-2xl">
<span className="text-sm text-zinc-300">{selected.size} selected</span>
<ScheduleFromLibraryDialog selectedItems={selectedItems} />
<AddToBlockDialog selectedItems={selectedItems} />
</div>
)}
</div>
);
}
- Step 5: TypeScript check
cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -30
Fix errors before continuing.
- Step 6: Commit
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
// 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<Set<Weekday>>(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 (
<>
<Button size="sm" onClick={() => setOpen(true)}>Schedule on channel</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Schedule on channel</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div>
<p className="mb-1.5 text-xs text-zinc-400">Channel</p>
<Select value={channelId} onValueChange={setChannelId}>
<SelectTrigger><SelectValue placeholder="Select channel…" /></SelectTrigger>
<SelectContent>
{channels?.map(c => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<p className="mb-1.5 text-xs text-zinc-400">Days</p>
<div className="flex flex-wrap gap-2">
{DAYS.map(day => (
<label key={day} className="flex items-center gap-1.5 cursor-pointer">
<Checkbox
checked={selectedDays.has(day)}
onCheckedChange={() => toggleDay(day)}
/>
<span className="text-xs">{WEEKDAY_LABELS[day]}</span>
</label>
))}
</div>
</div>
<div className="flex gap-4">
<div className="flex-1">
<p className="mb-1.5 text-xs text-zinc-400">
Start time
{selectedChannel?.timezone
? ` (${selectedChannel.timezone})`
: ""}
</p>
<Input
type="time"
value={startTime}
onChange={e => setStartTime(e.target.value)}
disabled={!channelId}
/>
</div>
<div className="flex-1">
<p className="mb-1.5 text-xs text-zinc-400">Duration (mins)</p>
<Input
type="number"
min={1}
value={durationMins}
onChange={e => setDurationMins(Number(e.target.value))}
disabled={!channelId}
/>
</div>
</div>
<div>
<p className="mb-1.5 text-xs text-zinc-400">Fill strategy</p>
<Select value={strategy} onValueChange={(v: any) => setStrategy(v)} disabled={!channelId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sequential">Sequential</SelectItem>
<SelectItem value="random">Random</SelectItem>
<SelectItem value="best_fit">Best fit</SelectItem>
</SelectContent>
</Select>
</div>
{preview && (
<p className="rounded-md bg-emerald-950/30 border border-emerald-800 px-3 py-2 text-xs text-emerald-300">
{preview}
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button
disabled={!canConfirm || updateChannel.isPending}
onClick={handleConfirm}
>
{updateChannel.isPending ? "Saving…" : `Create ${selectedDays.size} block(s)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
- Step 2: TypeScript check
cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -20
- Step 3: Commit
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
// 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<string>();
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 (
<>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
Add to block
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Add to existing block</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div>
<p className="mb-1.5 text-xs text-zinc-400">Channel</p>
<Select value={channelId} onValueChange={v => { setChannelId(v); setBlockId(""); }}>
<SelectTrigger><SelectValue placeholder="Select channel…" /></SelectTrigger>
<SelectContent>
{channels?.map(c => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{channelId && (
<div>
<p className="mb-1.5 text-xs text-zinc-400">Manual block</p>
{manualBlocks.length === 0 ? (
<p className="text-xs text-zinc-500">No manual blocks in this channel.</p>
) : (
<Select value={blockId} onValueChange={setBlockId}>
<SelectTrigger><SelectValue placeholder="Select block…" /></SelectTrigger>
<SelectContent>
{manualBlocks.map(b => (
<SelectItem key={b.id} value={b.id}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
<p className="text-xs text-zinc-500">
Adding {selectedItems.length} item(s) to selected block.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button
disabled={!blockId || updateChannel.isPending}
onClick={handleConfirm}
>
{updateChannel.isPending ? "Saving…" : "Add items"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
- Step 2: TypeScript check + dev server smoke test
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
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
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:
// 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<number | null>(null);
const displayInterval = syncIntervalInput ?? syncInterval;
// In the JSX, add a new section:
<div className="border-t border-zinc-800 pt-4 mt-4">
<h3 className="text-sm font-medium mb-3">Library sync</h3>
<div className="flex items-center gap-3 mb-3">
<label className="text-xs text-zinc-400 w-32">Sync interval (hours)</label>
<Input
type="number"
min={1}
max={168}
value={displayInterval}
onChange={e => setSyncIntervalInput(Number(e.target.value))}
className="h-8 w-24 text-xs"
/>
<Button
size="sm"
variant="outline"
onClick={() => updateAdminSettings.mutate({ library_sync_interval_hours: displayInterval })}
disabled={updateAdminSettings.isPending}
>
Save
</Button>
</div>
<Button
size="sm"
onClick={() => triggerSync.mutate()}
disabled={triggerSync.isPending || syncStatuses?.some(s => s.status === "running")}
>
{triggerSync.isPending ? "Triggering…" : "Sync now"}
</Button>
{syncStatuses?.map(s => (
<p key={s.id} className="mt-1 text-xs text-zinc-500">
{s.provider_id}: {s.status} — {s.items_found} items
</p>
))}
</div>
- Step 3: TypeScript check
cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -20
- Step 4: Manual end-to-end test
- Open dashboard → settings panel → Library sync section
- Click "Sync now" → status shows "syncing…"
- Wait for sync to complete → status shows item count
- Navigate to
/library→ items appear in grid - Select items → action bar appears
- Test schedule dialog: pick channel, days, time → confirm → check Dashboard blocks
- Test add-to-block dialog: select movie, pick manual block → confirm → check block items
- Step 5: Full test suite
cd k-tv-backend && cargo test 2>&1 | tail -20
Expected: all tests pass.
- Step 6: Commit
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 passnpx tsc --noEmit— no TypeScript errors- Migrations run cleanly on fresh DB
POST /library/syncreturns 202; second call while running returns 409GET /library/sync/statusshows correct status and item countGET /library/items?offset=0&limit=10returns 10 items +totalGET /library/items?provider=jellyfinfilters correctlyGET /library/collectionsreturns{ id, name, collection_type }objectsPUT /admin/settingswith non-admin token returns 403/librarypage 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