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

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.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

-- 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 LibraryItem construction

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 MediaItem in 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 IAppSettingsRepository to 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_provider exists 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, &registry).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_provider may not exist yet. Check infra/src/provider_registry.rs — if missing, add pub 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-fns is likely already a dependency. Check package.json; if not, run npm 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
  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
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 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