2887 lines
96 KiB
Markdown
2887 lines
96 KiB
Markdown
# Library Management Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add a synced in-house library that stores media metadata from all providers in SQLite and exposes it through a `/library` page where users can browse, filter, multi-select, and schedule media directly onto channels.
|
|
|
|
**Architecture:** Full-sync approach: a background tokio task periodically fetches all items from each provider and truncates+reinserts into a `library_items` SQLite table. Domain layer defines traits (`LibrarySyncAdapter`, `ILibraryRepository`); infra implements them. Existing `GET /library/*` routes are replaced with DB-backed equivalents (same API contract). Frontend gains a new `/library` route with sidebar filters, paginated grid, and scheduling dialogs.
|
|
|
|
**Tech Stack:** Rust (Axum, SQLx, async-trait, tokio), SQLite, Next.js 16, React 19, TanStack Query v5, shadcn/ui, Tailwind v4.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-19-library-management-design.md`
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
**New backend files:**
|
|
- `k-tv-backend/migrations_sqlite/20260319000002_add_library_tables.sql`
|
|
- `k-tv-backend/migrations_sqlite/20260319000003_add_app_settings.sql`
|
|
- `k-tv-backend/domain/src/library.rs` — domain types + traits
|
|
- `k-tv-backend/infra/src/library_repository.rs` — SqliteLibraryRepository
|
|
- `k-tv-backend/infra/src/app_settings_repository.rs` — SqliteAppSettingsRepository
|
|
- `k-tv-backend/infra/src/library_sync.rs` — FullSyncAdapter
|
|
- `k-tv-backend/api/src/library_scheduler.rs` — background sync task
|
|
|
|
**Modified backend files:**
|
|
- `k-tv-backend/domain/src/entities.rs` — add `thumbnail_url`, `collection_id` to `MediaItem`
|
|
- `k-tv-backend/domain/src/lib.rs` — add `library` module + re-exports
|
|
- `k-tv-backend/domain/src/repositories.rs` — add `IAppSettingsRepository` trait
|
|
- `k-tv-backend/infra/src/lib.rs` — add new module re-exports
|
|
- `k-tv-backend/infra/src/factory.rs` — add build functions
|
|
- `k-tv-backend/api/src/routes/library.rs` — replace live-provider handlers with DB-backed; add sync/admin routes
|
|
- `k-tv-backend/api/src/state.rs` — add `library_repo`, `library_sync_adapter`, `app_settings_repo`
|
|
- `k-tv-backend/api/src/main.rs` — wire repos + start scheduler
|
|
|
|
**New frontend files:**
|
|
- `k-tv-frontend/hooks/use-library-search.ts`
|
|
- `k-tv-frontend/hooks/use-library-sync.ts`
|
|
- `k-tv-frontend/hooks/use-admin-settings.ts`
|
|
- `k-tv-frontend/app/(main)/library/page.tsx`
|
|
- `k-tv-frontend/app/(main)/library/components/library-sidebar.tsx`
|
|
- `k-tv-frontend/app/(main)/library/components/library-grid.tsx`
|
|
- `k-tv-frontend/app/(main)/library/components/library-item-card.tsx`
|
|
- `k-tv-frontend/app/(main)/library/components/sync-status-bar.tsx`
|
|
- `k-tv-frontend/app/(main)/library/components/schedule-from-library-dialog.tsx`
|
|
- `k-tv-frontend/app/(main)/library/components/add-to-block-dialog.tsx`
|
|
|
|
**Modified frontend files:**
|
|
- `k-tv-frontend/lib/types.ts` — add new types
|
|
- `k-tv-frontend/lib/api.ts` — add new API methods
|
|
- `k-tv-frontend/app/(main)/layout.tsx` — add Library nav link
|
|
|
|
---
|
|
|
|
## Task 1: SQLite Migrations
|
|
|
|
**Files:**
|
|
- Create: `k-tv-backend/migrations_sqlite/20260319000002_add_library_tables.sql`
|
|
- Create: `k-tv-backend/migrations_sqlite/20260319000003_add_app_settings.sql`
|
|
|
|
- [ ] **Step 1: Write migration 1 — library tables**
|
|
|
|
```sql
|
|
-- 20260319000002_add_library_tables.sql
|
|
CREATE TABLE IF NOT EXISTS library_items (
|
|
id TEXT PRIMARY KEY,
|
|
provider_id TEXT NOT NULL,
|
|
external_id TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
content_type TEXT NOT NULL,
|
|
duration_secs INTEGER NOT NULL DEFAULT 0,
|
|
series_name TEXT,
|
|
season_number INTEGER,
|
|
episode_number INTEGER,
|
|
year INTEGER,
|
|
genres TEXT NOT NULL DEFAULT '[]',
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
collection_id TEXT,
|
|
collection_name TEXT,
|
|
collection_type TEXT,
|
|
thumbnail_url TEXT,
|
|
synced_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_library_items_provider ON library_items(provider_id);
|
|
CREATE INDEX IF NOT EXISTS idx_library_items_content_type ON library_items(content_type);
|
|
CREATE INDEX IF NOT EXISTS idx_library_items_series ON library_items(series_name);
|
|
|
|
CREATE TABLE IF NOT EXISTS library_sync_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
provider_id TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT,
|
|
items_found INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'running',
|
|
error_msg TEXT
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 2: Write migration 2 — app_settings**
|
|
|
|
```sql
|
|
-- 20260319000003_add_app_settings.sql
|
|
CREATE TABLE IF NOT EXISTS app_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
INSERT OR IGNORE INTO app_settings(key, value) VALUES ('library_sync_interval_hours', '6');
|
|
```
|
|
|
|
- [ ] **Step 3: Verify migrations run**
|
|
|
|
```bash
|
|
cd k-tv-backend && cargo run -- 2>&1 | grep -E "migration|error" | head -20
|
|
```
|
|
|
|
Expected: no migration errors, server starts.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add k-tv-backend/migrations_sqlite/20260319000002_add_library_tables.sql \
|
|
k-tv-backend/migrations_sqlite/20260319000003_add_app_settings.sql
|
|
git commit -m "feat(db): add library_items, library_sync_log, app_settings migrations"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Domain Types — library.rs
|
|
|
|
**Files:**
|
|
- Create: `k-tv-backend/domain/src/library.rs`
|
|
- Modify: `k-tv-backend/domain/src/lib.rs`
|
|
- Modify: `k-tv-backend/domain/src/repositories.rs`
|
|
|
|
- [ ] **Step 1: Write failing test for `LibraryItem` construction**
|
|
|
|
Add to the end of `domain/src/library.rs` (create the file):
|
|
|
|
```rust
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn library_item_id_uses_double_colon_separator() {
|
|
let item = LibraryItem {
|
|
id: "jellyfin::abc123".to_string(),
|
|
provider_id: "jellyfin".to_string(),
|
|
external_id: "abc123".to_string(),
|
|
title: "Test Movie".to_string(),
|
|
content_type: crate::ContentType::Movie,
|
|
duration_secs: 7200,
|
|
series_name: None,
|
|
season_number: None,
|
|
episode_number: None,
|
|
year: Some(2020),
|
|
genres: vec!["Action".to_string()],
|
|
tags: vec![],
|
|
collection_id: None,
|
|
collection_name: None,
|
|
collection_type: None,
|
|
thumbnail_url: None,
|
|
synced_at: "2026-03-19T00:00:00Z".to_string(),
|
|
};
|
|
assert!(item.id.contains("::"));
|
|
assert_eq!(item.provider_id, "jellyfin");
|
|
}
|
|
|
|
#[test]
|
|
fn library_search_filter_defaults_are_empty() {
|
|
let f = LibrarySearchFilter::default();
|
|
assert!(f.genres.is_empty());
|
|
assert!(f.series_names.is_empty());
|
|
assert_eq!(f.offset, 0);
|
|
assert_eq!(f.limit, 50);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to see it fail**
|
|
|
|
```bash
|
|
cd k-tv-backend && cargo test -p domain library 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: compile error — `library` module not found.
|
|
|
|
- [ ] **Step 3: Extend `MediaItem` in domain/src/entities.rs**
|
|
|
|
Open `k-tv-backend/domain/src/entities.rs` and add two optional fields to `MediaItem`:
|
|
|
|
```rust
|
|
// In the MediaItem struct — add after existing fields:
|
|
pub thumbnail_url: Option<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:
|
|
```rust
|
|
thumbnail_url: None,
|
|
collection_id: None,
|
|
```
|
|
|
|
For Jellyfin (`infra/src/jellyfin.rs`), populate `thumbnail_url` with the Jellyfin image URL format:
|
|
```rust
|
|
thumbnail_url: Some(format!("{}/Items/{}/Images/Primary?api_key={}", base_url, raw_id, api_key)),
|
|
collection_id: item.parent_id.clone(), // Jellyfin ParentId = collection
|
|
```
|
|
|
|
Run `cargo build` after this step to catch all `MediaItem` literal sites that need updating.
|
|
|
|
- [ ] **Step 4: Implement domain/src/library.rs**
|
|
|
|
```rust
|
|
//! Library domain types and ports.
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use crate::{ContentType, DomainResult, IMediaProvider};
|
|
|
|
/// A media item stored in the local library cache.
|
|
#[derive(Debug, Clone)]
|
|
pub struct LibraryItem {
|
|
pub id: String,
|
|
pub provider_id: String,
|
|
pub external_id: String,
|
|
pub title: String,
|
|
pub content_type: ContentType,
|
|
pub duration_secs: u32,
|
|
pub series_name: Option<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`:
|
|
|
|
```rust
|
|
/// Repository port for general admin settings (app_settings table).
|
|
#[async_trait]
|
|
pub trait IAppSettingsRepository: Send + Sync {
|
|
/// Get a setting value by key. Returns None if not set.
|
|
async fn get(&self, key: &str) -> DomainResult<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:
|
|
```rust
|
|
pub mod library;
|
|
```
|
|
|
|
Add to the re-exports:
|
|
```rust
|
|
pub use library::{
|
|
ILibraryRepository, LibraryCollection, LibraryItem, LibrarySearchFilter,
|
|
LibrarySyncAdapter, LibrarySyncLogEntry, LibrarySyncResult,
|
|
};
|
|
pub use repositories::IAppSettingsRepository;
|
|
```
|
|
|
|
- [ ] **Step 6: Run tests**
|
|
|
|
```bash
|
|
cd k-tv-backend && cargo test -p domain library 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: 2 tests pass.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add k-tv-backend/domain/src/library.rs \
|
|
k-tv-backend/domain/src/lib.rs \
|
|
k-tv-backend/domain/src/repositories.rs
|
|
git commit -m "feat(domain): add library types, LibrarySyncAdapter, ILibraryRepository, IAppSettingsRepository"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Infra — SqliteLibraryRepository
|
|
|
|
**Files:**
|
|
- Create: `k-tv-backend/infra/src/library_repository.rs`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
At the bottom of the new `library_repository.rs`:
|
|
|
|
```rust
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use sqlx::SqlitePool;
|
|
use domain::{LibraryItem, LibrarySearchFilter, ContentType};
|
|
|
|
async fn setup() -> SqlitePool {
|
|
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
|
sqlx::query(
|
|
"CREATE TABLE library_items (
|
|
id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, external_id TEXT NOT NULL,
|
|
title TEXT NOT NULL, content_type TEXT NOT NULL, duration_secs INTEGER NOT NULL DEFAULT 0,
|
|
series_name TEXT, season_number INTEGER, episode_number INTEGER, year INTEGER,
|
|
genres TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]',
|
|
collection_id TEXT, collection_name TEXT, collection_type TEXT,
|
|
thumbnail_url TEXT, synced_at TEXT NOT NULL
|
|
)"
|
|
).execute(&pool).await.unwrap();
|
|
sqlx::query(
|
|
"CREATE TABLE library_sync_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL,
|
|
started_at TEXT NOT NULL, finished_at TEXT, items_found INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'running', error_msg TEXT
|
|
)"
|
|
).execute(&pool).await.unwrap();
|
|
pool
|
|
}
|
|
|
|
fn make_item(id: &str, provider: &str, title: &str) -> LibraryItem {
|
|
LibraryItem {
|
|
id: id.to_string(), provider_id: provider.to_string(), external_id: id.to_string(),
|
|
title: title.to_string(), content_type: ContentType::Movie,
|
|
duration_secs: 3600, series_name: None, season_number: None, episode_number: None,
|
|
year: Some(2020), genres: vec!["Action".to_string()], tags: vec![],
|
|
collection_id: None, collection_name: None, collection_type: None,
|
|
thumbnail_url: None, synced_at: "2026-03-19T00:00:00Z".to_string(),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upsert_then_search_returns_items() {
|
|
let pool = setup().await;
|
|
let repo = SqliteLibraryRepository::new(pool);
|
|
let items = vec![make_item("jellyfin::1", "jellyfin", "Movie A")];
|
|
repo.upsert_items("jellyfin", items).await.unwrap();
|
|
|
|
let (results, total) = repo.search(&LibrarySearchFilter { limit: 50, ..Default::default() }).await.unwrap();
|
|
assert_eq!(total, 1);
|
|
assert_eq!(results[0].title, "Movie A");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_provider_removes_only_that_provider() {
|
|
let pool = setup().await;
|
|
let repo = SqliteLibraryRepository::new(pool);
|
|
repo.upsert_items("jellyfin", vec![make_item("jellyfin::1", "jellyfin", "Jelly Movie")]).await.unwrap();
|
|
repo.upsert_items("local", vec![make_item("local::1", "local", "Local Movie")]).await.unwrap();
|
|
repo.clear_provider("jellyfin").await.unwrap();
|
|
|
|
let (results, _) = repo.search(&LibrarySearchFilter { limit: 50, ..Default::default() }).await.unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].provider_id, "local");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn is_sync_running_reflects_status() {
|
|
let pool = setup().await;
|
|
let repo = SqliteLibraryRepository::new(pool);
|
|
assert!(!repo.is_sync_running("jellyfin").await.unwrap());
|
|
let log_id = repo.log_sync_start("jellyfin").await.unwrap();
|
|
assert!(repo.is_sync_running("jellyfin").await.unwrap());
|
|
let result = domain::LibrarySyncResult {
|
|
provider_id: "jellyfin".to_string(), items_found: 5, duration_ms: 100, error: None,
|
|
};
|
|
repo.log_sync_finish(log_id, &result).await.unwrap();
|
|
assert!(!repo.is_sync_running("jellyfin").await.unwrap());
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run to verify compile failure**
|
|
|
|
```bash
|
|
cd k-tv-backend && cargo test -p infra library_repository 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: compile error — `library_repository` not in infra.
|
|
|
|
- [ ] **Step 3: Implement SqliteLibraryRepository**
|
|
|
|
```rust
|
|
//! SQLite implementation of ILibraryRepository.
|
|
|
|
use std::sync::Arc;
|
|
use async_trait::async_trait;
|
|
use sqlx::SqlitePool;
|
|
|
|
use domain::{
|
|
ContentType, DomainError, DomainResult, ILibraryRepository,
|
|
LibraryCollection, LibraryItem, LibrarySearchFilter, LibrarySyncLogEntry, LibrarySyncResult,
|
|
};
|
|
|
|
pub struct SqliteLibraryRepository {
|
|
pool: SqlitePool,
|
|
}
|
|
|
|
impl SqliteLibraryRepository {
|
|
pub fn new(pool: SqlitePool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
fn content_type_str(ct: &ContentType) -> &'static str {
|
|
match ct {
|
|
ContentType::Movie => "movie",
|
|
ContentType::Episode => "episode",
|
|
ContentType::Short => "short",
|
|
}
|
|
}
|
|
|
|
fn parse_content_type(s: &str) -> ContentType {
|
|
match s {
|
|
"episode" => ContentType::Episode,
|
|
"short" => ContentType::Short,
|
|
_ => ContentType::Movie,
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ILibraryRepository for SqliteLibraryRepository {
|
|
async fn search(&self, filter: &LibrarySearchFilter) -> DomainResult<(Vec<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:
|
|
```rust
|
|
mod library_repository;
|
|
#[cfg(feature = "sqlite")]
|
|
pub use library_repository::SqliteLibraryRepository;
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests**
|
|
|
|
```bash
|
|
cd k-tv-backend && cargo test -p infra library_repository 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: 3 tests pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add k-tv-backend/infra/src/library_repository.rs k-tv-backend/infra/src/lib.rs
|
|
git commit -m "feat(infra): add SqliteLibraryRepository"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Infra — SqliteAppSettingsRepository
|
|
|
|
**Files:**
|
|
- Create: `k-tv-backend/infra/src/app_settings_repository.rs`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
In the new file:
|
|
|
|
```rust
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use sqlx::SqlitePool;
|
|
use domain::IAppSettingsRepository;
|
|
|
|
async fn setup() -> SqlitePool {
|
|
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
|
sqlx::query(
|
|
"CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
|
).execute(&pool).await.unwrap();
|
|
sqlx::query("INSERT INTO app_settings VALUES ('library_sync_interval_hours', '6')")
|
|
.execute(&pool).await.unwrap();
|
|
pool
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_returns_seeded_value() {
|
|
let repo = SqliteAppSettingsRepository::new(setup().await);
|
|
let val = repo.get("library_sync_interval_hours").await.unwrap();
|
|
assert_eq!(val, Some("6".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_then_get() {
|
|
let repo = SqliteAppSettingsRepository::new(setup().await);
|
|
repo.set("library_sync_interval_hours", "12").await.unwrap();
|
|
let val = repo.get("library_sync_interval_hours").await.unwrap();
|
|
assert_eq!(val, Some("12".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_all_returns_all_keys() {
|
|
let repo = SqliteAppSettingsRepository::new(setup().await);
|
|
let all = repo.get_all().await.unwrap();
|
|
assert!(!all.is_empty());
|
|
assert!(all.iter().any(|(k, _)| k == "library_sync_interval_hours"));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement SqliteAppSettingsRepository**
|
|
|
|
```rust
|
|
//! SQLite implementation of IAppSettingsRepository.
|
|
|
|
use async_trait::async_trait;
|
|
use sqlx::SqlitePool;
|
|
use domain::{DomainError, DomainResult, IAppSettingsRepository};
|
|
|
|
pub struct SqliteAppSettingsRepository {
|
|
pool: SqlitePool,
|
|
}
|
|
|
|
impl SqliteAppSettingsRepository {
|
|
pub fn new(pool: SqlitePool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl IAppSettingsRepository for SqliteAppSettingsRepository {
|
|
async fn get(&self, key: &str) -> DomainResult<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**
|
|
|
|
```rust
|
|
mod app_settings_repository;
|
|
#[cfg(feature = "sqlite")]
|
|
pub use app_settings_repository::SqliteAppSettingsRepository;
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
cd k-tv-backend && cargo test -p infra app_settings 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: 3 tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add k-tv-backend/infra/src/app_settings_repository.rs k-tv-backend/infra/src/lib.rs
|
|
git commit -m "feat(infra): add SqliteAppSettingsRepository"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Infra — FullSyncAdapter
|
|
|
|
**Files:**
|
|
- Create: `k-tv-backend/infra/src/library_sync.rs`
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
```rust
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::{Arc, Mutex};
|
|
use async_trait::async_trait;
|
|
use domain::*;
|
|
|
|
struct MockProvider {
|
|
items: Vec<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**
|
|
|
|
```rust
|
|
//! Full-sync library sync adapter: truncate + re-insert all provider items.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
use async_trait::async_trait;
|
|
use domain::{
|
|
Collection, ContentType, ILibraryRepository, IMediaProvider, LibraryItem,
|
|
LibrarySearchFilter, LibrarySyncAdapter, LibrarySyncResult, MediaFilter, MediaItemId,
|
|
};
|
|
|
|
pub struct FullSyncAdapter {
|
|
repo: Arc<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**
|
|
|
|
```rust
|
|
mod library_sync;
|
|
pub use library_sync::FullSyncAdapter;
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
cd k-tv-backend && cargo test -p infra library_sync 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: 1 test passes.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add k-tv-backend/infra/src/library_sync.rs k-tv-backend/infra/src/lib.rs
|
|
git commit -m "feat(infra): add FullSyncAdapter for library sync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: API — Library Sync Scheduler
|
|
|
|
**Files:**
|
|
- Create: `k-tv-backend/api/src/library_scheduler.rs`
|
|
|
|
- [ ] **Step 1: Ensure `get_provider` exists on ProviderRegistry**
|
|
|
|
Open `k-tv-backend/infra/src/provider_registry.rs`. Check if `get_provider(&str) -> Option<Arc<dyn IMediaProvider>>` exists. If not, add it:
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
//! Background library sync task.
|
|
//! Fires 10 seconds after startup, then every N hours (read from app_settings).
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use domain::{IAppSettingsRepository, ILibraryRepository, IProviderRegistry, LibrarySyncAdapter};
|
|
|
|
const STARTUP_DELAY_SECS: u64 = 10;
|
|
const DEFAULT_INTERVAL_HOURS: u64 = 6;
|
|
|
|
// Note: registry uses the concrete infra type to match what AppState holds
|
|
pub async fn run_library_sync(
|
|
sync_adapter: Arc<dyn domain::LibrarySyncAdapter>,
|
|
registry: Arc<tokio::sync::RwLock<Arc<infra::ProviderRegistry>>>,
|
|
app_settings_repo: Arc<dyn domain::IAppSettingsRepository>,
|
|
) {
|
|
tokio::time::sleep(Duration::from_secs(STARTUP_DELAY_SECS)).await;
|
|
|
|
loop {
|
|
tick(&sync_adapter, ®istry).await;
|
|
|
|
let interval_hours = load_interval_hours(&app_settings_repo).await;
|
|
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await;
|
|
}
|
|
}
|
|
|
|
async fn load_interval_hours(repo: &Arc<dyn IAppSettingsRepository>) -> u64 {
|
|
repo.get("library_sync_interval_hours")
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.and_then(|v| v.parse::<u64>().ok())
|
|
.unwrap_or(DEFAULT_INTERVAL_HOURS)
|
|
}
|
|
|
|
async fn tick(
|
|
sync_adapter: &Arc<dyn LibrarySyncAdapter>,
|
|
registry: &Arc<tokio::sync::RwLock<Arc<infra::ProviderRegistry>>>,
|
|
) {
|
|
let reg = registry.read().await;
|
|
let provider_ids = reg.provider_ids();
|
|
drop(reg);
|
|
|
|
for provider_id in provider_ids {
|
|
let reg = registry.read().await;
|
|
let provider = match reg.get_provider(&provider_id) {
|
|
Some(p) => p,
|
|
None => continue,
|
|
};
|
|
|
|
tracing::info!("library-sync: syncing provider '{}'", provider_id);
|
|
let result = sync_adapter.sync_provider(provider.as_ref(), &provider_id).await;
|
|
|
|
if let Some(ref err) = result.error {
|
|
tracing::warn!("library-sync: provider '{}' failed: {}", provider_id, err);
|
|
} else {
|
|
tracing::info!(
|
|
"library-sync: provider '{}' done — {} items in {}ms",
|
|
provider_id, result.items_found, result.duration_ms
|
|
);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
> **Note:** `ProviderRegistry::get_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**
|
|
|
|
```bash
|
|
cd k-tv-backend && cargo build 2>&1 | grep error | head -20
|
|
```
|
|
|
|
Fix any errors before continuing.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add k-tv-backend/api/src/library_scheduler.rs k-tv-backend/api/src/main.rs
|
|
git commit -m "feat(api): add library sync background task"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Wire Factory, AppState, and Main
|
|
|
|
**Files:**
|
|
- Modify: `k-tv-backend/infra/src/factory.rs`
|
|
- Modify: `k-tv-backend/api/src/state.rs`
|
|
- Modify: `k-tv-backend/api/src/main.rs`
|
|
|
|
- [ ] **Step 1: Add factory functions to infra/src/factory.rs**
|
|
|
|
```rust
|
|
pub async fn build_library_repository(
|
|
pool: &DatabasePool,
|
|
) -> FactoryResult<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:
|
|
|
|
```rust
|
|
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:
|
|
```rust
|
|
use domain::{IAppSettingsRepository, ILibraryRepository, LibrarySyncAdapter};
|
|
```
|
|
|
|
Add corresponding params to `AppState::new()` and assign them in the `Ok(Self { ... })` block.
|
|
|
|
- [ ] **Step 3: Wire in main.rs**
|
|
|
|
In `api/src/main.rs`, after building other repos:
|
|
|
|
```rust
|
|
let library_repo = infra::factory::build_library_repository(&db_pool).await?;
|
|
let app_settings_repo = infra::factory::build_app_settings_repository(&db_pool).await?;
|
|
let library_sync_adapter: Arc<dyn domain::LibrarySyncAdapter> =
|
|
Arc::new(infra::FullSyncAdapter::new(Arc::clone(&library_repo)));
|
|
```
|
|
|
|
Pass them to `AppState::new()`. After building state, spawn the scheduler:
|
|
|
|
```rust
|
|
tokio::spawn(library_scheduler::run_library_sync(
|
|
Arc::clone(&state.library_sync_adapter),
|
|
Arc::clone(&state.provider_registry),
|
|
Arc::clone(&state.app_settings_repo),
|
|
));
|
|
```
|
|
|
|
- [ ] **Step 4: Build check**
|
|
|
|
```bash
|
|
cd k-tv-backend && cargo build 2>&1 | grep error | head -20
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add k-tv-backend/infra/src/factory.rs \
|
|
k-tv-backend/api/src/state.rs \
|
|
k-tv-backend/api/src/main.rs
|
|
git commit -m "feat(api): wire library_repo, app_settings_repo, library_sync_adapter into AppState"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Backend API — DB-backed Library Routes + Admin
|
|
|
|
**Files:**
|
|
- Modify: `k-tv-backend/api/src/routes/library.rs`
|
|
- Modify: `k-tv-backend/api/src/routes/mod.rs` (or wherever routes are registered)
|
|
|
|
- [ ] **Step 1: Replace existing handlers + add new routes**
|
|
|
|
Replace the entire content of `api/src/routes/library.rs` with:
|
|
|
|
```rust
|
|
//! Library routes — DB-backed (synced from providers).
|
|
//! GET /library/collections
|
|
//! GET /library/series
|
|
//! GET /library/genres
|
|
//! GET /library/items (paginated, ?offset=&limit=&provider=&type=&series[]=&...)
|
|
//! GET /library/items/:id
|
|
//! GET /library/sync/status
|
|
//! POST /library/sync (admin only)
|
|
//! GET /admin/settings (admin only)
|
|
//! PUT /admin/settings (admin only)
|
|
|
|
use axum::{
|
|
Json, Router,
|
|
extract::{Path, Query, RawQuery, State},
|
|
http::StatusCode,
|
|
routing::{get, post, put},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::collections::HashMap;
|
|
|
|
use domain::{ContentType, IAppSettingsRepository, ILibraryRepository, LibrarySearchFilter, LibrarySyncAdapter};
|
|
|
|
use crate::{error::ApiError, extractors::{AdminUser, CurrentUser}, state::AppState};
|
|
|
|
pub fn router() -> Router<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:
|
|
|
|
```rust
|
|
.nest("/admin", library::admin_router())
|
|
```
|
|
|
|
alongside the existing `/library` nesting.
|
|
|
|
- [ ] **Step 3: Build**
|
|
|
|
```bash
|
|
cd k-tv-backend && cargo build 2>&1 | grep error | head -20
|
|
```
|
|
|
|
- [ ] **Step 4: Manual smoke test**
|
|
|
|
```bash
|
|
# Start server
|
|
cargo run &
|
|
sleep 3
|
|
|
|
# Trigger sync
|
|
curl -s -X POST http://localhost:3000/api/v1/library/sync \
|
|
-H "Authorization: Bearer $TOKEN" | jq .
|
|
|
|
# Check status
|
|
curl -s http://localhost:3000/api/v1/library/sync/status \
|
|
-H "Authorization: Bearer $TOKEN" | jq .
|
|
|
|
# Query items
|
|
curl -s "http://localhost:3000/api/v1/library/items?limit=5" \
|
|
-H "Authorization: Bearer $TOKEN" | jq .total
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add k-tv-backend/api/src/routes/library.rs
|
|
git commit -m "feat(api): replace live-provider library routes with DB-backed routes; add sync + admin settings endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Frontend Types and API Client
|
|
|
|
**Files:**
|
|
- Modify: `k-tv-frontend/lib/types.ts`
|
|
- Modify: `k-tv-frontend/lib/api.ts`
|
|
|
|
- [ ] **Step 1: Add types to lib/types.ts**
|
|
|
|
Append to the end of `types.ts`:
|
|
|
|
```typescript
|
|
// Library management
|
|
// Note: LibraryItemResponse is already defined in this file (search for it above).
|
|
// LibraryItemFull extends it with the extra fields returned by the DB-backed endpoint.
|
|
|
|
export interface LibraryItemFull extends LibraryItemResponse {
|
|
thumbnail_url?: string | null;
|
|
collection_id?: string | null;
|
|
collection_name?: string | null;
|
|
}
|
|
|
|
export interface PagedLibraryResponse {
|
|
items: LibraryItemFull[];
|
|
total: number;
|
|
}
|
|
|
|
export interface LibrarySyncLogEntry {
|
|
id: number;
|
|
provider_id: string;
|
|
started_at: string;
|
|
finished_at?: string | null;
|
|
items_found: number;
|
|
status: 'running' | 'done' | 'error';
|
|
error_msg?: string | null;
|
|
}
|
|
|
|
export interface AdminSettings {
|
|
library_sync_interval_hours: number;
|
|
[key: string]: unknown;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add API methods to lib/api.ts**
|
|
|
|
In the `api` object, extend `api.library` and add `api.admin`:
|
|
|
|
```typescript
|
|
// In api.library — add these methods:
|
|
|
|
syncStatus: (token: string): Promise<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**
|
|
|
|
```bash
|
|
cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -30
|
|
```
|
|
|
|
Fix any type errors.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add k-tv-frontend/lib/types.ts k-tv-frontend/lib/api.ts
|
|
git commit -m "feat(frontend): add library paged types, syncStatus/triggerSync/admin API methods"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Frontend Hooks
|
|
|
|
**Files:**
|
|
- Create: `k-tv-frontend/hooks/use-library-search.ts`
|
|
- Create: `k-tv-frontend/hooks/use-library-sync.ts`
|
|
- Create: `k-tv-frontend/hooks/use-admin-settings.ts`
|
|
|
|
- [ ] **Step 1: Write use-library-search.ts**
|
|
|
|
```typescript
|
|
"use client";
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { api } from "@/lib/api";
|
|
import { useAuthContext } from "@/context/auth-context";
|
|
|
|
export interface LibrarySearchParams {
|
|
q?: string;
|
|
type?: string;
|
|
series?: string[];
|
|
collection?: string;
|
|
provider?: string;
|
|
decade?: number;
|
|
min_duration?: number;
|
|
max_duration?: number;
|
|
genres?: string[];
|
|
offset?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
/**
|
|
* Paginated library search — always enabled, DB-backed (fast).
|
|
* Separate from useLibraryItems in use-library.ts which is snapshot-based
|
|
* and used only for block editor filter preview.
|
|
*/
|
|
export function useLibrarySearch(params: LibrarySearchParams) {
|
|
const { token } = useAuthContext();
|
|
return useQuery({
|
|
queryKey: ["library", "search", params],
|
|
queryFn: () => api.library.itemsPage(token!, params),
|
|
enabled: !!token,
|
|
staleTime: 2 * 60 * 1000,
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write use-library-sync.ts**
|
|
|
|
```typescript
|
|
"use client";
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { api } from "@/lib/api";
|
|
import { useAuthContext } from "@/context/auth-context";
|
|
|
|
export function useLibrarySyncStatus() {
|
|
const { token } = useAuthContext();
|
|
return useQuery({
|
|
queryKey: ["library", "sync"],
|
|
queryFn: () => api.library.syncStatus(token!),
|
|
enabled: !!token,
|
|
staleTime: 30 * 1000, // 30s — sync status can change frequently
|
|
refetchInterval: 10 * 1000, // poll while page is open
|
|
});
|
|
}
|
|
|
|
export function useTriggerSync() {
|
|
const { token } = useAuthContext();
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: () => api.library.triggerSync(token!),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["library", "search"] });
|
|
queryClient.invalidateQueries({ queryKey: ["library", "sync"] });
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Write use-admin-settings.ts**
|
|
|
|
```typescript
|
|
"use client";
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { api } from "@/lib/api";
|
|
import { useAuthContext } from "@/context/auth-context";
|
|
import type { AdminSettings } from "@/lib/types";
|
|
|
|
export function useAdminSettings() {
|
|
const { token } = useAuthContext();
|
|
return useQuery({
|
|
queryKey: ["admin", "settings"],
|
|
queryFn: () => api.admin.getSettings(token!),
|
|
enabled: !!token,
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
}
|
|
|
|
export function useUpdateAdminSettings() {
|
|
const { token } = useAuthContext();
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (patch: Partial<AdminSettings>) =>
|
|
api.admin.updateSettings(token!, patch),
|
|
onSuccess: (data) => {
|
|
queryClient.setQueryData(["admin", "settings"], data);
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: TypeScript check**
|
|
|
|
```bash
|
|
cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -20
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add k-tv-frontend/hooks/use-library-search.ts \
|
|
k-tv-frontend/hooks/use-library-sync.ts \
|
|
k-tv-frontend/hooks/use-admin-settings.ts
|
|
git commit -m "feat(frontend): add useLibrarySearch, useLibrarySyncStatus, useTriggerSync, useAdminSettings hooks"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Frontend — Library Page Shell + Nav
|
|
|
|
**Files:**
|
|
- Create: `k-tv-frontend/app/(main)/library/page.tsx`
|
|
- Modify: `k-tv-frontend/app/(main)/layout.tsx`
|
|
|
|
- [ ] **Step 1: Add Library to nav**
|
|
|
|
In `app/(main)/layout.tsx`, add to `NAV_LINKS`:
|
|
|
|
```typescript
|
|
{ href: "/library", label: "Library" },
|
|
```
|
|
|
|
Place it between Guide and Dashboard.
|
|
|
|
- [ ] **Step 2: Create the library page shell**
|
|
|
|
```typescript
|
|
// app/(main)/library/page.tsx
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useLibrarySearch, type LibrarySearchParams } from "@/hooks/use-library-search";
|
|
import { LibrarySidebar } from "./components/library-sidebar";
|
|
import { LibraryGrid } from "./components/library-grid";
|
|
import { SyncStatusBar } from "./components/sync-status-bar";
|
|
import type { LibraryItemFull } from "@/lib/types";
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
export default function LibraryPage() {
|
|
const [filter, setFilter] = useState<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**
|
|
|
|
```bash
|
|
cd k-tv-frontend && npm run dev &
|
|
```
|
|
|
|
Navigate to `http://localhost:3001/library` — page should render without errors (grid will be empty until sync runs).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add k-tv-frontend/app/(main)/layout.tsx \
|
|
k-tv-frontend/app/(main)/library/page.tsx
|
|
git commit -m "feat(frontend): add /library route and nav link"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Frontend — Library Components
|
|
|
|
**Files:**
|
|
- Create: `k-tv-frontend/app/(main)/library/components/sync-status-bar.tsx`
|
|
- Create: `k-tv-frontend/app/(main)/library/components/library-sidebar.tsx`
|
|
- Create: `k-tv-frontend/app/(main)/library/components/library-item-card.tsx`
|
|
- Create: `k-tv-frontend/app/(main)/library/components/library-grid.tsx`
|
|
|
|
- [ ] **Step 1: SyncStatusBar**
|
|
|
|
```typescript
|
|
// sync-status-bar.tsx
|
|
"use client";
|
|
|
|
import { useLibrarySyncStatus } from "@/hooks/use-library-sync";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
|
|
export function SyncStatusBar() {
|
|
const { data: statuses } = useLibrarySyncStatus();
|
|
|
|
if (!statuses || statuses.length === 0) return null;
|
|
|
|
return (
|
|
<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**
|
|
|
|
```typescript
|
|
// library-sidebar.tsx
|
|
"use client";
|
|
|
|
import { useCollections, useGenres } from "@/hooks/use-library";
|
|
import { useConfig } from "@/hooks/use-config";
|
|
import type { LibrarySearchParams } from "@/hooks/use-library-search";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
interface Props {
|
|
filter: LibrarySearchParams;
|
|
onFilterChange: (next: Partial<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**
|
|
|
|
```typescript
|
|
// library-item-card.tsx
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import type { LibraryItemFull } from "@/lib/types";
|
|
|
|
interface Props {
|
|
item: LibraryItemFull;
|
|
selected: boolean;
|
|
onToggle: () => void;
|
|
}
|
|
|
|
export function LibraryItemCard({ item, selected, onToggle }: Props) {
|
|
const [imgError, setImgError] = useState(false);
|
|
const mins = Math.ceil(item.duration_secs / 60);
|
|
|
|
return (
|
|
<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**
|
|
|
|
```typescript
|
|
// library-grid.tsx
|
|
"use client";
|
|
|
|
import { LibraryItemCard } from "./library-item-card";
|
|
import { ScheduleFromLibraryDialog } from "./schedule-from-library-dialog";
|
|
import { AddToBlockDialog } from "./add-to-block-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import type { LibraryItemFull } from "@/lib/types";
|
|
|
|
interface Props {
|
|
items: LibraryItemFull[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
isLoading: boolean;
|
|
selected: Set<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**
|
|
|
|
```bash
|
|
cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -30
|
|
```
|
|
|
|
Fix errors before continuing.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add k-tv-frontend/app/(main)/library/components/
|
|
git commit -m "feat(frontend): add library page components (sidebar, grid, card, sync bar)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Frontend — Schedule From Library Dialog
|
|
|
|
**Files:**
|
|
- Create: `k-tv-frontend/app/(main)/library/components/schedule-from-library-dialog.tsx`
|
|
|
|
- [ ] **Step 1: Implement dialog**
|
|
|
|
```typescript
|
|
// schedule-from-library-dialog.tsx
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { useChannels, useUpdateChannel } from "@/hooks/use-channels";
|
|
import type { LibraryItemFull, Weekday, ProgrammingBlock } from "@/lib/types";
|
|
import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types";
|
|
|
|
interface Props {
|
|
selectedItems: LibraryItemFull[];
|
|
}
|
|
|
|
const DAYS = WEEKDAYS;
|
|
|
|
export function ScheduleFromLibraryDialog({ selectedItems }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const [channelId, setChannelId] = useState("");
|
|
const [selectedDays, setSelectedDays] = useState<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**
|
|
|
|
```bash
|
|
cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -20
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add k-tv-frontend/app/(main)/library/components/schedule-from-library-dialog.tsx
|
|
git commit -m "feat(frontend): add ScheduleFromLibraryDialog"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Frontend — Add To Block Dialog
|
|
|
|
**Files:**
|
|
- Create: `k-tv-frontend/app/(main)/library/components/add-to-block-dialog.tsx`
|
|
|
|
- [ ] **Step 1: Implement dialog**
|
|
|
|
```typescript
|
|
// add-to-block-dialog.tsx
|
|
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useChannels, useChannel, useUpdateChannel } from "@/hooks/use-channels";
|
|
import type { LibraryItemFull, Weekday } from "@/lib/types";
|
|
import { WEEKDAYS } from "@/lib/types";
|
|
|
|
interface Props {
|
|
selectedItems: LibraryItemFull[];
|
|
}
|
|
|
|
export function AddToBlockDialog({ selectedItems }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const [channelId, setChannelId] = useState("");
|
|
const [blockId, setBlockId] = useState("");
|
|
|
|
const { data: channels } = useChannels();
|
|
const { data: channel } = useChannel(channelId);
|
|
const updateChannel = useUpdateChannel();
|
|
|
|
// Collect all manual blocks, deduplicated by block id
|
|
const manualBlocks = useMemo(() => {
|
|
if (!channel) return [];
|
|
const seen = new Set<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**
|
|
|
|
```bash
|
|
cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -20
|
|
```
|
|
|
|
Navigate to `/library`, select items, verify both action bar dialogs open.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add k-tv-frontend/app/(main)/library/components/add-to-block-dialog.tsx
|
|
git commit -m "feat(frontend): add AddToBlockDialog for adding library items to existing manual blocks"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Admin Settings UI (Sync Interval)
|
|
|
|
**Files:**
|
|
- Modify: find the existing dashboard settings/cog panel (check `app/(main)/dashboard/` for a transcode settings dialog or settings button)
|
|
|
|
- [ ] **Step 1: Locate the existing settings panel**
|
|
|
|
```bash
|
|
grep -r "transcode-settings\|TranscodeSettings\|Settings2" k-tv-frontend/app --include="*.tsx" -l
|
|
```
|
|
|
|
Find the component that renders the transcode settings button.
|
|
|
|
- [ ] **Step 2: Add library sync section to settings panel**
|
|
|
|
In the settings component (e.g. `TranscodeSettingsDialog` or wherever settings live), add a "Library sync" section:
|
|
|
|
```typescript
|
|
// Import at the top:
|
|
import { useAdminSettings, useUpdateAdminSettings } from "@/hooks/use-admin-settings";
|
|
import { useTriggerSync, useLibrarySyncStatus } from "@/hooks/use-library-sync";
|
|
|
|
// Inside the component:
|
|
const { data: adminSettings } = useAdminSettings();
|
|
const updateAdminSettings = useUpdateAdminSettings();
|
|
const triggerSync = useTriggerSync();
|
|
const { data: syncStatuses } = useLibrarySyncStatus();
|
|
// Derive from query data directly to avoid stale useState initial value
|
|
const syncInterval = adminSettings?.library_sync_interval_hours ?? 6;
|
|
const [syncIntervalInput, setSyncIntervalInput] = useState<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**
|
|
|
|
```bash
|
|
cd k-tv-frontend && npx tsc --noEmit 2>&1 | head -20
|
|
```
|
|
|
|
- [ ] **Step 4: Manual end-to-end test**
|
|
|
|
1. Open dashboard → settings panel → Library sync section
|
|
2. Click "Sync now" → status shows "syncing…"
|
|
3. Wait for sync to complete → status shows item count
|
|
4. Navigate to `/library` → items appear in grid
|
|
5. Select items → action bar appears
|
|
6. Test schedule dialog: pick channel, days, time → confirm → check Dashboard blocks
|
|
7. Test add-to-block dialog: select movie, pick manual block → confirm → check block items
|
|
|
|
- [ ] **Step 5: Full test suite**
|
|
|
|
```bash
|
|
cd k-tv-backend && cargo test 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add k-tv-frontend/
|
|
git commit -m "feat(frontend): add library sync interval + sync now to admin settings panel"
|
|
```
|
|
|
|
---
|
|
|
|
## Final Verification Checklist
|
|
|
|
- [ ] `cargo test` — all backend tests pass
|
|
- [ ] `npx tsc --noEmit` — no TypeScript errors
|
|
- [ ] Migrations run cleanly on fresh DB
|
|
- [ ] `POST /library/sync` returns 202; second call while running returns 409
|
|
- [ ] `GET /library/sync/status` shows correct status and item count
|
|
- [ ] `GET /library/items?offset=0&limit=10` returns 10 items + `total`
|
|
- [ ] `GET /library/items?provider=jellyfin` filters correctly
|
|
- [ ] `GET /library/collections` returns `{ id, name, collection_type }` objects
|
|
- [ ] `PUT /admin/settings` with non-admin token returns 403
|
|
- [ ] `/library` page loads, sidebar filters update grid, pagination works
|
|
- [ ] Broken thumbnail URL shows placeholder image
|
|
- [ ] Multi-select → action bar with both buttons
|
|
- [ ] Schedule dialog: time input disabled until channel selected
|
|
- [ ] Schedule dialog: confirm creates blocks visible in Dashboard
|
|
- [ ] Add to block: only manual blocks listed; selecting updates all days that have that block
|