Compare commits
32 Commits
175d0bb0bb
...
f45ca77b79
| Author | SHA1 | Date | |
|---|---|---|---|
| f45ca77b79 | |||
| a5c31ef8a9 | |||
| 3662a5ab9e | |||
| 137251fe37 | |||
| 8101734c63 | |||
| 6cf8a6d5e3 | |||
| c5317cb639 | |||
| 5f66493558 | |||
| 5cc4cde223 | |||
| 5b89481104 | |||
| 33338ac100 | |||
| 66eef2c82e | |||
| 6f1a4e19d3 | |||
| dd69470ee4 | |||
| 23722a771b | |||
| 4cf7fdc1c2 | |||
| 91271bd83c | |||
| 49c7f7abd7 | |||
| 978ad1cdb0 | |||
| e1a885dcc9 | |||
| e849548e9e | |||
| d92d629fbc | |||
| aa5e3c28aa | |||
| 64138b07e4 | |||
| 6732576d06 | |||
| a3a421c0ac | |||
| c6c93766c7 | |||
| e101b44fa5 | |||
| 666b1f2753 | |||
| a7c3f1f92e | |||
| 187cd064fb | |||
| 4cc0e155bd |
2886
docs/superpowers/plans/2026-03-19-library-management.md
Normal file
2886
docs/superpowers/plans/2026-03-19-library-management.md
Normal file
File diff suppressed because it is too large
Load Diff
255
docs/superpowers/specs/2026-03-19-library-management-design.md
Normal file
255
docs/superpowers/specs/2026-03-19-library-management-design.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Library Management — Design Spec
|
||||
|
||||
**Date:** 2026-03-19
|
||||
**Status:** Approved
|
||||
|
||||
## Context
|
||||
|
||||
K-TV currently has ephemeral library browsing: metadata is always fetched live from providers (Jellyfin, local files) on demand, only accessible through the block editor filter UI. There is no persistent library, no cross-provider browsing, and no way to schedule directly from browsing media.
|
||||
|
||||
This feature introduces an in-house library that syncs and stores media metadata from all providers into k-tv's own DB, then surfaces it through a first-class `/library` page where users can browse, filter, multi-select, and schedule media directly onto channels.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Migration `20260319000002_add_library_tables.sql`
|
||||
|
||||
**`library_items` table**
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | TEXT PK | `"{provider_id}::{raw_item_id}"` — double-colon, matches existing registry prefix format |
|
||||
| `provider_id` | TEXT | `"jellyfin"`, `"local"`, etc. |
|
||||
| `external_id` | TEXT | Raw ID from provider (for re-fetching) |
|
||||
| `title` | TEXT | |
|
||||
| `content_type` | TEXT | `"movie"` \| `"episode"` \| `"short"` |
|
||||
| `duration_secs` | INTEGER | |
|
||||
| `series_name` | TEXT | NULL for movies |
|
||||
| `season_number` | INTEGER | NULL for movies |
|
||||
| `episode_number` | INTEGER | NULL for movies |
|
||||
| `year` | INTEGER | |
|
||||
| `genres` | TEXT | JSON array |
|
||||
| `tags` | TEXT | JSON array |
|
||||
| `collection_id` | TEXT | Provider-specific collection ID |
|
||||
| `collection_name` | TEXT | Human-readable name (synced from provider) |
|
||||
| `collection_type` | TEXT | e.g. `"movies"`, `"tvshows"` |
|
||||
| `thumbnail_url` | TEXT | Provider-served image URL; re-fetched on every sync |
|
||||
| `synced_at` | TEXT | ISO8601 timestamp |
|
||||
|
||||
`thumbnail_url` is refreshed on every full sync. Frontend must handle broken image URLs gracefully (show a placeholder on load error) since URLs may break if provider URL or API key changes between syncs.
|
||||
|
||||
**`library_sync_log` table**
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK AUTOINCREMENT | |
|
||||
| `provider_id` | TEXT | |
|
||||
| `started_at` | TEXT | ISO8601 |
|
||||
| `finished_at` | TEXT | ISO8601, NULL while running |
|
||||
| `items_found` | INTEGER | |
|
||||
| `status` | TEXT | `"running"` \| `"done"` \| `"error"` |
|
||||
| `error_msg` | TEXT | NULL on success |
|
||||
|
||||
### Migration `20260319000003_add_app_settings.sql`
|
||||
|
||||
**`app_settings` table** — general-purpose key-value store for admin-configurable settings. Co-exists with the existing `transcode_settings` singleton table (that table is not modified). Seeded with: `INSERT OR IGNORE INTO app_settings(key, value) VALUES ('library_sync_interval_hours', '6')`.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `key` | TEXT PK | |
|
||||
| `value` | TEXT | Bare JSON scalar stored as text (e.g. `6`, not `"6"`) |
|
||||
|
||||
`GET /admin/settings` returns parsed values: `{ "library_sync_interval_hours": 6 }` (number, not string). Backend parses with `serde_json::Value` on read; frontend receives typed JSON.
|
||||
|
||||
---
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Sync Engine
|
||||
|
||||
**Layer placement:**
|
||||
- `LibraryItem`, `LibrarySyncResult`, `LibrarySyncAdapter` trait, and `ILibraryRepository` trait live in **`domain/src/library.rs`**
|
||||
- `FullSyncAdapter` (impl) and `SqliteLibraryRepository` (impl) live in **`infra/src/library/`**
|
||||
|
||||
The `LibrarySyncAdapter` domain trait does **not** take a DB pool — DB writes are an infra concern handled entirely inside the impl:
|
||||
|
||||
```rust
|
||||
// domain/src/library.rs
|
||||
#[async_trait]
|
||||
pub trait LibrarySyncAdapter: Send + Sync {
|
||||
async fn sync_provider(
|
||||
&self,
|
||||
provider: &dyn IMediaProvider,
|
||||
provider_id: &str,
|
||||
) -> LibrarySyncResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ILibraryRepository: Send + Sync {
|
||||
async fn search(&self, filter: LibrarySearchFilter) -> Vec<LibraryItem>;
|
||||
async fn get_by_id(&self, id: &str) -> Option<LibraryItem>;
|
||||
async fn list_collections(&self, provider_id: Option<&str>) -> Vec<LibraryCollection>;
|
||||
async fn list_series(&self, provider_id: Option<&str>) -> Vec<String>;
|
||||
async fn list_genres(&self, content_type: Option<ContentType>, provider_id: Option<&str>) -> 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) -> i64; // returns log row id
|
||||
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult);
|
||||
async fn latest_sync_status(&self) -> Vec<LibrarySyncLogEntry>;
|
||||
async fn is_sync_running(&self, provider_id: &str) -> bool;
|
||||
}
|
||||
```
|
||||
|
||||
`FullSyncAdapter` in infra holds `Arc<dyn ILibraryRepository>` and calls repo methods internally — no DB pool leaks into domain.
|
||||
|
||||
```
|
||||
infra/src/library/
|
||||
mod.rs
|
||||
full_sync.rs -- FullSyncAdapter impl: calls list_collections for names/types,
|
||||
fetch_items(&MediaFilter::default()), repo.clear_provider + repo.upsert_items
|
||||
repository.rs -- SqliteLibraryRepository impl of ILibraryRepository
|
||||
scheduler.rs -- tokio interval task; 10s startup delay (hardcoded); reads interval from
|
||||
app_settings on each tick via AppSettingsRepository
|
||||
```
|
||||
|
||||
**AppState** gains:
|
||||
```rust
|
||||
library_sync_adapter: Arc<dyn LibrarySyncAdapter>,
|
||||
library_repo: Arc<dyn ILibraryRepository>,
|
||||
```
|
||||
|
||||
### Sync Concurrency Guard
|
||||
|
||||
Before starting a sync for a provider, the scheduler and `POST /library/sync` handler both call `repo.is_sync_running(provider_id)`. If `true`, the scheduler skips that provider for this tick; the HTTP endpoint returns **409 Conflict** with body `{ "error": "sync already running for provider" }`. This prevents the truncate+insert race.
|
||||
|
||||
### Admin Settings
|
||||
|
||||
- `GET /admin/settings` — returns `app_settings` rows as parsed JSON object. Requires `is_admin = true` (`AdminUser` extractor).
|
||||
- `PUT /admin/settings` — partial update (only provided keys updated). Requires `is_admin = true`. Scheduler reads new value on next tick.
|
||||
|
||||
### Library API Routes (all require authenticated user)
|
||||
|
||||
| Endpoint | Notes |
|
||||
|---|---|
|
||||
| `GET /library/items?type=&series[]=&collection=&genre=&decade=&min_duration=&max_duration=&search=&provider=&offset=0&limit=50` | DB-backed; returns `{ items: LibraryItemResponse[], total: u32 }` |
|
||||
| `GET /library/items/:id` | Single item |
|
||||
| `GET /library/collections?provider=` | `{ id, name, collection_type }[]` from DB |
|
||||
| `GET /library/series?provider=` | `String[]` from DB |
|
||||
| `GET /library/genres?type=&provider=` | `String[]` from DB |
|
||||
| `GET /library/sync/status` | `LibrarySyncLogEntry[]` (latest per provider) |
|
||||
| `POST /library/sync` | Fires sync; 409 if already running; requires `is_admin = true` |
|
||||
| `GET /admin/settings` | `{ key: value }` map (parsed); requires `is_admin = true` |
|
||||
| `PUT /admin/settings` | Partial update; requires `is_admin = true` |
|
||||
|
||||
**Existing library route API contract is unchanged** for all params except `offset`/`limit` (new). Frontend `use-library.ts` hooks continue working without modification.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### New route: `/library`
|
||||
|
||||
Added to main nav alongside Dashboard and TV.
|
||||
|
||||
```
|
||||
app/(main)/library/
|
||||
page.tsx -- layout, search/filter state, pagination state, multi-select state
|
||||
components/
|
||||
library-sidebar.tsx -- provider picker, type, genre chips, series picker, decade, duration range
|
||||
library-grid.tsx -- paginated grid of LibraryItemCard
|
||||
library-item-card.tsx -- thumbnail (with broken-image fallback placeholder), title,
|
||||
duration badge, content type, checkbox
|
||||
schedule-from-library-dialog.tsx -- modal (see flow below)
|
||||
add-to-block-dialog.tsx -- modal (see flow below)
|
||||
sync-status-bar.tsx -- "Last synced 2h ago · Jellyfin" strip at top
|
||||
```
|
||||
|
||||
### New hooks
|
||||
|
||||
```
|
||||
hooks/use-library-search.ts -- useLibrarySearch(filter, page): wraps GET /library/items with
|
||||
offset/limit pagination. Query key: ["library", "search", filter, page].
|
||||
onSuccess of useTriggerSync: invalidate ["library", "search"] and ["library", "sync"].
|
||||
hooks/use-library-sync.ts -- useLibrarySyncStatus() → ["library", "sync"],
|
||||
useTriggerSync() → POST /library/sync; on success invalidates
|
||||
["library", "search"] and ["library", "sync"]
|
||||
hooks/use-admin-settings.ts -- useAdminSettings(), useUpdateAdminSettings()
|
||||
```
|
||||
|
||||
Existing `use-library.ts` and its four hooks (`useCollections`, `useSeries`, `useGenres`, `useLibraryItems`) are **unchanged** — still used by `AlgorithmicFilterEditor` in the block editor.
|
||||
|
||||
### Schedule From Library Flow
|
||||
|
||||
1. User selects one or more items → floating action bar at bottom
|
||||
2. "Schedule on channel" → `ScheduleFromLibraryDialog` modal
|
||||
3. Modal fields (in order — time/days/strategy disabled until channel is selected):
|
||||
- **Channel** picker (required; enables remaining fields once selected)
|
||||
- **Days**: Mon–Sun checkboxes
|
||||
- **Time**: `NaiveTime` input interpreted in the selected channel's timezone. Timezone label displayed inline (e.g. "20:00 Europe/Warsaw"). Disabled until channel is selected.
|
||||
- **Duration**: For single item, defaults to `ceil(duration_secs / 60)` minutes shown in UI. For multi-item, user sets manually. Rounding to nearest minute shown explicitly (e.g. "1h 35m (rounded from 1h 34m 47s)").
|
||||
- **Fill strategy**: Sequential (default for episodic) | Random | Best Fit
|
||||
4. Preview: *"3 blocks will be created on [Channel] — Mon/Wed/Fri at 20:00 [Europe/Warsaw], Sequential"*
|
||||
5. Confirm → `PUT /channels/:id` merging new `ProgrammingBlock` entries into `schedule_config.day_blocks`:
|
||||
- Series / episodic: **Algorithmic** block with `series_names: [series]`
|
||||
- Specific item(s): **Manual** block with those item IDs
|
||||
|
||||
### Add To Block Flow
|
||||
|
||||
1. User selects items → "Add to block" from action bar
|
||||
2. `AddToBlockDialog`:
|
||||
- Pick channel
|
||||
- Pick existing **manual** block: populated from `useChannel(id)` by collecting all blocks across all days with `content.type === "manual"`, **deduplicated by block `id`** (same block appearing Mon + Wed shown once)
|
||||
3. Confirm → appends item IDs to that block. Since the same block object (by `id`) may appear in multiple days in `schedule_config.day_blocks`, the PUT updates **all day entries that contain that block id** — the block is mutated wherever it appears, consistently.
|
||||
|
||||
### Admin Settings UI
|
||||
|
||||
Settings panel (cog icon in dashboard header, alongside existing transcode settings) gains a "Library sync" section:
|
||||
- Number input: "Sync interval (hours)"
|
||||
- "Sync now" button (visible to admin users only; calls `POST /library/sync`; disabled + shows spinner while running)
|
||||
- Status: "Last synced: [time] · [N] items" per provider from `GET /library/sync/status`
|
||||
|
||||
---
|
||||
|
||||
## Key Files Modified
|
||||
|
||||
**Backend:**
|
||||
- `domain/src/lib.rs` — add `library` module
|
||||
- `domain/src/library.rs` — new: `LibraryItem`, `LibraryCollection`, `LibrarySyncResult`, `LibrarySyncAdapter` trait, `ILibraryRepository` trait, `LibrarySearchFilter`, `LibrarySyncLogEntry`
|
||||
- `infra/src/library/full_sync.rs` — `FullSyncAdapter` impl
|
||||
- `infra/src/library/repository.rs` — `SqliteLibraryRepository` impl
|
||||
- `infra/src/library/scheduler.rs` — tokio interval task, 10s startup delay
|
||||
- `api/src/routes/library.rs` — DB-backed handlers + sync/admin routes
|
||||
- `api/src/routes/mod.rs` — wire admin settings routes
|
||||
- `api/src/main.rs` — start sync scheduler task
|
||||
- `api/src/state.rs` — add `library_sync_adapter: Arc<dyn LibrarySyncAdapter>`, `library_repo: Arc<dyn ILibraryRepository>`
|
||||
- `migrations_sqlite/20260319000002_add_library_tables.sql`
|
||||
- `migrations_sqlite/20260319000003_add_app_settings.sql`
|
||||
|
||||
**Frontend:**
|
||||
- `lib/types.ts` — add `LibraryItem`, `LibraryCollection`, `SyncLogEntry`, `AdminSettings`
|
||||
- `lib/api.ts` — add `api.library.items(filter, page)`, `api.library.syncStatus()`, `api.library.triggerSync()`, `api.admin.getSettings()`, `api.admin.updateSettings(partial)`
|
||||
- `app/(main)/layout.tsx` — add Library nav link
|
||||
- New files per structure above
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Sync**: `POST /library/sync` → 200. `GET /library/sync/status` shows `done` with item count. `library_items` rows in DB have `collection_name` and `thumbnail_url` populated.
|
||||
2. **Sync dedup**: Second `POST /library/sync` while first is running → 409 Conflict.
|
||||
3. **Library API pagination**: `GET /library/items?offset=0&limit=10` returns 10 items + `total`. `?offset=10&limit=10` returns next page.
|
||||
4. **Provider filter**: `GET /library/items?provider=jellyfin` returns only Jellyfin items.
|
||||
5. **Collections**: `GET /library/collections` returns `{ id, name, collection_type }` objects.
|
||||
6. **Admin guard**: `POST /library/sync` and `PUT /admin/settings` with non-admin user → 403.
|
||||
7. **Admin settings**: `PUT /admin/settings { "library_sync_interval_hours": 2 }` → `GET /admin/settings` returns `{ "library_sync_interval_hours": 2 }` (number). Scheduler uses new interval.
|
||||
8. **Library UI**: `/library` page loads, sidebar filters update grid, pagination controls work. `sync-status-bar` shows last sync time.
|
||||
9. **Broken thumbnail**: Item with a broken `thumbnail_url` shows fallback placeholder in `library-item-card`.
|
||||
10. **Multi-select action bar**: Select 3 items → action bar appears with "Schedule on channel" and "Add to block".
|
||||
11. **Schedule flow — time gating**: Time input is disabled until channel is selected; timezone shown next to input after channel selected.
|
||||
12. **Schedule flow — rounding**: Single-item selection shows rounded duration with note in dialog.
|
||||
13. **Schedule flow — confirm**: Series scheduled → Dashboard shows Algorithmic blocks on correct days with `series_names` filter.
|
||||
14. **Add to block — dedup**: Block appearing on Mon+Wed shown once in picker. Confirming updates both days.
|
||||
15. **Cache invalidation**: After `useTriggerSync()` resolves, `["library", "search"]` and `["library", "sync"]` query keys are invalidated, grid refreshes.
|
||||
16. **Block editor unchanged**: `AlgorithmicFilterEditor` works; `useLibraryItems` in `use-library.ts` unchanged.
|
||||
17. **Regression**: `cargo test` passes.
|
||||
@@ -42,6 +42,9 @@ pub enum ApiError {
|
||||
|
||||
#[error("Not implemented: {0}")]
|
||||
NotImplemented(String),
|
||||
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
}
|
||||
|
||||
/// Error response body
|
||||
@@ -155,6 +158,14 @@ impl IntoResponse for ApiError {
|
||||
details: Some(msg.clone()),
|
||||
},
|
||||
),
|
||||
|
||||
ApiError::Conflict(msg) => (
|
||||
StatusCode::CONFLICT,
|
||||
ErrorResponse {
|
||||
error: "Conflict".to_string(),
|
||||
details: Some(msg.clone()),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
(status, Json(error_response)).into_response()
|
||||
@@ -166,16 +177,18 @@ impl ApiError {
|
||||
Self::Validation(msg.into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "local-files")]
|
||||
pub fn internal(msg: impl Into<String>) -> Self {
|
||||
Self::Internal(msg.into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "local-files")]
|
||||
pub fn not_found(msg: impl Into<String>) -> Self {
|
||||
Self::NotFound(msg.into())
|
||||
}
|
||||
|
||||
pub fn conflict(msg: impl Into<String>) -> Self {
|
||||
Self::Conflict(msg.into())
|
||||
}
|
||||
|
||||
pub fn not_implemented(msg: impl Into<String>) -> Self {
|
||||
Self::NotImplemented(msg.into())
|
||||
}
|
||||
|
||||
64
k-tv-backend/api/src/library_scheduler.rs
Normal file
64
k-tv-backend/api/src/library_scheduler.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! 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::IProviderRegistry;
|
||||
|
||||
const STARTUP_DELAY_SECS: u64 = 10;
|
||||
const DEFAULT_INTERVAL_HOURS: u64 = 6;
|
||||
|
||||
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 domain::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 domain::LibrarySyncAdapter>,
|
||||
registry: &Arc<tokio::sync::RwLock<Arc<infra::ProviderRegistry>>>,
|
||||
) {
|
||||
let reg = registry.read().await;
|
||||
let provider_ids: Vec<String> = 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,
|
||||
};
|
||||
drop(reg);
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,13 @@ use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
use domain::{ChannelService, IProviderRegistry, ScheduleEngineService, UserService};
|
||||
use infra::factory::{build_activity_log_repository, build_channel_repository, build_provider_config_repository, build_schedule_repository, build_user_repository};
|
||||
use infra::factory::{build_activity_log_repository, build_app_settings_repository, build_channel_repository, build_library_repository, build_provider_config_repository, build_schedule_repository, build_user_repository};
|
||||
#[cfg(feature = "local-files")]
|
||||
use infra::factory::build_transcode_settings_repository;
|
||||
|
||||
mod config;
|
||||
mod database;
|
||||
mod library_scheduler;
|
||||
mod provider_registry;
|
||||
mod dto;
|
||||
mod error;
|
||||
@@ -80,6 +81,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
#[cfg(feature = "local-files")]
|
||||
let transcode_settings_repo = build_transcode_settings_repository(&db_pool).await.ok();
|
||||
|
||||
let library_repo = build_library_repository(&db_pool).await?;
|
||||
let app_settings_repo = build_app_settings_repository(&db_pool).await?;
|
||||
let library_sync_adapter: Arc<dyn domain::LibrarySyncAdapter> =
|
||||
Arc::new(infra::FullSyncAdapter::new(Arc::clone(&library_repo)));
|
||||
|
||||
#[allow(unused_mut)]
|
||||
let mut state = AppState::new(
|
||||
user_service,
|
||||
@@ -93,6 +99,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
handles.log_history,
|
||||
activity_log_repo,
|
||||
db_pool,
|
||||
library_repo,
|
||||
library_sync_adapter,
|
||||
app_settings_repo,
|
||||
#[cfg(feature = "local-files")]
|
||||
transcode_settings_repo,
|
||||
)
|
||||
@@ -113,5 +122,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
event_tx,
|
||||
);
|
||||
|
||||
tokio::spawn(library_scheduler::run_library_sync(
|
||||
Arc::clone(&state.library_sync_adapter),
|
||||
Arc::clone(&state.provider_registry),
|
||||
Arc::clone(&state.app_settings_repo),
|
||||
));
|
||||
|
||||
server::build_and_serve(state, &config).await
|
||||
}
|
||||
|
||||
@@ -300,6 +300,8 @@ mod tests {
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
thumbnail_url: None,
|
||||
collection_id: None,
|
||||
},
|
||||
source_block_id: Uuid::new_v4(),
|
||||
}
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
//! Library browsing routes
|
||||
//! Library routes — DB-backed.
|
||||
//!
|
||||
//! These endpoints expose the media provider's library to the dashboard so
|
||||
//! users can discover what's available without knowing provider-internal IDs.
|
||||
//! All routes require authentication.
|
||||
//! GET /library/collections — collections derived from synced items
|
||||
//! GET /library/series — series names
|
||||
//! GET /library/genres — genres
|
||||
//! GET /library/items — search / browse
|
||||
//! GET /library/items/:id — single item
|
||||
//! GET /library/sync/status — latest sync log per provider
|
||||
//! POST /library/sync — trigger an ad-hoc sync (auth)
|
||||
//!
|
||||
//! GET /library/collections — top-level libraries (Jellyfin views, Plex sections)
|
||||
//! GET /library/series — TV series, optionally scoped to a collection
|
||||
//! GET /library/genres — available genres, optionally filtered by content type
|
||||
//! GET /library/items — search / browse items (used for block filter preview)
|
||||
//! Admin (nested under /admin/library):
|
||||
//! GET /admin/library/settings — app_settings key/value
|
||||
//! PUT /admin/library/settings — update app_settings
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, RawQuery, State},
|
||||
routing::get,
|
||||
extract::{Path, Query, RawQuery, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use domain::IProviderRegistry as _;
|
||||
use domain::{ContentType, ILibraryRepository, LibrarySearchFilter, LibrarySyncAdapter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::{Collection, ContentType, MediaFilter, SeriesSummary};
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
extractors::{AdminUser, CurrentUser},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use crate::{error::ApiError, extractors::CurrentUser, state::AppState};
|
||||
// ============================================================================
|
||||
// Routers
|
||||
// ============================================================================
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -27,6 +41,15 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/series", get(list_series))
|
||||
.route("/genres", get(list_genres))
|
||||
.route("/items", get(search_items))
|
||||
.route("/items/{id}", get(get_item))
|
||||
.route("/shows", get(list_shows))
|
||||
.route("/shows/{name}/seasons", get(list_seasons))
|
||||
.route("/sync/status", get(sync_status))
|
||||
.route("/sync", post(trigger_sync))
|
||||
}
|
||||
|
||||
pub fn admin_router() -> Router<AppState> {
|
||||
Router::new().route("/settings", get(get_settings).put(update_settings))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -41,38 +64,6 @@ struct CollectionResponse {
|
||||
collection_type: Option<String>,
|
||||
}
|
||||
|
||||
impl From<Collection> for CollectionResponse {
|
||||
fn from(c: Collection) -> Self {
|
||||
Self {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
collection_type: c.collection_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SeriesResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
episode_count: u32,
|
||||
genres: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
year: Option<u16>,
|
||||
}
|
||||
|
||||
impl From<SeriesSummary> for SeriesResponse {
|
||||
fn from(s: SeriesSummary) -> Self {
|
||||
Self {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
episode_count: s.episode_count,
|
||||
genres: s.genres,
|
||||
year: s.year,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LibraryItemResponse {
|
||||
id: String,
|
||||
@@ -88,6 +79,45 @@ struct LibraryItemResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
year: Option<u16>,
|
||||
genres: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PagedResponse<T: Serialize> {
|
||||
items: Vec<T>,
|
||||
total: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ShowSummaryResponse {
|
||||
series_name: String,
|
||||
episode_count: u32,
|
||||
season_count: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thumbnail_url: Option<String>,
|
||||
genres: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SeasonSummaryResponse {
|
||||
season_number: u32,
|
||||
episode_count: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SyncLogResponse {
|
||||
id: i64,
|
||||
provider_id: String,
|
||||
started_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
finished_at: Option<String>,
|
||||
items_found: u32,
|
||||
status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_msg: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -96,47 +126,47 @@ struct LibraryItemResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CollectionsQuery {
|
||||
/// Provider key to query (default: primary).
|
||||
provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeriesQuery {
|
||||
/// Scope results to a specific collection (provider library ID).
|
||||
collection: Option<String>,
|
||||
/// Provider key to query (default: primary).
|
||||
provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GenresQuery {
|
||||
/// Limit genres to a content type: "movie", "episode", or "short".
|
||||
#[serde(rename = "type")]
|
||||
content_type: Option<String>,
|
||||
/// Provider key to query (default: primary).
|
||||
provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ItemsQuery {
|
||||
/// Free-text search.
|
||||
q: Option<String>,
|
||||
/// Content type filter: "movie", "episode", or "short".
|
||||
#[serde(rename = "type")]
|
||||
content_type: Option<String>,
|
||||
/// Filter episodes by series name. Repeat the param for multiple series:
|
||||
/// `?series[]=iCarly&series[]=Victorious`
|
||||
#[serde(default)]
|
||||
series: Vec<String>,
|
||||
/// Scope to a provider collection ID.
|
||||
#[serde(default)]
|
||||
genres: Vec<String>,
|
||||
collection: Option<String>,
|
||||
/// Maximum number of results (default: 50, max: 200).
|
||||
limit: Option<usize>,
|
||||
/// Fill strategy to simulate: "random" | "sequential" | "best_fit".
|
||||
/// Applies the same ordering the schedule engine would use so the preview
|
||||
/// reflects what will actually be scheduled.
|
||||
strategy: Option<String>,
|
||||
/// Provider key to query (default: primary).
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
provider: Option<String>,
|
||||
season: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ShowsQuery {
|
||||
q: Option<String>,
|
||||
provider: Option<String>,
|
||||
#[serde(default)]
|
||||
genres: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeasonsQuery {
|
||||
provider: Option<String>,
|
||||
}
|
||||
|
||||
@@ -144,130 +174,275 @@ struct ItemsQuery {
|
||||
// Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// List top-level collections (Jellyfin virtual libraries, Plex sections, etc.)
|
||||
async fn list_collections(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<CollectionsQuery>,
|
||||
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
|
||||
let provider_id = params.provider.as_deref().unwrap_or("");
|
||||
let registry = state.provider_registry.read().await;
|
||||
let caps = registry.capabilities(provider_id).ok_or_else(|| {
|
||||
ApiError::validation(format!("Unknown provider '{}'", provider_id))
|
||||
})?;
|
||||
if !caps.collections {
|
||||
return Err(ApiError::not_implemented("collections not supported by this provider"));
|
||||
}
|
||||
let collections = registry.list_collections(provider_id).await?;
|
||||
Ok(Json(collections.into_iter().map(Into::into).collect()))
|
||||
let cols = state
|
||||
.library_repo
|
||||
.list_collections(params.provider.as_deref())
|
||||
.await?;
|
||||
let resp = cols
|
||||
.into_iter()
|
||||
.map(|c| CollectionResponse {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
collection_type: c.collection_type,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
|
||||
/// List TV series, optionally scoped to a collection.
|
||||
async fn list_series(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<SeriesQuery>,
|
||||
) -> Result<Json<Vec<SeriesResponse>>, ApiError> {
|
||||
let provider_id = params.provider.as_deref().unwrap_or("");
|
||||
let registry = state.provider_registry.read().await;
|
||||
let caps = registry.capabilities(provider_id).ok_or_else(|| {
|
||||
ApiError::validation(format!("Unknown provider '{}'", provider_id))
|
||||
})?;
|
||||
if !caps.series {
|
||||
return Err(ApiError::not_implemented("series not supported by this provider"));
|
||||
}
|
||||
let series = registry
|
||||
.list_series(provider_id, params.collection.as_deref())
|
||||
) -> Result<Json<Vec<String>>, ApiError> {
|
||||
let series = state
|
||||
.library_repo
|
||||
.list_series(params.provider.as_deref())
|
||||
.await?;
|
||||
Ok(Json(series.into_iter().map(Into::into).collect()))
|
||||
Ok(Json(series))
|
||||
}
|
||||
|
||||
/// List available genres, optionally filtered to a content type.
|
||||
async fn list_genres(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<GenresQuery>,
|
||||
) -> Result<Json<Vec<String>>, ApiError> {
|
||||
let provider_id = params.provider.as_deref().unwrap_or("");
|
||||
let registry = state.provider_registry.read().await;
|
||||
let caps = registry.capabilities(provider_id).ok_or_else(|| {
|
||||
ApiError::validation(format!("Unknown provider '{}'", provider_id))
|
||||
})?;
|
||||
if !caps.genres {
|
||||
return Err(ApiError::not_implemented("genres not supported by this provider"));
|
||||
}
|
||||
let ct = parse_content_type(params.content_type.as_deref())?;
|
||||
let genres = registry.list_genres(provider_id, ct.as_ref()).await?;
|
||||
let genres = state
|
||||
.library_repo
|
||||
.list_genres(ct.as_ref(), params.provider.as_deref())
|
||||
.await?;
|
||||
Ok(Json(genres))
|
||||
}
|
||||
|
||||
/// Search / browse library items. Used by the block editor to preview what a
|
||||
/// filter matches before saving a channel config.
|
||||
async fn search_items(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
) -> Result<Json<Vec<LibraryItemResponse>>, ApiError> {
|
||||
let qs_config = serde_qs::Config::new(2, false); // non-strict: accept encoded brackets
|
||||
) -> Result<Json<PagedResponse<LibraryItemResponse>>, ApiError> {
|
||||
let qs_config = serde_qs::Config::new(2, false);
|
||||
let params: ItemsQuery = raw_query
|
||||
.as_deref()
|
||||
.map(|q| qs_config.deserialize_str::<ItemsQuery>(q))
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::validation(e.to_string()))?
|
||||
.unwrap_or_default();
|
||||
|
||||
let limit = params.limit.unwrap_or(50).min(200);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
let provider_id = params.provider.as_deref().unwrap_or("");
|
||||
|
||||
let filter = MediaFilter {
|
||||
let filter = LibrarySearchFilter {
|
||||
provider_id: params.provider,
|
||||
content_type: parse_content_type(params.content_type.as_deref())?,
|
||||
search_term: params.q,
|
||||
series_names: params.series,
|
||||
collections: params
|
||||
.collection
|
||||
.map(|c| vec![c])
|
||||
.unwrap_or_default(),
|
||||
collection_id: params.collection,
|
||||
genres: params.genres,
|
||||
search_term: params.q,
|
||||
season_number: params.season,
|
||||
offset,
|
||||
limit,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let registry = state.provider_registry.read().await;
|
||||
let mut items = registry.fetch_items(provider_id, &filter).await?;
|
||||
let (items, total) = state.library_repo.search(&filter).await?;
|
||||
let resp = items.into_iter().map(library_item_to_response).collect();
|
||||
Ok(Json(PagedResponse { items: resp, total }))
|
||||
}
|
||||
|
||||
// Apply the same ordering the schedule engine uses so the preview reflects
|
||||
// what will actually be scheduled rather than raw provider order.
|
||||
match params.strategy.as_deref() {
|
||||
Some("random") => {
|
||||
use rand::seq::SliceRandom;
|
||||
items.shuffle(&mut rand::thread_rng());
|
||||
}
|
||||
Some("best_fit") => {
|
||||
// Mirror the greedy bin-packing: longest items first.
|
||||
items.sort_by(|a, b| b.duration_secs.cmp(&a.duration_secs));
|
||||
}
|
||||
_ => {} // "sequential" / unset: keep provider order (episode order per series)
|
||||
}
|
||||
async fn get_item(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<LibraryItemResponse>, ApiError> {
|
||||
let item = state
|
||||
.library_repo
|
||||
.get_by_id(&id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Library item '{}' not found", id)))?;
|
||||
Ok(Json(library_item_to_response(item)))
|
||||
}
|
||||
|
||||
let response: Vec<LibraryItemResponse> = items
|
||||
async fn sync_status(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
) -> Result<Json<Vec<SyncLogResponse>>, ApiError> {
|
||||
let entries = state.library_repo.latest_sync_status().await?;
|
||||
let resp = entries
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|item| LibraryItemResponse {
|
||||
id: item.id.into_inner(),
|
||||
title: item.title,
|
||||
content_type: match item.content_type {
|
||||
domain::ContentType::Movie => "movie".into(),
|
||||
domain::ContentType::Episode => "episode".into(),
|
||||
domain::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,
|
||||
.map(|e| SyncLogResponse {
|
||||
id: e.id,
|
||||
provider_id: e.provider_id,
|
||||
started_at: e.started_at,
|
||||
finished_at: e.finished_at,
|
||||
items_found: e.items_found,
|
||||
status: e.status,
|
||||
error_msg: e.error_msg,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
|
||||
Ok(Json(response))
|
||||
async fn trigger_sync(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
use domain::IProviderRegistry as _;
|
||||
let provider_ids: Vec<String> = {
|
||||
let reg = state.provider_registry.read().await;
|
||||
reg.provider_ids()
|
||||
};
|
||||
|
||||
// 409 if any provider is already syncing
|
||||
for pid in &provider_ids {
|
||||
let running = state.library_repo.is_sync_running(pid).await?;
|
||||
if running {
|
||||
return Ok((
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("Sync already running for provider '{}'", pid)
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn background sync
|
||||
let sync_adapter: Arc<dyn LibrarySyncAdapter> = Arc::clone(&state.library_sync_adapter);
|
||||
let registry = Arc::clone(&state.provider_registry);
|
||||
tokio::spawn(async move {
|
||||
let providers: Vec<(String, Arc<dyn domain::IMediaProvider>)> = {
|
||||
let reg = registry.read().await;
|
||||
provider_ids
|
||||
.iter()
|
||||
.filter_map(|id| reg.get_provider(id).map(|p| (id.clone(), p)))
|
||||
.collect()
|
||||
};
|
||||
|
||||
for (pid, provider) in providers {
|
||||
let result = sync_adapter.sync_provider(provider.as_ref(), &pid).await;
|
||||
if let Some(ref err) = result.error {
|
||||
tracing::warn!("manual sync: provider '{}' failed: {}", pid, err);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"manual sync: provider '{}' done — {} items in {}ms",
|
||||
pid,
|
||||
result.items_found,
|
||||
result.duration_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(serde_json::json!({ "message": "Sync started" })),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn list_shows(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<ShowsQuery>,
|
||||
) -> Result<Json<Vec<ShowSummaryResponse>>, ApiError> {
|
||||
let shows = state
|
||||
.library_repo
|
||||
.list_shows(
|
||||
params.provider.as_deref(),
|
||||
params.q.as_deref(),
|
||||
¶ms.genres,
|
||||
)
|
||||
.await?;
|
||||
let resp = shows
|
||||
.into_iter()
|
||||
.map(|s| ShowSummaryResponse {
|
||||
series_name: s.series_name,
|
||||
episode_count: s.episode_count,
|
||||
season_count: s.season_count,
|
||||
thumbnail_url: s.thumbnail_url,
|
||||
genres: s.genres,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
|
||||
async fn list_seasons(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path(name): Path<String>,
|
||||
Query(params): Query<SeasonsQuery>,
|
||||
) -> Result<Json<Vec<SeasonSummaryResponse>>, ApiError> {
|
||||
let seasons = state
|
||||
.library_repo
|
||||
.list_seasons(&name, params.provider.as_deref())
|
||||
.await?;
|
||||
let resp = seasons
|
||||
.into_iter()
|
||||
.map(|s| SeasonSummaryResponse {
|
||||
season_number: s.season_number,
|
||||
episode_count: s.episode_count,
|
||||
thumbnail_url: s.thumbnail_url,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
|
||||
async fn get_settings(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
) -> Result<Json<HashMap<String, serde_json::Value>>, ApiError> {
|
||||
let pairs = state.app_settings_repo.get_all().await?;
|
||||
let map: HashMap<String, serde_json::Value> = pairs
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
// Try to parse as number first, then bool, then keep as string
|
||||
let val = if let Ok(n) = v.parse::<i64>() {
|
||||
serde_json::Value::Number(n.into())
|
||||
} else if let Ok(b) = v.parse::<bool>() {
|
||||
serde_json::Value::Bool(b)
|
||||
} else {
|
||||
serde_json::Value::String(v)
|
||||
};
|
||||
(k, val)
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(map))
|
||||
}
|
||||
|
||||
async fn update_settings(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Json(body): Json<HashMap<String, serde_json::Value>>,
|
||||
) -> Result<Json<HashMap<String, serde_json::Value>>, ApiError> {
|
||||
for (key, val) in &body {
|
||||
let val_str = match val {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
state.app_settings_repo.set(key, &val_str).await?;
|
||||
}
|
||||
// Return the updated state
|
||||
let pairs = state.app_settings_repo.get_all().await?;
|
||||
let map: HashMap<String, serde_json::Value> = pairs
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
let val = if let Ok(n) = v.parse::<i64>() {
|
||||
serde_json::Value::Number(n.into())
|
||||
} else if let Ok(b) = v.parse::<bool>() {
|
||||
serde_json::Value::Bool(b)
|
||||
} else {
|
||||
serde_json::Value::String(v)
|
||||
};
|
||||
(k, val)
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(map))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -286,3 +461,22 @@ fn parse_content_type(s: Option<&str>) -> Result<Option<ContentType>, ApiError>
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn library_item_to_response(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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,5 @@ pub fn api_v1_router() -> Router<AppState> {
|
||||
.nest("/files", files::router())
|
||||
.nest("/iptv", iptv::router())
|
||||
.nest("/library", library::router())
|
||||
.nest("/admin", library::admin_router())
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use tokio::sync::broadcast;
|
||||
use crate::config::Config;
|
||||
use crate::events::EventBus;
|
||||
use crate::log_layer::LogLine;
|
||||
use domain::{ActivityLogRepository, ChannelService, ProviderConfigRepository, ScheduleEngineService, UserService};
|
||||
use domain::{ActivityLogRepository, ChannelService, IAppSettingsRepository, ILibraryRepository, LibrarySyncAdapter, ProviderConfigRepository, ScheduleEngineService, UserService};
|
||||
#[cfg(feature = "local-files")]
|
||||
use domain::TranscodeSettingsRepository;
|
||||
use k_core::db::DatabasePool;
|
||||
@@ -53,6 +53,9 @@ pub struct AppState {
|
||||
pub transcode_settings_repo: Option<Arc<dyn TranscodeSettingsRepository>>,
|
||||
/// Database pool — used by infra factory functions for hot-reload.
|
||||
pub db_pool: Arc<DatabasePool>,
|
||||
pub library_repo: Arc<dyn ILibraryRepository>,
|
||||
pub library_sync_adapter: Arc<dyn LibrarySyncAdapter>,
|
||||
pub app_settings_repo: Arc<dyn IAppSettingsRepository>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -69,6 +72,9 @@ impl AppState {
|
||||
log_history: Arc<Mutex<VecDeque<LogLine>>>,
|
||||
activity_log_repo: Arc<dyn ActivityLogRepository>,
|
||||
db_pool: Arc<DatabasePool>,
|
||||
library_repo: Arc<dyn ILibraryRepository>,
|
||||
library_sync_adapter: Arc<dyn LibrarySyncAdapter>,
|
||||
app_settings_repo: Arc<dyn IAppSettingsRepository>,
|
||||
#[cfg(feature = "local-files")]
|
||||
transcode_settings_repo: Option<Arc<dyn TranscodeSettingsRepository>>,
|
||||
) -> anyhow::Result<Self> {
|
||||
@@ -155,6 +161,9 @@ impl AppState {
|
||||
#[cfg(feature = "local-files")]
|
||||
transcode_settings_repo,
|
||||
db_pool,
|
||||
library_repo,
|
||||
library_sync_adapter,
|
||||
app_settings_repo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +346,10 @@ pub struct MediaItem {
|
||||
pub season_number: Option<u32>,
|
||||
/// For episodes: episode number within the season (1-based).
|
||||
pub episode_number: Option<u32>,
|
||||
/// Provider-served thumbnail image URL, populated if available.
|
||||
pub thumbnail_url: Option<String>,
|
||||
/// Provider-specific collection this item belongs to.
|
||||
pub collection_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A fully resolved 7-day broadcast program for one channel.
|
||||
|
||||
@@ -58,6 +58,8 @@ mod tests {
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
thumbnail_url: None,
|
||||
collection_id: None,
|
||||
},
|
||||
source_block_id: Uuid::new_v4(),
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod entities;
|
||||
pub mod errors;
|
||||
pub mod iptv;
|
||||
pub mod library;
|
||||
pub mod ports;
|
||||
pub mod repositories;
|
||||
pub mod services;
|
||||
@@ -19,5 +20,10 @@ pub use events::DomainEvent;
|
||||
pub use ports::{Collection, IMediaProvider, IProviderRegistry, ProviderCapabilities, SeriesSummary, StreamingProtocol, StreamQuality};
|
||||
pub use repositories::*;
|
||||
pub use iptv::{generate_m3u, generate_xmltv};
|
||||
pub use library::{
|
||||
ILibraryRepository, LibraryCollection, LibraryItem, LibrarySearchFilter,
|
||||
LibrarySyncAdapter, LibrarySyncLogEntry, LibrarySyncResult,
|
||||
SeasonSummary, ShowSummary,
|
||||
};
|
||||
pub use services::{ChannelService, ScheduleEngineService, UserService};
|
||||
pub use value_objects::*;
|
||||
|
||||
187
k-tv-backend/domain/src/library.rs
Normal file
187
k-tv-backend/domain/src/library.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! 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)]
|
||||
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 season_number: Option<u32>,
|
||||
pub offset: u32,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
impl Default for LibrarySearchFilter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider_id: None,
|
||||
content_type: None,
|
||||
series_names: vec![],
|
||||
collection_id: None,
|
||||
genres: vec![],
|
||||
decade: None,
|
||||
min_duration_secs: None,
|
||||
max_duration_secs: None,
|
||||
search_term: None,
|
||||
season_number: None,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated summary of a TV show derived from synced episodes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShowSummary {
|
||||
pub series_name: String,
|
||||
pub episode_count: u32,
|
||||
pub season_count: u32,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub genres: Vec<String>,
|
||||
}
|
||||
|
||||
/// Aggregated summary of one season of a TV show.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SeasonSummary {
|
||||
pub season_number: u32,
|
||||
pub episode_count: u32,
|
||||
pub thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 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>;
|
||||
async fn list_shows(
|
||||
&self,
|
||||
provider_id: Option<&str>,
|
||||
search_term: Option<&str>,
|
||||
genres: &[String],
|
||||
) -> DomainResult<Vec<ShowSummary>>;
|
||||
async fn list_seasons(
|
||||
&self,
|
||||
series_name: &str,
|
||||
provider_id: Option<&str>,
|
||||
) -> DomainResult<Vec<SeasonSummary>>;
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -180,3 +180,14 @@ pub trait TranscodeSettingsRepository: Send + Sync {
|
||||
/// Persist the cleanup TTL (upsert — always row id=1).
|
||||
async fn save_cleanup_ttl(&self, hours: u32) -> DomainResult<()>;
|
||||
}
|
||||
|
||||
/// 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)>>;
|
||||
}
|
||||
|
||||
83
k-tv-backend/infra/src/app_settings_repository.rs
Normal file
83
k-tv-backend/infra/src/app_settings_repository.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! 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::InfrastructureError(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::InfrastructureError(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::InfrastructureError(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::DatabasePool;
|
||||
use domain::{ActivityLogRepository, ChannelRepository, ProviderConfigRepository, ScheduleRepository, TranscodeSettingsRepository, UserRepository};
|
||||
use domain::{ActivityLogRepository, ChannelRepository, IAppSettingsRepository, ILibraryRepository, ProviderConfigRepository, ScheduleRepository, TranscodeSettingsRepository, UserRepository};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FactoryError {
|
||||
@@ -119,6 +119,36 @@ pub async fn build_transcode_settings_repository(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_library_repository(
|
||||
pool: &DatabasePool,
|
||||
) -> FactoryResult<Arc<dyn 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 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(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local-files")]
|
||||
pub struct LocalFilesBundle {
|
||||
pub provider: Arc<crate::LocalFilesProvider>,
|
||||
|
||||
@@ -31,5 +31,8 @@ pub(super) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
|
||||
series_name: item.series_name,
|
||||
season_number: item.parent_index_number,
|
||||
episode_number: item.index_number,
|
||||
// TODO(library-sync): populate thumbnail_url from Jellyfin image API and collection_id from parent_id when FullSyncAdapter is implemented (Task 5)
|
||||
thumbnail_url: None,
|
||||
collection_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,8 +18,13 @@ pub mod db;
|
||||
pub mod factory;
|
||||
pub mod jellyfin;
|
||||
pub mod provider_registry;
|
||||
mod library_sync;
|
||||
pub use library_sync::FullSyncAdapter;
|
||||
|
||||
mod app_settings_repository;
|
||||
mod activity_log_repository;
|
||||
mod channel_repository;
|
||||
mod library_repository;
|
||||
mod provider_config_repository;
|
||||
mod schedule_repository;
|
||||
mod transcode_settings_repository;
|
||||
@@ -32,6 +37,8 @@ pub mod local_files;
|
||||
pub use db::run_migrations;
|
||||
pub use provider_registry::ProviderRegistry;
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
pub use app_settings_repository::SqliteAppSettingsRepository;
|
||||
#[cfg(feature = "sqlite")]
|
||||
pub use activity_log_repository::SqliteActivityLogRepository;
|
||||
#[cfg(feature = "sqlite")]
|
||||
@@ -44,6 +51,8 @@ pub use provider_config_repository::SqliteProviderConfigRepository;
|
||||
pub use schedule_repository::SqliteScheduleRepository;
|
||||
#[cfg(feature = "sqlite")]
|
||||
pub use transcode_settings_repository::SqliteTranscodeSettingsRepository;
|
||||
#[cfg(feature = "sqlite")]
|
||||
pub use library_repository::SqliteLibraryRepository;
|
||||
|
||||
pub use domain::TranscodeSettingsRepository;
|
||||
|
||||
|
||||
508
k-tv-backend/infra/src/library_repository.rs
Normal file
508
k-tv-backend/infra/src/library_repository.rs
Normal file
@@ -0,0 +1,508 @@
|
||||
//! SQLite implementation of ILibraryRepository.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::{
|
||||
ContentType, DomainError, DomainResult, ILibraryRepository,
|
||||
LibraryCollection, LibraryItem, LibrarySearchFilter, LibrarySyncLogEntry, LibrarySyncResult,
|
||||
SeasonSummary, ShowSummary,
|
||||
};
|
||||
|
||||
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)> {
|
||||
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));
|
||||
}
|
||||
if !filter.series_names.is_empty() {
|
||||
let quoted: Vec<String> = filter.series_names.iter()
|
||||
.map(|s| format!("'{}'", s.replace('\'', "''")))
|
||||
.collect();
|
||||
conditions.push(format!("series_name IN ({})", quoted.join(",")));
|
||||
}
|
||||
if !filter.genres.is_empty() {
|
||||
let genre_conditions: Vec<String> = filter.genres.iter()
|
||||
.map(|g| format!("EXISTS (SELECT 1 FROM json_each(library_items.genres) WHERE value = '{}')", g.replace('\'', "''")))
|
||||
.collect();
|
||||
conditions.push(format!("({})", genre_conditions.join(" OR ")));
|
||||
}
|
||||
if let Some(sn) = filter.season_number {
|
||||
conditions.push(format!("season_number = {}", sn));
|
||||
}
|
||||
|
||||
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::InfrastructureError(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::InfrastructureError(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::InfrastructureError(e.to_string()))?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn list_collections(&self, provider_id: Option<&str>) -> DomainResult<Vec<LibraryCollection>> {
|
||||
let rows: Vec<(String, Option<String>, Option<String>)> = 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::InfrastructureError(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::InfrastructureError(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>> {
|
||||
let sql = match (content_type, provider_id) {
|
||||
(Some(ct), Some(p)) => format!(
|
||||
"SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je
|
||||
WHERE li.content_type = '{}' AND li.provider_id = '{}' ORDER BY je.value ASC",
|
||||
content_type_str(ct), p.replace('\'', "''")
|
||||
),
|
||||
(Some(ct), None) => format!(
|
||||
"SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je
|
||||
WHERE li.content_type = '{}' ORDER BY je.value ASC",
|
||||
content_type_str(ct)
|
||||
),
|
||||
(None, Some(p)) => format!(
|
||||
"SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je
|
||||
WHERE li.provider_id = '{}' ORDER BY je.value ASC",
|
||||
p.replace('\'', "''")
|
||||
),
|
||||
(None, None) => "SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je ORDER BY je.value ASC".to_string(),
|
||||
};
|
||||
let rows: Vec<(String,)> = sqlx::query_as(&sql)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(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::InfrastructureError(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::InfrastructureError(e.to_string()))?;
|
||||
}
|
||||
tx.commit().await.map_err(|e| DomainError::InfrastructureError(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::InfrastructureError(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::InfrastructureError(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::InfrastructureError(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::InfrastructureError(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::InfrastructureError(e.to_string()))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn list_shows(
|
||||
&self,
|
||||
provider_id: Option<&str>,
|
||||
search_term: Option<&str>,
|
||||
genres: &[String],
|
||||
) -> DomainResult<Vec<ShowSummary>> {
|
||||
let mut conditions = vec![
|
||||
"content_type = 'episode'".to_string(),
|
||||
"series_name IS NOT NULL".to_string(),
|
||||
];
|
||||
if let Some(p) = provider_id {
|
||||
conditions.push(format!("provider_id = '{}'", p.replace('\'', "''")));
|
||||
}
|
||||
if let Some(st) = search_term {
|
||||
let escaped = st.replace('\'', "''");
|
||||
conditions.push(format!(
|
||||
"(title LIKE '%{escaped}%' OR series_name LIKE '%{escaped}%')"
|
||||
));
|
||||
}
|
||||
if !genres.is_empty() {
|
||||
let genre_conditions: Vec<String> = genres
|
||||
.iter()
|
||||
.map(|g| format!(
|
||||
"EXISTS (SELECT 1 FROM json_each(library_items.genres) WHERE value = '{}')",
|
||||
g.replace('\'', "''")
|
||||
))
|
||||
.collect();
|
||||
conditions.push(format!("({})", genre_conditions.join(" OR ")));
|
||||
}
|
||||
let where_clause = format!("WHERE {}", conditions.join(" AND "));
|
||||
let sql = format!(
|
||||
"SELECT series_name, COUNT(*) AS episode_count, COUNT(DISTINCT season_number) AS season_count, MAX(thumbnail_url) AS thumbnail_url, GROUP_CONCAT(genres, ',') AS genres_blob FROM library_items {} GROUP BY series_name ORDER BY series_name ASC",
|
||||
where_clause
|
||||
);
|
||||
let rows = sqlx::query_as::<_, ShowSummaryRow>(&sql)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let genres: Vec<String> = r
|
||||
.genres_blob
|
||||
.split("],[")
|
||||
.flat_map(|chunk| {
|
||||
let cleaned = chunk.trim_start_matches('[').trim_end_matches(']');
|
||||
cleaned
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let s = s.trim().trim_matches('"');
|
||||
if s.is_empty() { None } else { Some(s.to_string()) }
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
ShowSummary {
|
||||
series_name: r.series_name,
|
||||
episode_count: r.episode_count as u32,
|
||||
season_count: r.season_count as u32,
|
||||
thumbnail_url: r.thumbnail_url,
|
||||
genres,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_seasons(
|
||||
&self,
|
||||
series_name: &str,
|
||||
provider_id: Option<&str>,
|
||||
) -> DomainResult<Vec<SeasonSummary>> {
|
||||
let mut conditions = vec![
|
||||
format!("series_name = '{}'", series_name.replace('\'', "''")),
|
||||
"content_type = 'episode'".to_string(),
|
||||
];
|
||||
if let Some(p) = provider_id {
|
||||
conditions.push(format!("provider_id = '{}'", p.replace('\'', "''")));
|
||||
}
|
||||
let where_clause = format!("WHERE {}", conditions.join(" AND "));
|
||||
let sql = format!(
|
||||
"SELECT season_number, COUNT(*) AS episode_count, MAX(thumbnail_url) AS thumbnail_url FROM library_items {} GROUP BY season_number ORDER BY season_number ASC",
|
||||
where_clause
|
||||
);
|
||||
let rows = sqlx::query_as::<_, SeasonSummaryRow>(&sql)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| SeasonSummary {
|
||||
season_number: r.season_number as u32,
|
||||
episode_count: r.episode_count as u32,
|
||||
thumbnail_url: r.thumbnail_url,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ShowSummaryRow {
|
||||
series_name: String,
|
||||
episode_count: i64,
|
||||
season_count: i64,
|
||||
thumbnail_url: Option<String>,
|
||||
genres_blob: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SeasonSummaryRow {
|
||||
season_number: i64,
|
||||
episode_count: i64,
|
||||
thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
249
k-tv-backend/infra/src/library_sync.rs
Normal file
249
k-tv-backend/infra/src/library_sync.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
//! Full-sync library sync adapter: truncate + re-insert all provider items.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
Collection, ILibraryRepository, IMediaProvider, LibraryItem,
|
||||
LibrarySyncAdapter, LibrarySyncResult, MediaFilter,
|
||||
};
|
||||
|
||||
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: 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.
|
||||
let (col_name, col_type) = item.collection_id.as_deref()
|
||||
.and_then(|cid| collection_map.get(cid))
|
||||
.map(|c| (Some(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,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
series: false,
|
||||
genres: false,
|
||||
tags: false,
|
||||
decade: false,
|
||||
search: false,
|
||||
streaming_protocol: StreamingProtocol::Hls,
|
||||
rescan: false,
|
||||
transcode: false,
|
||||
}
|
||||
}
|
||||
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) }
|
||||
async fn list_shows(&self, _p: Option<&str>, _st: Option<&str>, _g: &[String]) -> DomainResult<Vec<domain::ShowSummary>> { Ok(vec![]) }
|
||||
async fn list_seasons(&self, _sn: &str, _p: Option<&str>) -> DomainResult<Vec<domain::SeasonSummary>> { Ok(vec![]) }
|
||||
}
|
||||
|
||||
#[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,
|
||||
description: None,
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
year: None,
|
||||
genres: vec![],
|
||||
tags: vec![],
|
||||
thumbnail_url: None,
|
||||
collection_id: None,
|
||||
}],
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,8 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
thumbnail_url: None,
|
||||
collection_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ impl ProviderRegistry {
|
||||
self.providers.is_empty()
|
||||
}
|
||||
|
||||
/// Return the provider registered under `id`, if any.
|
||||
pub fn get_provider(&self, id: &str) -> Option<Arc<dyn IMediaProvider>> {
|
||||
self.providers
|
||||
.iter()
|
||||
.find(|(pid, _)| pid == id)
|
||||
.map(|(_, p)| Arc::clone(p))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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 UNIQUE INDEX IF NOT EXISTS idx_library_items_provider_external ON library_items(provider_id, external_id);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_library_sync_log_provider ON library_sync_log(provider_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_library_sync_log_provider_started ON library_sync_log(provider_id, started_at DESC);
|
||||
@@ -0,0 +1,6 @@
|
||||
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');
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
useTranscodeStats,
|
||||
useClearTranscodeCache,
|
||||
} from "@/hooks/use-transcode";
|
||||
import { useAdminSettings, useUpdateAdminSettings } from "@/hooks/use-admin-settings";
|
||||
import { useTriggerSync, useLibrarySyncStatus } from "@/hooks/use-library-sync";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
@@ -39,6 +41,14 @@ export function TranscodeSettingsDialog({ open, onOpenChange }: Props) {
|
||||
const updateSettings = useUpdateTranscodeSettings();
|
||||
const clearCache = useClearTranscodeCache();
|
||||
|
||||
const { data: adminSettings } = useAdminSettings();
|
||||
const updateAdminSettings = useUpdateAdminSettings();
|
||||
const triggerSync = useTriggerSync();
|
||||
const { data: syncStatuses } = useLibrarySyncStatus();
|
||||
const syncInterval = adminSettings?.library_sync_interval_hours ?? 6;
|
||||
const [syncIntervalInput, setSyncIntervalInput] = useState<number | null>(null);
|
||||
const displayInterval = syncIntervalInput ?? syncInterval;
|
||||
|
||||
const [ttl, setTtl] = useState<number>(24);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
@@ -130,6 +140,42 @@ export function TranscodeSettingsDialog({ open, onOpenChange }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 bg-zinc-800 border-zinc-700 text-zinc-100"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => updateAdminSettings.mutate({ library_sync_interval_hours: displayInterval })}
|
||||
disabled={updateAdminSettings.isPending}
|
||||
className="border-zinc-700 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AdminNavLink } from "./components/admin-nav-link";
|
||||
const NAV_LINKS = [
|
||||
{ href: "/tv", label: "TV" },
|
||||
{ href: "/guide", label: "Guide" },
|
||||
{ href: "/library", label: "Library" },
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/docs", label: "Docs" },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"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, ScheduleConfig } 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();
|
||||
|
||||
const manualBlocks = useMemo(() => {
|
||||
if (!channel) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: { id: string; name: string }[] = [];
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [channel]);
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!channel || !blockId) return;
|
||||
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)],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const scheduleConfig: ScheduleConfig = { day_blocks: updatedDayBlocks };
|
||||
|
||||
await updateChannel.mutateAsync({
|
||||
id: channelId,
|
||||
data: { schedule_config: scheduleConfig },
|
||||
});
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
interface Props {
|
||||
series?: string;
|
||||
season?: number;
|
||||
onNavigate: (target: "root" | "series") => void;
|
||||
}
|
||||
|
||||
export function BreadcrumbNav({ series, season, onNavigate }: Props) {
|
||||
return (
|
||||
<nav className="flex items-center gap-1 px-1 py-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
onClick={() => onNavigate("root")}
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
{series && (
|
||||
<>
|
||||
<span className="text-zinc-600">›</span>
|
||||
{season != null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
onClick={() => onNavigate("series")}
|
||||
>
|
||||
{series}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-zinc-100">{series}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{season != null && (
|
||||
<>
|
||||
<span className="text-zinc-600">›</span>
|
||||
<span className="text-zinc-100">Season {season}</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
183
k-tv-frontend/app/(main)/library/components/library-grid.tsx
Normal file
183
k-tv-frontend/app/(main)/library/components/library-grid.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useLibraryShows } from "@/hooks/use-library-shows";
|
||||
import { useLibrarySeasons } from "@/hooks/use-library-seasons";
|
||||
import { LibraryItemCard } from "./library-item-card";
|
||||
import { ShowTile } from "./show-tile";
|
||||
import { SeasonTile } from "./season-tile";
|
||||
import { BreadcrumbNav } from "./breadcrumb-nav";
|
||||
import { ScheduleFromLibraryDialog } from "./schedule-from-library-dialog";
|
||||
import { AddToBlockDialog } from "./add-to-block-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { LibraryItemFull, ShowSummary } from "@/lib/types";
|
||||
import type { LibrarySearchParams } from "@/hooks/use-library-search";
|
||||
|
||||
type Drilldown = null | { series: string } | { series: string; season: number };
|
||||
|
||||
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[];
|
||||
viewMode: "grouped" | "flat";
|
||||
drilldown: Drilldown;
|
||||
onDrilldown: (next: Drilldown) => void;
|
||||
filter: LibrarySearchParams;
|
||||
selectedShows: ShowSummary[];
|
||||
selectedShowNames: Set<string>;
|
||||
onToggleSelectShow: (show: ShowSummary) => void;
|
||||
}
|
||||
|
||||
export function LibraryGrid({
|
||||
items, total, page, pageSize, isLoading,
|
||||
selected, onToggleSelect, onPageChange, selectedItems,
|
||||
viewMode, drilldown, onDrilldown, filter,
|
||||
selectedShows, selectedShowNames, onToggleSelectShow,
|
||||
}: Props) {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// Hooks for grouped mode (called unconditionally per React rules)
|
||||
const showsFilter = {
|
||||
q: filter.q,
|
||||
genres: filter.genres,
|
||||
provider: filter.provider,
|
||||
};
|
||||
const { data: showsData, isLoading: showsLoading } = useLibraryShows(showsFilter);
|
||||
const seasonsSeries = (viewMode === "grouped" && drilldown !== null && !("season" in drilldown))
|
||||
? drilldown.series
|
||||
: null;
|
||||
const { data: seasonsData, isLoading: seasonsLoading } = useLibrarySeasons(
|
||||
seasonsSeries,
|
||||
filter.provider,
|
||||
);
|
||||
|
||||
const isGroupedTopLevel = viewMode === "grouped" && drilldown === null;
|
||||
const isSeasonLevel = viewMode === "grouped" && drilldown !== null && !("season" in drilldown);
|
||||
const isEpisodeLevel = viewMode === "flat" || (viewMode === "grouped" && drilldown !== null && "season" in drilldown);
|
||||
|
||||
function renderContent() {
|
||||
if (isGroupedTopLevel) {
|
||||
// Only show TV show tiles when no type filter is active — "Movies"/"Shorts" should not include shows
|
||||
const shows = !filter.type ? (showsData ?? []) : [];
|
||||
const nonEpisodes = items.filter(i => i.content_type !== "episode");
|
||||
const loading = showsLoading;
|
||||
|
||||
if (loading && shows.length === 0 && nonEpisodes.length === 0) {
|
||||
return <p className="text-sm text-zinc-500">Loading…</p>;
|
||||
}
|
||||
if (shows.length === 0 && nonEpisodes.length === 0) {
|
||||
return <p className="text-sm text-zinc-500">No items found. Run a library sync to populate the library.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{shows.map(show => (
|
||||
<ShowTile
|
||||
key={show.series_name}
|
||||
show={show}
|
||||
selected={selectedShowNames.has(show.series_name)}
|
||||
onToggle={() => onToggleSelectShow(show)}
|
||||
onClick={() => onDrilldown({ series: show.series_name })}
|
||||
/>
|
||||
))}
|
||||
{nonEpisodes.map(item => (
|
||||
<LibraryItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selected.has(item.id)}
|
||||
onToggle={() => onToggleSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSeasonLevel && drilldown) {
|
||||
const seasons = seasonsData ?? [];
|
||||
if (seasonsLoading && seasons.length === 0) {
|
||||
return <p className="text-sm text-zinc-500">Loading…</p>;
|
||||
}
|
||||
if (seasons.length === 0) {
|
||||
return <p className="text-sm text-zinc-500">No seasons found.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{seasons.map(season => (
|
||||
<SeasonTile
|
||||
key={season.season_number}
|
||||
season={season}
|
||||
selected={false}
|
||||
onToggle={() => {}}
|
||||
onClick={() => onDrilldown({ series: drilldown.series, season: season.season_number })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Flat mode or episode-level drilldown
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-zinc-500">Loading…</p>;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-zinc-500">No items found. Run a library sync to populate the library.</p>;
|
||||
}
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const showPagination = isEpisodeLevel && totalPages > 1;
|
||||
const totalSelected = selected.size + selectedShows.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{drilldown && (
|
||||
<BreadcrumbNav
|
||||
series={"series" in drilldown ? drilldown.series : undefined}
|
||||
season={"season" in drilldown ? drilldown.season : undefined}
|
||||
onNavigate={target => {
|
||||
if (target === "root") onDrilldown(null);
|
||||
else if (target === "series" && drilldown && "series" in drilldown)
|
||||
onDrilldown({ series: drilldown.series });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{showPagination && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{totalSelected > 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">{totalSelected} selected</span>
|
||||
<ScheduleFromLibraryDialog selectedItems={selectedItems} selectedShows={selectedShows} />
|
||||
{selected.size > 0 && <AddToBlockDialog selectedItems={selectedItems} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"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}
|
||||
>
|
||||
<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>
|
||||
<div className="absolute left-2 top-2" onClick={e => { e.stopPropagation(); onToggle(); }}>
|
||||
<Checkbox checked={selected} className="border-white/50 bg-black/40" />
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
129
k-tv-frontend/app/(main)/library/components/library-sidebar.tsx
Normal file
129
k-tv-frontend/app/(main)/library/components/library-sidebar.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useCollections, useGenres } from "@/hooks/use-library";
|
||||
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";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
filter: LibrarySearchParams;
|
||||
onFilterChange: (next: Partial<LibrarySearchParams>) => void;
|
||||
viewMode: "grouped" | "flat";
|
||||
drilldown: null | { series: string } | { series: string; season: number };
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
const CONTENT_TYPES_ALL = [
|
||||
{ value: ALL, label: "All types" },
|
||||
{ value: "movie", label: "Movies" },
|
||||
{ value: "episode", label: "Episodes" },
|
||||
{ value: "short", label: "Shorts" },
|
||||
];
|
||||
|
||||
const CONTENT_TYPES_GROUPED = [
|
||||
{ value: ALL, label: "All types" },
|
||||
{ value: "movie", label: "Movies" },
|
||||
{ value: "short", label: "Shorts" },
|
||||
];
|
||||
|
||||
export function LibrarySidebar({ filter, onFilterChange, viewMode, drilldown, onBack }: Props) {
|
||||
const { data: collections } = useCollections(filter.provider);
|
||||
const { data: genres } = useGenres(filter.type, { provider: filter.provider });
|
||||
|
||||
if (drilldown !== null) {
|
||||
return (
|
||||
<aside className="w-56 shrink-0 border-r border-zinc-800 bg-zinc-950 p-4 flex flex-col gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back
|
||||
</button>
|
||||
<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>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const contentTypes = viewMode === "grouped" ? CONTENT_TYPES_GROUPED : CONTENT_TYPES_ALL;
|
||||
|
||||
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 ?? ALL} onValueChange={v => onFilterChange({ type: v === ALL ? undefined : v })}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{contentTypes.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"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, ShowSummary, Weekday, ProgrammingBlock, ScheduleConfig } from "@/lib/types";
|
||||
import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
selectedItems: LibraryItemFull[];
|
||||
selectedShows?: ShowSummary[];
|
||||
}
|
||||
|
||||
export function ScheduleFromLibraryDialog({ selectedItems, selectedShows }: 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 &&
|
||||
selectedItems.length > 0 &&
|
||||
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 startTimeFull = startTime.length === 5 ? `${startTime}:00` : startTime;
|
||||
|
||||
const hasShows = selectedShows && selectedShows.length > 0;
|
||||
|
||||
const newBlock: ProgrammingBlock = hasShows
|
||||
? {
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
name: selectedShows!.length === 1
|
||||
? `${selectedShows![0].series_name} — ${startTime}`
|
||||
: `${selectedShows!.length} shows — ${startTime}`,
|
||||
start_time: startTimeFull,
|
||||
duration_mins: durationMins,
|
||||
content: {
|
||||
type: "algorithmic",
|
||||
filter: {
|
||||
content_type: "episode",
|
||||
series_names: selectedShows!.map(s => s.series_name),
|
||||
genres: [],
|
||||
tags: [],
|
||||
collections: [],
|
||||
},
|
||||
strategy,
|
||||
},
|
||||
}
|
||||
: allSameSeries
|
||||
? {
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
name: `${selectedItems[0].series_name ?? "Series"} — ${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) },
|
||||
};
|
||||
|
||||
const updatedDayBlocks = { ...selectedChannel.schedule_config.day_blocks };
|
||||
for (const day of selectedDays) {
|
||||
updatedDayBlocks[day] = [...(updatedDayBlocks[day] ?? []), newBlock];
|
||||
}
|
||||
|
||||
const scheduleConfig: ScheduleConfig = { day_blocks: updatedDayBlocks };
|
||||
|
||||
await updateChannel.mutateAsync({
|
||||
id: channelId,
|
||||
data: { schedule_config: scheduleConfig },
|
||||
});
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const canConfirm = !!channelId && selectedDays.size > 0;
|
||||
const daysLabel = [...selectedDays].map(d => WEEKDAY_LABELS[d]).join(", ");
|
||||
const hasShows = selectedShows && selectedShows.length > 0;
|
||||
const contentLabel = hasShows
|
||||
? (selectedShows!.length === 1 ? selectedShows![0].series_name : `${selectedShows!.length} shows`)
|
||||
: `${selectedItems.length} item(s)`;
|
||||
const preview = canConfirm
|
||||
? `${selectedDays.size} block(s) of ${contentLabel} 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">
|
||||
{WEEKDAYS.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: "sequential" | "random" | "best_fit") => 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
k-tv-frontend/app/(main)/library/components/season-tile.tsx
Normal file
63
k-tv-frontend/app/(main)/library/components/season-tile.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Film } from "lucide-react";
|
||||
import type { SeasonSummary } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
season: SeasonSummary;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function SeasonTile({ season, selected, onToggle, onClick }: Props) {
|
||||
return (
|
||||
<div className="group relative flex flex-col overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 transition-colors hover:border-zinc-600">
|
||||
<button
|
||||
type="button"
|
||||
className="relative aspect-video w-full overflow-hidden bg-zinc-800"
|
||||
onClick={onClick}
|
||||
>
|
||||
{season.thumbnail_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={season.thumbnail_url}
|
||||
alt={`Season ${season.season_number}`}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Film className="h-10 w-10 text-zinc-600" />
|
||||
</div>
|
||||
)}
|
||||
{selected && (
|
||||
<div className="absolute inset-0 bg-blue-600/30 ring-2 ring-inset ring-blue-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col gap-1 p-2">
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-left text-sm font-medium text-zinc-100 hover:text-white"
|
||||
onClick={onClick}
|
||||
>
|
||||
Season {season.season_number}
|
||||
</button>
|
||||
<p className="text-xs text-zinc-500">{season.episode_count} episodes</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggle(); }}
|
||||
className={`absolute left-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded border text-xs font-bold transition-opacity ${
|
||||
selected
|
||||
? "border-blue-500 bg-blue-600 text-white opacity-100"
|
||||
: "border-zinc-600 bg-zinc-900/80 text-transparent opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={selected ? "Deselect" : "Select"}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
k-tv-frontend/app/(main)/library/components/show-tile.tsx
Normal file
75
k-tv-frontend/app/(main)/library/components/show-tile.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { Tv } from "lucide-react";
|
||||
import type { ShowSummary } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
show: ShowSummary;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function ShowTile({ show, selected, onToggle, onClick }: Props) {
|
||||
return (
|
||||
<div className="group relative flex flex-col overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 transition-colors hover:border-zinc-600">
|
||||
{/* Thumbnail area - clickable to drill in */}
|
||||
<button
|
||||
type="button"
|
||||
className="relative aspect-video w-full overflow-hidden bg-zinc-800"
|
||||
onClick={onClick}
|
||||
>
|
||||
{show.thumbnail_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={show.thumbnail_url}
|
||||
alt={show.series_name}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Tv className="h-10 w-10 text-zinc-600" />
|
||||
</div>
|
||||
)}
|
||||
{/* Selection overlay */}
|
||||
{selected && (
|
||||
<div className="absolute inset-0 bg-blue-600/30 ring-2 ring-inset ring-blue-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Info area */}
|
||||
<div className="flex flex-col gap-1 p-2">
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-left text-sm font-medium text-zinc-100 hover:text-white"
|
||||
onClick={onClick}
|
||||
title={show.series_name}
|
||||
>
|
||||
{show.series_name}
|
||||
</button>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{show.season_count} {show.season_count === 1 ? "season" : "seasons"} · {show.episode_count} eps
|
||||
</p>
|
||||
{show.genres.length > 0 && (
|
||||
<p className="truncate text-xs text-zinc-600">
|
||||
{show.genres.slice(0, 3).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Select checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggle(); }}
|
||||
className={`absolute left-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded border text-xs font-bold transition-opacity ${
|
||||
selected
|
||||
? "border-blue-500 bg-blue-600 text-white opacity-100"
|
||||
: "border-zinc-600 bg-zinc-900/80 text-transparent opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={selected ? "Deselect" : "Select"}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useLibrarySyncStatus } from "@/hooks/use-library-sync";
|
||||
|
||||
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 ? ` · synced ${new Date(s.finished_at).toLocaleTimeString()}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
k-tv-frontend/app/(main)/library/page.tsx
Normal file
134
k-tv-frontend/app/(main)/library/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LayoutGrid, FolderOpen } from "lucide-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 { Button } from "@/components/ui/button";
|
||||
import type { LibraryItemFull, ShowSummary } from "@/lib/types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
type Drilldown = null | { series: string } | { series: string; season: number };
|
||||
|
||||
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 [viewMode, setViewMode] = useState<"grouped" | "flat">("grouped");
|
||||
const [drilldown, setDrilldown] = useState<Drilldown>(null);
|
||||
const [selectedShowMap, setSelectedShowMap] = useState<Map<string, ShowSummary>>(new Map());
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectShow(show: ShowSummary) {
|
||||
setSelectedShowMap(prev => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(show.series_name)) next.delete(show.series_name);
|
||||
else next.set(show.series_name, show);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleDrilldown(next: Drilldown) {
|
||||
setDrilldown(next);
|
||||
setSelected(new Set());
|
||||
setSelectedShowMap(new Map());
|
||||
setPage(0);
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (!drilldown) return;
|
||||
if ("season" in drilldown) {
|
||||
// From episode level → go back to season level
|
||||
setDrilldown({ series: drilldown.series });
|
||||
} else {
|
||||
// From season level → go back to root
|
||||
setDrilldown(null);
|
||||
}
|
||||
setSelected(new Set());
|
||||
setPage(0);
|
||||
}
|
||||
|
||||
function handleViewModeToggle() {
|
||||
const next = viewMode === "grouped" ? "flat" : "grouped";
|
||||
setViewMode(next);
|
||||
if (next === "flat") setDrilldown(null);
|
||||
setSelected(new Set());
|
||||
setSelectedShowMap(new Map());
|
||||
}
|
||||
|
||||
const selectedItems = data?.items.filter(i => selected.has(i.id)) ?? [];
|
||||
const selectedShows = Array.from(selectedShowMap.values());
|
||||
const selectedShowNames = new Set(selectedShowMap.keys());
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<SyncStatusBar />
|
||||
<div className="flex items-center justify-end gap-2 border-b border-zinc-800 bg-zinc-950 px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-1.5 text-xs"
|
||||
onClick={handleViewModeToggle}
|
||||
>
|
||||
{viewMode === "grouped" ? (
|
||||
<>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
Flat view
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
Grouped view
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-1">
|
||||
<LibrarySidebar
|
||||
filter={filter}
|
||||
onFilterChange={handleFilterChange}
|
||||
viewMode={viewMode}
|
||||
drilldown={drilldown}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
<LibraryGrid
|
||||
items={data?.items ?? []}
|
||||
total={data?.total ?? 0}
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
isLoading={isLoading}
|
||||
selected={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onPageChange={setPage}
|
||||
selectedItems={selectedItems}
|
||||
viewMode={viewMode}
|
||||
drilldown={drilldown}
|
||||
onDrilldown={handleDrilldown}
|
||||
filter={filter}
|
||||
selectedShows={selectedShows}
|
||||
selectedShowNames={selectedShowNames}
|
||||
onToggleSelectShow={toggleSelectShow}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
k-tv-frontend/hooks/use-admin-settings.ts
Normal file
28
k-tv-frontend/hooks/use-admin-settings.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"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: AdminSettings) => {
|
||||
queryClient.setQueryData(["admin", "settings"], data);
|
||||
},
|
||||
});
|
||||
}
|
||||
34
k-tv-frontend/hooks/use-library-search.ts
Normal file
34
k-tv-frontend/hooks/use-library-search.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
"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,
|
||||
});
|
||||
}
|
||||
14
k-tv-frontend/hooks/use-library-seasons.ts
Normal file
14
k-tv-frontend/hooks/use-library-seasons.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuthContext } from "@/context/auth-context";
|
||||
import type { SeasonSummary } from "@/lib/types";
|
||||
|
||||
export function useLibrarySeasons(seriesName: string | null, provider?: string) {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery<SeasonSummary[]>({
|
||||
queryKey: ["library", "seasons", seriesName, provider],
|
||||
queryFn: () => api.library.seasons(token!, seriesName!, provider),
|
||||
enabled: !!token && !!seriesName,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
20
k-tv-frontend/hooks/use-library-shows.ts
Normal file
20
k-tv-frontend/hooks/use-library-shows.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuthContext } from "@/context/auth-context";
|
||||
import type { ShowSummary } from "@/lib/types";
|
||||
|
||||
export interface ShowsFilter {
|
||||
q?: string;
|
||||
provider?: string;
|
||||
genres?: string[];
|
||||
}
|
||||
|
||||
export function useLibraryShows(filter: ShowsFilter = {}) {
|
||||
const { token } = useAuthContext();
|
||||
return useQuery<ShowSummary[]>({
|
||||
queryKey: ["library", "shows", filter],
|
||||
queryFn: () => api.library.shows(token!, filter),
|
||||
enabled: !!token,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
28
k-tv-frontend/hooks/use-library-sync.ts
Normal file
28
k-tv-frontend/hooks/use-library-sync.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"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,
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,11 @@ import type {
|
||||
ProviderTestResult,
|
||||
ConfigSnapshot,
|
||||
ScheduleHistoryEntry,
|
||||
LibrarySyncLogEntry,
|
||||
PagedLibraryResponse,
|
||||
AdminSettings,
|
||||
ShowSummary,
|
||||
SeasonSummary,
|
||||
} from "@/lib/types";
|
||||
|
||||
const API_BASE =
|
||||
@@ -235,6 +240,51 @@ export const api = {
|
||||
if (provider) params.set("provider", provider);
|
||||
return request<LibraryItemResponse[]>(`/library/items?${params}`, { token });
|
||||
},
|
||||
|
||||
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 });
|
||||
},
|
||||
|
||||
shows: (token: string, filter?: { q?: string; provider?: string; genres?: string[] }): Promise<ShowSummary[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.q) params.set('q', filter.q);
|
||||
if (filter?.provider) params.set('provider', filter.provider);
|
||||
filter?.genres?.forEach(g => params.append('genres[]', g));
|
||||
const qs = params.toString();
|
||||
return request(`/library/shows${qs ? `?${qs}` : ''}`, { token });
|
||||
},
|
||||
|
||||
seasons: (token: string, seriesName: string, provider?: string): Promise<SeasonSummary[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (provider) params.set('provider', provider);
|
||||
const qs = params.toString();
|
||||
return request(`/library/shows/${encodeURIComponent(seriesName)}/seasons${qs ? `?${qs}` : ''}`, { token });
|
||||
},
|
||||
},
|
||||
|
||||
files: {
|
||||
@@ -266,6 +316,17 @@ export const api = {
|
||||
activity: (token: string) =>
|
||||
request<ActivityEvent[]>("/admin/activity", { token }),
|
||||
|
||||
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' },
|
||||
}),
|
||||
|
||||
providers: {
|
||||
getProviders: (token: string) =>
|
||||
request<ProviderConfig[]>("/admin/providers", { token }),
|
||||
|
||||
@@ -292,3 +292,47 @@ export interface CurrentBroadcastResponse {
|
||||
offset_secs: number;
|
||||
block_access_mode: AccessMode;
|
||||
}
|
||||
|
||||
// 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 ShowSummary {
|
||||
series_name: string;
|
||||
episode_count: number;
|
||||
season_count: number;
|
||||
thumbnail_url?: string | null;
|
||||
genres: string[];
|
||||
}
|
||||
|
||||
export interface SeasonSummary {
|
||||
season_number: number;
|
||||
episode_count: number;
|
||||
thumbnail_url?: 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user