docs: add library management design spec
This commit is contained in:
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.
|
||||
Reference in New Issue
Block a user