Compare commits

...

29 Commits

Author SHA1 Message Date
2698cb3ad4 cleanup: narrow SCTE-35 visibility to pub(crate), remove dead target_duration_secs 2026-07-12 15:31:59 +02:00
abd6bf4bc4 fix: add utoipa annotations to iCal export/import handlers 2026-07-12 15:30:45 +02:00
25ff9d2779 fix: rename recycle_policy column to rotation_policy + migration 2026-07-12 15:29:50 +02:00
2995aea606 fix: wire tags filter through LibrarySearchFilter to SQLite adapter 2026-07-12 15:29:06 +02:00
f52e024e98 refactor: deduplicate content_type/role string converters in MCP tools 2026-07-12 15:27:52 +02:00
5561b70e1b refactor: extract build_schedule helper, deduplicate generate/preview/preview_config 2026-07-12 15:26:30 +02:00
8de7d33007 fix: iCal duration parser tracks seconds, rounds up to minutes 2026-07-12 15:24:50 +02:00
8dabbdf280 fix: fill_weighted sorts by recency via playback history 2026-07-12 15:24:17 +02:00
9558f04f73 fix: add gap_filler to frontend ChannelResponse + UpdateChannelRequest 2026-07-12 15:21:20 +02:00
a823a79f6b fix: install curl in presentation image for healthcheck 2026-07-12 15:20:51 +02:00
5bc1e5e44b frontend: align types to backend, playout HLS, rm Jellyfin proxy (#12) 2026-07-12 15:11:50 +02:00
711f0e4411 docker: 3-binary deployment (presentation, worker, playout) 2026-07-12 14:56:38 +02:00
a7fa2ec4aa iCalendar import: parse_ical + API + MCP tool (#16) 2026-07-12 14:46:15 +02:00
6b0d060945 expand MCP tools: browse_library, stats, analyze, preview, suggest, config tools (#17) 2026-07-12 14:34:36 +02:00
6dbf7ab98c SCTE-35 markers + overlay metadata in playout (#10) 2026-07-12 14:33:22 +02:00
e27e2ab6d1 wire utoipa OpenAPI: #[utoipa::path] on all handlers, Scalar UI (#14) 2026-07-12 14:31:20 +02:00
b00272aceb iCalendar export: domain service + API + MCP tool (#15) 2026-07-12 14:27:20 +02:00
affd7f0223 merge feat/playout: HLS streaming, FFmpeg, multi-channel playout service (#9, #11) 2026-07-12 14:14:52 +02:00
8db70df0a0 merge feat/schedule-engine: interstitials, gap filler, mid-roll breaks (#3, #4, #8, #6) 2026-07-12 14:14:43 +02:00
c14e01e0f2 merge feat/library-sync: chapter extraction + MediaRole auto-detection (#5, #7) 2026-07-12 14:14:33 +02:00
874de68fb5 wire mid-roll breaks into schedule engine with chapter-aware splitting 2026-07-12 14:12:59 +02:00
79e975f057 wire gap_filler into schedule engine, API, MCP 2026-07-12 14:08:55 +02:00
8edd7a9c0b wire interstitial insertion into schedule engine resolve_block 2026-07-12 14:05:33 +02:00
8e4f724562 add integration tests for all 6 FillStrategy variants, role filter on LibrarySearchFilter 2026-07-12 14:02:45 +02:00
f21d70c559 MediaRole auto-detection + manual role API + sync wiring (#7)
role_detector: classify items as Interstitial by collection name/tag patterns.
Wire chapter extraction + role detection into sync adapter (worker + presentation).
SQLite: persist/read role column, migration.
PUT /library/items/{id}/role endpoint for manual override.
2026-07-12 14:00:28 +02:00
607d311375 chapter extraction via ffprobe during library sync (#5)
ffprobe module in adapter-common: parse JSON output into Vec<Chapter>,
should_probe_chapters gate (movie OR >45min), graceful failure.
Add set_chapters/set_role setters to MediaItem.
2026-07-12 14:00:17 +02:00
84dd05a8a6 multi-channel playout: concurrent tick, status endpoint, ffmpeg restart (#11) 2026-07-12 13:59:54 +02:00
1b3ecc10e1 add playout service: HLS streaming, FFmpeg, SegmentStore port (#9) 2026-07-12 13:58:09 +02:00
de7f3092d2 add parallel execution plan for roadmap issues 2026-07-12 07:54:01 +02:00
90 changed files with 5951 additions and 562 deletions

40
Cargo.lock generated
View File

@@ -25,6 +25,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"tokio",
"tracing", "tracing",
"uuid", "uuid",
] ]
@@ -1766,6 +1767,29 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "playout"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"bytes",
"chrono",
"domain",
"dotenvy",
"futures",
"serde",
"serde_json",
"thiserror",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -1795,6 +1819,7 @@ name = "presentation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"adapter-auth", "adapter-auth",
"adapter-common",
"adapter-event-publisher", "adapter-event-publisher",
"adapter-jellyfin", "adapter-jellyfin",
"adapter-local-files", "adapter-local-files",
@@ -1818,6 +1843,8 @@ dependencies = [
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"utoipa",
"utoipa-scalar",
"uuid", "uuid",
] ]
@@ -3004,6 +3031,18 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "utoipa-scalar"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719"
dependencies = [
"axum",
"serde",
"serde_json",
"utoipa",
]
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.4" version = "1.23.4"
@@ -3372,6 +3411,7 @@ name = "worker"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"adapter-auth", "adapter-auth",
"adapter-common",
"adapter-event-publisher", "adapter-event-publisher",
"adapter-jellyfin", "adapter-jellyfin",
"adapter-local-files", "adapter-local-files",

View File

@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common", "crates/adapters/sqlite", "crates/adapters/auth", "crates/adapters/jellyfin", "crates/adapters/local-files", "crates/adapters/event-publisher", "crates/presentation", "crates/worker", "crates/mcp"] members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common", "crates/adapters/sqlite", "crates/adapters/auth", "crates/adapters/jellyfin", "crates/adapters/local-files", "crates/adapters/event-publisher", "crates/presentation", "crates/worker", "crates/mcp", "crates/playout"]
exclude = ["k-tv-backend", "k-tv-frontend"] exclude = ["k-tv-backend", "k-tv-frontend"]
resolver = "2" resolver = "2"
@@ -24,6 +24,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
utoipa = { version = "5", features = ["chrono", "uuid"] } utoipa = { version = "5", features = ["chrono", "uuid"] }
utoipa-scalar = { version = "0.3", features = ["axum"] }
jsonwebtoken = "9" jsonwebtoken = "9"
# Internal crates # Internal crates

View File

@@ -11,24 +11,38 @@
# TRAEFIK_CERT_RESOLVER cert resolver name for TLS (default: letsencrypt) # TRAEFIK_CERT_RESOLVER cert resolver name for TLS (default: letsencrypt)
# FRONTEND_HOST public hostname for the frontend e.g. tv.example.com # FRONTEND_HOST public hostname for the frontend e.g. tv.example.com
# BACKEND_HOST public hostname for the backend API e.g. tv-api.example.com # BACKEND_HOST public hostname for the backend API e.g. tv-api.example.com
# PLAYOUT_HOST public hostname for playout streams e.g. tv-playout.example.com
# #
# Remember: NEXT_PUBLIC_API_URL in .env must be the *public* backend URL, # Remember: NEXT_PUBLIC_API_URL in .env must be the *public* backend URL,
# e.g. https://tv-api.example.com/api/v1, and you must rebuild after changing it. # e.g. https://tv-api.example.com/api/v1, and you must rebuild after changing it.
services: services:
backend: presentation:
ports: [] # Traefik handles ingress; no direct port exposure needed ports: []
networks: networks:
- default - default
- traefik - traefik
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}" - "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}"
- "traefik.http.routers.ktv-backend.rule=Host(`${BACKEND_HOST}`)" - "traefik.http.routers.ktv-presentation.rule=Host(`${BACKEND_HOST}`)"
- "traefik.http.routers.ktv-backend.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" - "traefik.http.routers.ktv-presentation.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.ktv-backend.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}" - "traefik.http.routers.ktv-presentation.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
- "traefik.http.services.ktv-backend.loadbalancer.server.port=3000" - "traefik.http.services.ktv-presentation.loadbalancer.server.port=3000"
playout:
ports: []
networks:
- default
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}"
- "traefik.http.routers.ktv-playout.rule=Host(`${PLAYOUT_HOST}`)"
- "traefik.http.routers.ktv-playout.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.ktv-playout.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
- "traefik.http.services.ktv-playout.loadbalancer.server.port=9090"
frontend: frontend:
ports: [] ports: []

View File

@@ -1,19 +1,19 @@
services: services:
# ── Backend (Rust / Axum) ────────────────────────────────────────────────── # ── Presentation (Rust / Axum — HTTP API) ────────────────────────────────
backend: presentation:
build: ./k-tv-backend build:
context: ./k-tv-backend
target: presentation
image: registry.gabrielkaszewski.dev/k-tv-presentation:latest
ports: ports:
- "${BACKEND_PORT:-3000}:3000" - "${BACKEND_PORT:-3000}:3000"
environment: environment:
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=3000 - PORT=3000
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc - DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
# Allow requests from the browser (the user-facing frontend URL)
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS} - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS}
# Auth — generate with: openssl rand -hex 32
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
# Cookie secret — generate with: openssl rand -base64 64
- COOKIE_SECRET=${COOKIE_SECRET} - COOKIE_SECRET=${COOKIE_SECRET}
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24} - JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24}
- SECURE_COOKIE=${SECURE_COOKIE:-false} - SECURE_COOKIE=${SECURE_COOKIE:-false}
@@ -21,7 +21,6 @@ services:
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
- DB_MAX_CONNECTIONS=${DB_MAX_CONNECTIONS:-5} - DB_MAX_CONNECTIONS=${DB_MAX_CONNECTIONS:-5}
- DB_MIN_CONNECTIONS=${DB_MIN_CONNECTIONS:-1} - DB_MIN_CONNECTIONS=${DB_MIN_CONNECTIONS:-1}
# Jellyfin — all three required for schedule generation
- JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL} - JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL}
- JELLYFIN_API_KEY=${JELLYFIN_API_KEY} - JELLYFIN_API_KEY=${JELLYFIN_API_KEY}
- JELLYFIN_USER_ID=${JELLYFIN_USER_ID} - JELLYFIN_USER_ID=${JELLYFIN_USER_ID}
@@ -34,40 +33,60 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
# ── Worker (background jobs) ─────────────────────────────────────────────
worker:
build:
context: ./k-tv-backend
target: worker
image: registry.gabrielkaszewski.dev/k-tv-worker:latest
environment:
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
- JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL}
- JELLYFIN_API_KEY=${JELLYFIN_API_KEY}
- JELLYFIN_USER_ID=${JELLYFIN_USER_ID}
volumes:
- backend_data:/app/data
depends_on:
presentation:
condition: service_healthy
restart: unless-stopped
# ── Playout (HLS streaming) ──────────────────────────────────────────────
playout:
build:
context: ./k-tv-backend
target: playout
image: registry.gabrielkaszewski.dev/k-tv-playout:latest
ports:
- "${PLAYOUT_PORT:-9090}:9090"
environment:
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
- PLAYOUT_LISTEN_ADDR=0.0.0.0:9090
- PLAYOUT_STORAGE_PATH=/tmp/k-tv-playout
- PLAYOUT_SEGMENT_DURATION=${PLAYOUT_SEGMENT_DURATION:-6}
volumes:
- backend_data:/app/data
depends_on:
presentation:
condition: service_healthy
restart: unless-stopped
# ── Frontend (Next.js) ──────────────────────────────────────────────────── # ── Frontend (Next.js) ────────────────────────────────────────────────────
frontend: frontend:
build: build:
context: ./k-tv-frontend context: ./k-tv-frontend
args: args:
# Browser-visible backend URL — baked into the client bundle at build time.
# Rebuild the image after changing this.
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000/api/v1} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000/api/v1}
NEXT_PUBLIC_PLAYOUT_URL: ${NEXT_PUBLIC_PLAYOUT_URL:-http://localhost:9090}
image: registry.gabrielkaszewski.dev/k-tv-frontend:latest
ports: ports:
- "${FRONTEND_PORT:-3001}:3001" - "${FRONTEND_PORT:-3001}:3001"
environment: environment:
# Server-side API URL — uses Docker's internal network, never exposed. API_URL: http://presentation:3000/api/v1
# Next.js API routes (e.g. /api/stream/[channelId]) use this.
API_URL: http://backend:3000/api/v1
depends_on: depends_on:
backend: presentation:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
volumes: volumes:
backend_data: backend_data:
# ── Optional: PostgreSQL ───────────────────────────────────────────────────
# Uncomment the db service and set DATABASE_URL in backend's environment:
# DATABASE_URL: postgres://ktv:${POSTGRES_PASSWORD}@db:5432/ktv
#
# db:
# image: postgres:16-alpine
# environment:
# POSTGRES_USER: ktv
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# POSTGRES_DB: ktv
# volumes:
# - db_data:/var/lib/postgresql/data
# restart: unless-stopped
#
# db_data:

View File

@@ -11,3 +11,4 @@ uuid = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,180 @@
use domain::{Chapter, ContentType, SourceUri};
use serde::Deserialize;
const CHAPTER_PROBE_MIN_DURATION_SECS: u32 = 2700;
#[derive(Deserialize)]
struct FfprobeOutput {
#[serde(default)]
chapters: Vec<FfprobeChapter>,
}
#[derive(Deserialize)]
struct FfprobeChapter {
#[serde(default)]
start_time: String,
#[serde(default)]
end_time: String,
#[serde(default)]
tags: Option<FfprobeChapterTags>,
}
#[derive(Deserialize)]
struct FfprobeChapterTags {
title: Option<String>,
}
pub fn parse_chapters_json(json: &str) -> Vec<Chapter> {
let output: FfprobeOutput = match serde_json::from_str(json) {
Ok(o) => o,
Err(_) => return Vec::new(),
};
output
.chapters
.into_iter()
.map(|c| {
let title = c.tags.and_then(|t| t.title);
let start_secs = c.start_time.parse::<f64>().unwrap_or(0.0);
let end_secs = c.end_time.parse::<f64>().unwrap_or(0.0);
Chapter::new(title, start_secs, end_secs)
})
.collect()
}
pub fn should_probe_chapters(content_type: &ContentType, duration_secs: u32) -> bool {
matches!(content_type, ContentType::Movie) || duration_secs > CHAPTER_PROBE_MIN_DURATION_SECS
}
pub async fn extract_chapters(source_uri: &SourceUri) -> Vec<Chapter> {
let path = match source_uri {
SourceUri::FilePath { path } => path.clone(),
SourceUri::NetworkUrl { url } => url.clone(),
};
let result = tokio::process::Command::new("ffprobe")
.args([
"-v",
"quiet",
"-print_format",
"json",
"-show_chapters",
&path,
])
.output()
.await;
match result {
Ok(output) if output.status.success() => {
let json = String::from_utf8_lossy(&output.stdout);
parse_chapters_json(&json)
}
Ok(output) => {
tracing::warn!(
path = %path,
stderr = %String::from_utf8_lossy(&output.stderr),
"ffprobe exited with non-zero status"
);
Vec::new()
}
Err(e) => {
tracing::warn!(error = %e, "ffprobe not available or failed to execute");
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ffprobe_json_with_chapters() {
let json = r#"{
"chapters": [
{
"id": 0,
"time_base": "1/1000",
"start": 0,
"start_time": "0.000000",
"end": 300000,
"end_time": "300.000000",
"tags": { "title": "Opening" }
},
{
"id": 1,
"time_base": "1/1000",
"start": 300000,
"start_time": "300.000000",
"end": 1800000,
"end_time": "1800.000000",
"tags": { "title": "Main Feature" }
},
{
"id": 2,
"time_base": "1/1000",
"start": 1800000,
"start_time": "1800.000000",
"end": 2100000,
"end_time": "2100.000000"
}
]
}"#;
let chapters = parse_chapters_json(json);
assert_eq!(chapters.len(), 3);
assert_eq!(chapters[0].title(), Some("Opening"));
assert!((chapters[0].start_secs() - 0.0).abs() < f64::EPSILON);
assert!((chapters[0].end_secs() - 300.0).abs() < f64::EPSILON);
assert_eq!(chapters[1].title(), Some("Main Feature"));
assert!((chapters[1].start_secs() - 300.0).abs() < f64::EPSILON);
assert!((chapters[1].end_secs() - 1800.0).abs() < f64::EPSILON);
assert_eq!(chapters[2].title(), None);
assert!((chapters[2].start_secs() - 1800.0).abs() < f64::EPSILON);
assert!((chapters[2].end_secs() - 2100.0).abs() < f64::EPSILON);
}
#[test]
fn parse_ffprobe_json_no_chapters() {
let json = r#"{ "chapters": [] }"#;
let chapters = parse_chapters_json(json);
assert!(chapters.is_empty());
}
#[test]
fn parse_ffprobe_json_missing_chapters_key() {
let json = r#"{}"#;
let chapters = parse_chapters_json(json);
assert!(chapters.is_empty());
}
#[test]
fn parse_ffprobe_json_invalid() {
let chapters = parse_chapters_json("not json");
assert!(chapters.is_empty());
}
#[test]
fn short_items_skip_probe() {
assert!(!should_probe_chapters(&ContentType::Episode, 1800));
assert!(!should_probe_chapters(&ContentType::Short, 300));
}
#[test]
fn movie_always_probed() {
assert!(should_probe_chapters(&ContentType::Movie, 600));
assert!(should_probe_chapters(&ContentType::Movie, 7200));
}
#[test]
fn long_episode_probed() {
assert!(should_probe_chapters(&ContentType::Episode, 3600));
}
#[test]
fn episode_under_threshold_skipped() {
assert!(!should_probe_chapters(&ContentType::Episode, 2700));
}
}

View File

@@ -1,3 +1,6 @@
pub mod ffprobe;
pub mod role_detector;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{DomainError, RotationPolicy, ScheduleConfig, ScheduleConfigCompat}; use domain::{DomainError, RotationPolicy, ScheduleConfig, ScheduleConfigCompat};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;

View File

@@ -0,0 +1,158 @@
use domain::{MediaItem, MediaRole};
#[derive(Debug, Clone)]
pub struct RoleDetectionConfig {
pub interstitial_collection_patterns: Vec<String>,
pub interstitial_tag_patterns: Vec<String>,
}
impl Default for RoleDetectionConfig {
fn default() -> Self {
Self {
interstitial_collection_patterns: vec![
"bumper".into(),
"bumpers".into(),
"ad".into(),
"ads".into(),
"interstitial".into(),
"interstitials".into(),
"promo".into(),
"promos".into(),
"ident".into(),
"idents".into(),
],
interstitial_tag_patterns: vec![
"bumper".into(),
"interstitial".into(),
"ad".into(),
"promo".into(),
"ident".into(),
],
}
}
}
pub fn detect_role(item: &MediaItem, config: &RoleDetectionConfig) -> MediaRole {
if matches_collection_pattern(item, &config.interstitial_collection_patterns) {
return MediaRole::Interstitial;
}
if matches_tag_pattern(item, &config.interstitial_tag_patterns) {
return MediaRole::Interstitial;
}
MediaRole::Program
}
fn matches_collection_pattern(item: &MediaItem, patterns: &[String]) -> bool {
let collection_name = match item.collection_name() {
Some(name) => name.to_lowercase(),
None => return false,
};
patterns
.iter()
.any(|pattern| collection_name == pattern.to_lowercase())
}
fn matches_tag_pattern(item: &MediaItem, patterns: &[String]) -> bool {
item.tags().iter().any(|tag| {
let lower_tag = tag.to_lowercase();
patterns
.iter()
.any(|pattern| lower_tag == pattern.to_lowercase())
})
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{ContentType, MediaItemId, MediaItemRow};
fn make_item(
collection_name: Option<&str>,
tags: Vec<&str>,
) -> MediaItem {
MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new("test::1"),
provider_id: "test".into(),
external_id: "1".into(),
title: "Test Item".into(),
content_type: ContentType::Movie,
duration_secs: 3600,
description: None,
series_name: None,
season_number: None,
episode_number: None,
year: None,
genres: vec![],
tags: tags.into_iter().map(String::from).collect(),
collection_id: None,
collection_name: collection_name.map(String::from),
collection_type: None,
thumbnail_url: None,
synced_at: None,
role: MediaRole::default(),
chapters: vec![],
})
}
#[test]
fn item_from_bumpers_collection_gets_interstitial() {
let item = make_item(Some("Bumpers"), vec![]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
#[test]
fn item_with_bumper_tag_gets_interstitial() {
let item = make_item(None, vec!["bumper"]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
#[test]
fn item_from_ads_collection_gets_interstitial() {
let item = make_item(Some("Ads"), vec![]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
#[test]
fn normal_item_from_movies_gets_program() {
let item = make_item(Some("Movies"), vec![]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Program);
}
#[test]
fn item_with_no_collection_or_tags_gets_program() {
let item = make_item(None, vec![]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Program);
}
#[test]
fn case_insensitive_collection_match() {
let item = make_item(Some("INTERSTITIALS"), vec![]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
#[test]
fn case_insensitive_tag_match() {
let item = make_item(None, vec!["PROMO"]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
#[test]
fn custom_config_patterns() {
let item = make_item(Some("Station IDs"), vec![]);
let config = RoleDetectionConfig {
interstitial_collection_patterns: vec!["station ids".into()],
interstitial_tag_patterns: vec![],
};
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
}

View File

@@ -23,7 +23,7 @@ impl SqliteChannelRepository {
} }
} }
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy AS rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, gap_filler, created_at, updated_at"; const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, gap_filler, created_at, updated_at";
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
struct ChannelRow { struct ChannelRow {
@@ -126,7 +126,7 @@ impl ChannelCommand for SqliteChannelRepository {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO channels INSERT INTO channels
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, (id, owner_id, name, description, timezone, schedule_config, rotation_policy,
auto_schedule, access_mode, logo, logo_position, auto_schedule, access_mode, logo, logo_position,
logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template,
webhook_headers, gap_filler, created_at, updated_at) webhook_headers, gap_filler, created_at, updated_at)
@@ -136,7 +136,7 @@ impl ChannelCommand for SqliteChannelRepository {
description = excluded.description, description = excluded.description,
timezone = excluded.timezone, timezone = excluded.timezone,
schedule_config = excluded.schedule_config, schedule_config = excluded.schedule_config,
recycle_policy = excluded.recycle_policy, rotation_policy = excluded.rotation_policy,
auto_schedule = excluded.auto_schedule, auto_schedule = excluded.auto_schedule,
access_mode = excluded.access_mode, access_mode = excluded.access_mode,
logo = excluded.logo, logo = excluded.logo,

View File

@@ -1,7 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use adapter_common::{content_type_str, parse_content_type, parse_genres_blob}; use adapter_common::{content_type_str, parse_content_type, parse_enum_or_default, parse_genres_blob, serialize_enum_as_string};
use domain::{ use domain::{
ports::library::{LibraryCommand, LibraryQuery}, ports::library::{LibraryCommand, LibraryQuery},
ContentType, DomainError, DomainResult, LibraryCollection, ContentType, DomainError, DomainResult, LibraryCollection,
@@ -40,10 +40,15 @@ struct LibraryItemRow {
thumbnail_url: Option<String>, thumbnail_url: Option<String>,
synced_at: String, synced_at: String,
chapters: Option<String>, chapters: Option<String>,
role: Option<String>,
} }
impl LibraryItemRow { impl LibraryItemRow {
fn into_media_item(self) -> MediaItem { fn into_media_item(self) -> MediaItem {
let role: MediaRole = self
.role
.map(parse_enum_or_default)
.unwrap_or_default();
MediaItem::from_persistence(DomainMediaItemRow { MediaItem::from_persistence(DomainMediaItemRow {
id: domain::MediaItemId::new(&self.id), id: domain::MediaItemId::new(&self.id),
provider_id: self.provider_id, provider_id: self.provider_id,
@@ -63,7 +68,7 @@ impl LibraryItemRow {
collection_type: self.collection_type, collection_type: self.collection_type,
thumbnail_url: self.thumbnail_url, thumbnail_url: self.thumbnail_url,
synced_at: Some(self.synced_at), synced_at: Some(self.synced_at),
role: MediaRole::default(), role,
chapters: self chapters: self
.chapters .chapters
.as_deref() .as_deref()
@@ -116,12 +121,14 @@ impl LibraryCommand for SqliteLibraryRepository {
Some(serde_json::to_string(item.chapters()).unwrap_or_default()) Some(serde_json::to_string(item.chapters()).unwrap_or_default())
}; };
let role_str = serialize_enum_as_string(item.role(), "program");
sqlx::query( sqlx::query(
"INSERT OR REPLACE INTO library_items "INSERT OR REPLACE INTO library_items
(id, provider_id, external_id, title, content_type, duration_secs, (id, provider_id, external_id, title, content_type, duration_secs,
series_name, season_number, episode_number, year, genres, tags, series_name, season_number, episode_number, year, genres, tags,
collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters) collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters, role)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
) )
.bind(item.id().value()) .bind(item.id().value())
.bind(item.provider_id()) .bind(item.provider_id())
@@ -141,6 +148,7 @@ impl LibraryCommand for SqliteLibraryRepository {
.bind(item.thumbnail_url()) .bind(item.thumbnail_url())
.bind(item.synced_at().unwrap_or("")) .bind(item.synced_at().unwrap_or(""))
.bind(&chapters_json) .bind(&chapters_json)
.bind(&role_str)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
@@ -151,6 +159,22 @@ impl LibraryCommand for SqliteLibraryRepository {
.map_err(|e| DomainError::InfrastructureError(e.to_string())) .map_err(|e| DomainError::InfrastructureError(e.to_string()))
} }
async fn update_role(&self, item_id: &str, role: MediaRole) -> DomainResult<()> {
let role_str = serialize_enum_as_string(&role, "program");
let rows = sqlx::query("UPDATE library_items SET role = ? WHERE id = ?")
.bind(&role_str)
.bind(item_id)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
if rows.rows_affected() == 0 {
return Err(DomainError::NotFound(format!(
"Library item {item_id} not found"
)));
}
Ok(())
}
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> { async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> {
sqlx::query("DELETE FROM library_items WHERE provider_id = ?") sqlx::query("DELETE FROM library_items WHERE provider_id = ?")
.bind(provider_id) .bind(provider_id)
@@ -249,6 +273,19 @@ impl LibraryQuery for SqliteLibraryRepository {
.collect(); .collect();
conditions.push(format!("({})", genre_conditions.join(" OR "))); conditions.push(format!("({})", genre_conditions.join(" OR ")));
} }
if !filter.tags().is_empty() {
let tag_conditions: Vec<String> = filter
.tags()
.iter()
.map(|t| {
format!(
"EXISTS (SELECT 1 FROM json_each(library_items.tags) WHERE LOWER(value) = LOWER('{}'))",
t.replace('\'', "''")
)
})
.collect();
conditions.push(format!("({})", tag_conditions.join(" OR ")));
}
if let Some(sn) = filter.season_number() { if let Some(sn) = filter.season_number() {
conditions.push(format!("season_number = {}", sn)); conditions.push(format!("season_number = {}", sn));
} }

View File

@@ -1,6 +1,6 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::{IntoParams, ToSchema};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -17,7 +17,7 @@ pub struct ActivityEventResponse {
pub channel_id: Option<Uuid>, pub channel_id: Option<Uuid>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ActivityLogParams { pub struct ActivityLogParams {
pub limit: Option<u32>, pub limit: Option<u32>,
} }

View File

@@ -35,6 +35,8 @@ pub struct UpdateChannelRequest {
pub webhook_poll_interval_secs: Option<u32>, pub webhook_poll_interval_secs: Option<u32>,
pub webhook_body_template: Option<Option<String>>, pub webhook_body_template: Option<Option<String>>,
pub webhook_headers: Option<Option<String>>, pub webhook_headers: Option<Option<String>>,
#[schema(value_type = Option<Option<Object>>)]
pub gap_filler: Option<Option<domain::MediaFilter>>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -55,6 +57,8 @@ pub struct ChannelResponse {
pub webhook_poll_interval_secs: u32, pub webhook_poll_interval_secs: u32,
pub webhook_body_template: Option<String>, pub webhook_body_template: Option<String>,
pub webhook_headers: Option<String>, pub webhook_headers: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gap_filler: Option<serde_json::Value>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@@ -78,6 +82,7 @@ impl From<domain::Channel> for ChannelResponse {
webhook_poll_interval_secs: c.webhook_poll_interval_secs(), webhook_poll_interval_secs: c.webhook_poll_interval_secs(),
webhook_body_template: c.webhook_body_template().map(|s| s.to_string()), webhook_body_template: c.webhook_body_template().map(|s| s.to_string()),
webhook_headers: c.webhook_headers().map(|s| s.to_string()), webhook_headers: c.webhook_headers().map(|s| s.to_string()),
gap_filler: c.gap_filler().map(|f| serde_json::to_value(f).unwrap_or_default()),
created_at: c.created_at(), created_at: c.created_at(),
updated_at: c.updated_at(), updated_at: c.updated_at(),
} }

View File

@@ -1,7 +1,7 @@
use serde::Deserialize; use serde::Deserialize;
use utoipa::ToSchema; use utoipa::{IntoParams, ToSchema};
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct IptvParams { pub struct IptvParams {
pub token: Option<String>, pub token: Option<String>,
} }

View File

@@ -20,7 +20,7 @@ pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
pub use iptv::IptvParams; pub use iptv::IptvParams;
pub use library::{ pub use library::{
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam, CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam,
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry, UpdateRoleRequest,
}; };
pub use providers::{ProviderConfigRequest, ProviderConfigResponse}; pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
pub use schedule::{ pub use schedule::{

View File

@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::{IntoParams, ToSchema};
use crate::common::enum_to_string; use crate::common::enum_to_string;
@@ -22,6 +22,7 @@ pub struct LibraryItemResponse {
pub collection_type: Option<String>, pub collection_type: Option<String>,
pub thumbnail_url: Option<String>, pub thumbnail_url: Option<String>,
pub synced_at: Option<String>, pub synced_at: Option<String>,
pub role: String,
} }
impl From<domain::MediaItem> for LibraryItemResponse { impl From<domain::MediaItem> for LibraryItemResponse {
@@ -44,6 +45,7 @@ impl From<domain::MediaItem> for LibraryItemResponse {
collection_type: i.collection_type().map(|s| s.to_string()), collection_type: i.collection_type().map(|s| s.to_string()),
thumbnail_url: i.thumbnail_url().map(|s| s.to_string()), thumbnail_url: i.thumbnail_url().map(|s| s.to_string()),
synced_at: i.synced_at().map(|s| s.to_string()), synced_at: i.synced_at().map(|s| s.to_string()),
role: enum_to_string(i.role()),
} }
} }
} }
@@ -126,7 +128,7 @@ impl From<domain::LibrarySyncLogEntry> for SyncStatusEntry {
} }
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct LibrarySearchParams { pub struct LibrarySearchParams {
pub provider: Option<String>, pub provider: Option<String>,
pub content_type: Option<String>, pub content_type: Option<String>,
@@ -142,12 +144,12 @@ pub struct LibrarySearchParams {
pub limit: Option<u32>, pub limit: Option<u32>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ProviderParam { pub struct ProviderParam {
pub provider: Option<String>, pub provider: Option<String>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ShowsParams { pub struct ShowsParams {
pub provider: Option<String>, pub provider: Option<String>,
pub search_term: Option<String>, pub search_term: Option<String>,
@@ -155,14 +157,19 @@ pub struct ShowsParams {
pub genres: Vec<String>, pub genres: Vec<String>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct SeasonsParams { pub struct SeasonsParams {
pub series_name: String, pub series_name: String,
pub provider: Option<String>, pub provider: Option<String>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct GenresParams { pub struct GenresParams {
pub content_type: Option<String>, pub content_type: Option<String>,
pub provider: Option<String>, pub provider: Option<String>,
} }
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateRoleRequest {
pub role: String,
}

View File

@@ -1,5 +1,5 @@
use domain::models::ScheduleConfig; use domain::models::ScheduleConfig;
use domain::value_objects::{ChannelId, RotationPolicy, UserId}; use domain::value_objects::{ChannelId, MediaFilter, RotationPolicy, UserId};
pub struct CreateChannelCommand { pub struct CreateChannelCommand {
pub owner_id: UserId, pub owner_id: UserId,
@@ -16,6 +16,7 @@ pub struct UpdateChannelCommand {
pub schedule_config: Option<ScheduleConfig>, pub schedule_config: Option<ScheduleConfig>,
pub rotation_policy: Option<RotationPolicy>, pub rotation_policy: Option<RotationPolicy>,
pub auto_schedule: Option<bool>, pub auto_schedule: Option<bool>,
pub gap_filler: Option<Option<MediaFilter>>,
} }
pub struct DeleteChannelCommand { pub struct DeleteChannelCommand {

View File

@@ -45,6 +45,7 @@ async fn updates_channel_name() {
schedule_config: None, schedule_config: None,
rotation_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
gap_filler: None,
}, },
) )
.await .await
@@ -82,6 +83,7 @@ async fn update_fails_if_not_owner() {
schedule_config: None, schedule_config: None,
rotation_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
gap_filler: None,
}, },
) )
.await; .await;
@@ -108,6 +110,7 @@ async fn update_nonexistent_channel_returns_not_found() {
schedule_config: None, schedule_config: None,
rotation_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
gap_filler: None,
}, },
) )
.await; .await;
@@ -148,6 +151,7 @@ async fn update_config_creates_snapshot() {
schedule_config: Some(new_config), schedule_config: Some(new_config),
rotation_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
gap_filler: None,
}, },
) )
.await .await
@@ -187,6 +191,7 @@ async fn update_without_config_skips_snapshot() {
schedule_config: None, schedule_config: None,
rotation_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
gap_filler: None,
}, },
) )
.await .await
@@ -225,6 +230,7 @@ async fn update_description_clear() {
schedule_config: None, schedule_config: None,
rotation_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
gap_filler: None,
}, },
) )
.await .await
@@ -243,6 +249,7 @@ async fn update_description_clear() {
schedule_config: None, schedule_config: None,
rotation_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
gap_filler: None,
}, },
) )
.await .await

View File

@@ -35,6 +35,9 @@ pub async fn execute(deps: &ChannelCommandDeps, cmd: UpdateChannelCommand) -> Do
if let Some(auto) = cmd.auto_schedule { if let Some(auto) = cmd.auto_schedule {
channel.set_auto_schedule(auto); channel.set_auto_schedule(auto);
} }
if let Some(gap_filler) = cmd.gap_filler {
channel.set_gap_filler(gap_filler);
}
deps.channel_command.save(&channel).await?; deps.channel_command.save(&channel).await?;

View File

@@ -10,5 +10,5 @@ pub mod value_objects;
pub use errors::{DomainError, DomainResult}; pub use errors::{DomainError, DomainResult};
pub use events::DomainEvent; pub use events::DomainEvent;
pub use models::*; pub use models::*;
pub use services::{generate_m3u, generate_xmltv, ScheduleEngineService}; pub use services::{generate_ical, generate_m3u, generate_xmltv, parse_ical, ScheduleEngineService};
pub use value_objects::*; pub use value_objects::*;

View File

@@ -282,6 +282,13 @@ impl ScheduleConfig {
&self.day_blocks &self.day_blocks
} }
pub fn find_block_mut(&mut self, block_id: BlockId) -> Option<&mut ProgrammingBlock> {
self.day_blocks
.values_mut()
.flatten()
.find(|b| b.id() == block_id)
}
pub fn insert_day(&mut self, day: Weekday, blocks: Vec<ProgrammingBlock>) { pub fn insert_day(&mut self, day: Weekday, blocks: Vec<ProgrammingBlock>) {
self.day_blocks.insert(day, blocks); self.day_blocks.insert(day, blocks);
} }
@@ -370,6 +377,28 @@ impl ProgrammingBlock {
} }
} }
pub fn from_parts(
id: BlockId,
name: impl Into<String>,
start_time: NaiveTime,
duration_mins: u32,
content: BlockContent,
interstitial_rule: Option<InterstitialRule>,
mid_roll_rule: Option<MidRollRule>,
) -> Self {
Self {
id,
name: name.into(),
start_time,
duration_mins,
content,
loop_on_finish: true,
ignore_rotation_policy: false,
interstitial_rule,
mid_roll_rule,
}
}
pub fn new_manual( pub fn new_manual(
name: impl Into<String>, name: impl Into<String>,
start_time: NaiveTime, start_time: NaiveTime,
@@ -426,6 +455,24 @@ impl ProgrammingBlock {
pub fn mid_roll_rule(&self) -> Option<&MidRollRule> { pub fn mid_roll_rule(&self) -> Option<&MidRollRule> {
self.mid_roll_rule.as_ref() self.mid_roll_rule.as_ref()
} }
pub fn with_interstitial_rule(mut self, rule: InterstitialRule) -> Self {
self.interstitial_rule = Some(rule);
self
}
pub fn with_mid_roll_rule(mut self, rule: MidRollRule) -> Self {
self.mid_roll_rule = Some(rule);
self
}
pub fn set_interstitial_rule(&mut self, rule: Option<InterstitialRule>) {
self.interstitial_rule = rule;
}
pub fn set_mid_roll_rule(&mut self, rule: Option<MidRollRule>) {
self.mid_roll_rule = rule;
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -226,6 +226,14 @@ impl MediaItem {
pub fn chapters(&self) -> &[Chapter] { pub fn chapters(&self) -> &[Chapter] {
&self.chapters &self.chapters
} }
pub fn set_chapters(&mut self, chapters: Vec<Chapter>) {
self.chapters = chapters;
}
pub fn set_role(&mut self, role: MediaRole) {
self.role = role;
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -5,7 +5,7 @@ use crate::models::{
LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult, MediaItem, LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult, MediaItem,
SeasonSummary, ShowSummary, SeasonSummary, ShowSummary,
}; };
use crate::value_objects::{ContentType, LibrarySearchFilter}; use crate::value_objects::{ContentType, LibrarySearchFilter, MediaRole};
use super::media::IMediaProvider; use super::media::IMediaProvider;
@@ -18,6 +18,8 @@ pub trait LibraryCommand: Send + Sync {
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64>; 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 log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>;
async fn update_role(&self, item_id: &str, role: MediaRole) -> DomainResult<()>;
} }
#[async_trait] #[async_trait]

View File

@@ -0,0 +1,342 @@
use std::collections::HashMap;
use chrono::NaiveTime;
use crate::errors::DomainResult;
use crate::models::{BlockContent, ProgrammingBlock, ScheduleConfig};
use crate::value_objects::{BlockId, FillStrategy, InterstitialRule, MediaFilter, MidRollRule, Weekday};
pub fn generate_ical(channel_name: &str, timezone: &str, config: &ScheduleConfig) -> String {
let mut out = String::new();
out.push_str("BEGIN:VCALENDAR\r\n");
out.push_str("VERSION:2.0\r\n");
out.push_str("PRODID:-//K-TV//Schedule Export//EN\r\n");
out.push_str("CALSCALE:GREGORIAN\r\n");
out.push_str(&format!("X-WR-CALNAME:{}\r\n", fold_line(channel_name)));
out.push_str(&format!("X-WR-TIMEZONE:{}\r\n", timezone));
for day in Weekday::all() {
for block in config.blocks_for(day) {
write_vevent(&mut out, day, block);
}
}
out.push_str("END:VCALENDAR\r\n");
out
}
fn write_vevent(out: &mut String, day: Weekday, block: &ProgrammingBlock) {
let byday = weekday_to_byday(day);
let hours = block.start_time().format("%H%M%S");
let dur = format_duration(block.duration_mins());
out.push_str("BEGIN:VEVENT\r\n");
out.push_str(&format!("UID:{}\r\n", block.id()));
out.push_str(&format!("DTSTART:{}\r\n", hours));
out.push_str(&format!("DURATION:{}\r\n", dur));
out.push_str(&format!("RRULE:FREQ=WEEKLY;BYDAY={}\r\n", byday));
out.push_str(&format!("SUMMARY:{}\r\n", fold_line(block.name())));
let content_json = serde_json::to_string(block.content()).unwrap_or_default();
write_folded_property(out, "X-KTV-CONTENT", &content_json);
if let BlockContent::Algorithmic { strategy, .. } = block.content() {
let strategy_name = serde_json::to_string(strategy)
.unwrap_or_default()
.trim_matches('"')
.to_string();
out.push_str(&format!("X-KTV-STRATEGY:{}\r\n", strategy_name));
}
if let Some(rule) = block.interstitial_rule() {
let json = serde_json::to_string(rule).unwrap_or_default();
write_folded_property(out, "X-KTV-INTERSTITIAL", &json);
}
if let Some(rule) = block.mid_roll_rule() {
let json = serde_json::to_string(rule).unwrap_or_default();
write_folded_property(out, "X-KTV-MIDROLL", &json);
}
out.push_str("END:VEVENT\r\n");
}
fn weekday_to_byday(day: Weekday) -> &'static str {
match day {
Weekday::Monday => "MO",
Weekday::Tuesday => "TU",
Weekday::Wednesday => "WE",
Weekday::Thursday => "TH",
Weekday::Friday => "FR",
Weekday::Saturday => "SA",
Weekday::Sunday => "SU",
}
}
fn format_duration(mins: u32) -> String {
let h = mins / 60;
let m = mins % 60;
if h > 0 && m > 0 {
format!("PT{}H{}M", h, m)
} else if h > 0 {
format!("PT{}H", h)
} else {
format!("PT{}M", m)
}
}
fn fold_line(value: &str) -> String {
value.replace('\\', "\\\\").replace(',', "\\,").replace(';', "\\;")
}
fn write_folded_property(out: &mut String, name: &str, value: &str) {
let line = format!("{}:{}", name, value);
if line.len() <= 75 {
out.push_str(&line);
out.push_str("\r\n");
return;
}
let bytes = line.as_bytes();
out.push_str(&line[..75]);
out.push_str("\r\n");
let mut pos = 75;
while pos < bytes.len() {
let end = (pos + 74).min(bytes.len());
out.push(' ');
out.push_str(&line[pos..end]);
out.push_str("\r\n");
pos = end;
}
}
pub fn parse_ical(ical_str: &str) -> DomainResult<ScheduleConfig> {
let unfolded = unfold_lines(ical_str);
let lines: Vec<&str> = unfolded.lines().collect();
if !lines.iter().any(|l| l.starts_with("BEGIN:VCALENDAR")) {
return Err(crate::DomainError::validation("missing BEGIN:VCALENDAR"));
}
if !lines.iter().any(|l| l.starts_with("END:VCALENDAR")) {
return Err(crate::DomainError::validation("missing END:VCALENDAR"));
}
let mut day_blocks: HashMap<Weekday, Vec<ProgrammingBlock>> = HashMap::new();
let mut in_vevent = false;
let mut props: Vec<(&str, &str)> = Vec::new();
for line in &lines {
if *line == "BEGIN:VEVENT" {
in_vevent = true;
props.clear();
continue;
}
if *line == "END:VEVENT" {
if in_vevent {
let block = parse_vevent(&props)?;
let days = extract_byday(&props)?;
for day in days {
day_blocks.entry(day).or_default().push(block.clone());
}
}
in_vevent = false;
continue;
}
if in_vevent && let Some((name, value)) = line.split_once(':') {
props.push((name, value));
}
}
Ok(ScheduleConfig::from_day_blocks(day_blocks))
}
fn unfold_lines(s: &str) -> String {
let normalized = s.replace("\r\n", "\n").replace('\r', "\n");
let mut result = String::with_capacity(normalized.len());
for line in normalized.split('\n') {
if line.starts_with(' ') || line.starts_with('\t') {
result.push_str(&line[1..]);
} else {
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
}
}
result
}
fn extract_byday(props: &[(&str, &str)]) -> DomainResult<Vec<Weekday>> {
let rrule = props
.iter()
.find(|(n, _)| *n == "RRULE")
.map(|(_, v)| *v)
.unwrap_or("");
let byday_part = rrule
.split(';')
.find(|p| p.starts_with("BYDAY="))
.and_then(|p| p.strip_prefix("BYDAY="));
match byday_part {
Some(days_str) => {
let mut days = Vec::new();
for d in days_str.split(',') {
days.push(byday_to_weekday(d.trim())?);
}
Ok(days)
}
None => Err(crate::DomainError::validation(
"VEVENT missing RRULE with BYDAY",
)),
}
}
fn byday_to_weekday(s: &str) -> DomainResult<Weekday> {
match s {
"MO" => Ok(Weekday::Monday),
"TU" => Ok(Weekday::Tuesday),
"WE" => Ok(Weekday::Wednesday),
"TH" => Ok(Weekday::Thursday),
"FR" => Ok(Weekday::Friday),
"SA" => Ok(Weekday::Saturday),
"SU" => Ok(Weekday::Sunday),
_ => Err(crate::DomainError::validation(format!(
"unknown BYDAY value: {s}"
))),
}
}
fn parse_vevent(props: &[(&str, &str)]) -> DomainResult<ProgrammingBlock> {
let uid = prop_value(props, "UID").unwrap_or("");
let id: BlockId = if uid.is_empty() {
BlockId::generate()
} else {
uid.parse()
.unwrap_or_else(|_| BlockId::generate())
};
let name = prop_value(props, "SUMMARY")
.map(unfold_value)
.unwrap_or_else(|| "Untitled".to_string());
let start_time = parse_dtstart(prop_value(props, "DTSTART").unwrap_or(""))?;
let duration_mins = parse_duration(prop_value(props, "DURATION").unwrap_or(""))?;
let content = match prop_value(props, "X-KTV-CONTENT") {
Some(json) => serde_json::from_str(json).map_err(|e| {
crate::DomainError::validation(format!("invalid X-KTV-CONTENT: {e}"))
})?,
None => default_content(props),
};
let interstitial_rule = match prop_value(props, "X-KTV-INTERSTITIAL") {
Some(json) => Some(serde_json::from_str::<InterstitialRule>(json).map_err(|e| {
crate::DomainError::validation(format!("invalid X-KTV-INTERSTITIAL: {e}"))
})?),
None => None,
};
let mid_roll_rule = match prop_value(props, "X-KTV-MIDROLL") {
Some(json) => Some(serde_json::from_str::<MidRollRule>(json).map_err(|e| {
crate::DomainError::validation(format!("invalid X-KTV-MIDROLL: {e}"))
})?),
None => None,
};
Ok(ProgrammingBlock::from_parts(
id,
name,
start_time,
duration_mins,
content,
interstitial_rule,
mid_roll_rule,
))
}
fn default_content(props: &[(&str, &str)]) -> BlockContent {
let strategy = match prop_value(props, "X-KTV-STRATEGY") {
Some(s) => parse_strategy(s).unwrap_or(FillStrategy::Random),
None => FillStrategy::Random,
};
BlockContent::Algorithmic {
filter: MediaFilter::default(),
strategy,
}
}
fn parse_strategy(s: &str) -> DomainResult<FillStrategy> {
let quoted = format!("\"{}\"", s);
serde_json::from_str(&quoted)
.map_err(|e| crate::DomainError::validation(format!("invalid strategy '{s}': {e}")))
}
fn prop_value<'a>(props: &[(&str, &'a str)], name: &str) -> Option<&'a str> {
props.iter().find(|(n, _)| *n == name).map(|(_, v)| *v)
}
fn unfold_value(s: &str) -> String {
s.replace("\\\\", "\x00")
.replace("\\,", ",")
.replace("\\;", ";")
.replace('\x00', "\\")
}
fn parse_dtstart(s: &str) -> DomainResult<NaiveTime> {
if s.len() < 6 {
return Err(crate::DomainError::validation(format!(
"invalid DTSTART: {s}"
)));
}
let time_part = if s.contains('T') {
s.split('T').next_back().unwrap_or(s)
} else {
s
};
let digits = &time_part[..6.min(time_part.len())];
NaiveTime::parse_from_str(digits, "%H%M%S")
.map_err(|e| crate::DomainError::validation(format!("invalid DTSTART time '{s}': {e}")))
}
fn parse_duration(s: &str) -> DomainResult<u32> {
if !s.starts_with("PT") {
return Err(crate::DomainError::validation(format!(
"invalid DURATION: {s}"
)));
}
let body = &s[2..];
let mut total_secs: u32 = 0;
let mut num_buf = String::new();
for c in body.chars() {
if c.is_ascii_digit() {
num_buf.push(c);
} else {
let n: u32 = num_buf.parse().map_err(|_| {
crate::DomainError::validation(format!("invalid DURATION number in: {s}"))
})?;
num_buf.clear();
match c {
'H' => total_secs += n * 3600,
'M' => total_secs += n * 60,
'S' => total_secs += n,
_ => {
return Err(crate::DomainError::validation(format!(
"unknown DURATION unit '{c}' in: {s}"
)))
}
}
}
}
let total_mins = (total_secs + 59) / 60;
if total_mins == 0 {
return Err(crate::DomainError::validation(format!(
"zero DURATION: {s}"
)));
}
Ok(total_mins)
}
#[cfg(test)]
#[path = "tests/ical.rs"]
mod tests;

View File

@@ -1,5 +1,7 @@
pub mod ical;
pub mod iptv; pub mod iptv;
pub mod schedule; pub mod schedule;
pub use ical::{generate_ical, parse_ical};
pub use iptv::{generate_m3u, generate_xmltv}; pub use iptv::{generate_m3u, generate_xmltv};
pub use schedule::ScheduleEngineService; pub use schedule::ScheduleEngineService;

View File

@@ -4,7 +4,9 @@ use rand::rngs::StdRng;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use rand::SeedableRng; use rand::SeedableRng;
use crate::models::MediaItem; use std::collections::HashMap;
use crate::models::{MediaItem, PlaybackRecord};
use crate::value_objects::{FillStrategy, MediaItemId}; use crate::value_objects::{FillStrategy, MediaItemId};
pub(super) fn fill_block<'a>( pub(super) fn fill_block<'a>(
@@ -14,6 +16,7 @@ pub(super) fn fill_block<'a>(
strategy: &FillStrategy, strategy: &FillStrategy,
last_item_id: Option<&MediaItemId>, last_item_id: Option<&MediaItemId>,
loop_on_finish: bool, loop_on_finish: bool,
history: &[PlaybackRecord],
) -> Vec<&'a MediaItem> { ) -> Vec<&'a MediaItem> {
match strategy { match strategy {
FillStrategy::BestFit => fill_best_fit(pool, target_secs), FillStrategy::BestFit => fill_best_fit(pool, target_secs),
@@ -38,7 +41,7 @@ pub(super) fn fill_block<'a>(
fill_alternating(candidates, pool, target_secs) fill_alternating(candidates, pool, target_secs)
} }
FillStrategy::Weighted => { FillStrategy::Weighted => {
fill_weighted(candidates, pool, target_secs) fill_weighted(pool, target_secs, history)
} }
FillStrategy::Marathon => { FillStrategy::Marathon => {
fill_marathon(candidates, pool, target_secs, loop_on_finish) fill_marathon(candidates, pool, target_secs, loop_on_finish)
@@ -201,53 +204,33 @@ pub(super) fn fill_alternating<'a>(
} }
pub(super) fn fill_weighted<'a>( pub(super) fn fill_weighted<'a>(
candidates: &'a [MediaItem],
pool: &'a [MediaItem], pool: &'a [MediaItem],
target_secs: u32, target_secs: u32,
history: &[PlaybackRecord],
) -> Vec<&'a MediaItem> { ) -> Vec<&'a MediaItem> {
if pool.is_empty() { if pool.is_empty() {
return vec![]; return vec![];
} }
let pool_ids: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect(); let last_played: HashMap<&MediaItemId, i64> = history
let candidate_ids: HashSet<&MediaItemId> = candidates.iter().map(|i| i.id()).collect();
let mut fresh: Vec<&MediaItem> = pool
.iter() .iter()
.filter(|i| !candidate_ids.contains(i.id()) || pool_ids.contains(i.id())) .fold(HashMap::new(), |mut acc, r| {
.collect(); let ts = r.played_at().timestamp();
acc.entry(r.item_id())
let all_in_pool: Vec<&MediaItem> = pool.iter().collect(); .and_modify(|prev| *prev = (*prev).max(ts))
.or_insert(ts);
acc
});
let mut items: Vec<&MediaItem> = pool.iter().collect();
let mut rng = StdRng::from_entropy(); let mut rng = StdRng::from_entropy();
fresh.shuffle(&mut rng); items.shuffle(&mut rng);
items.sort_by_key(|i| last_played.get(i.id()).copied().unwrap_or(i64::MIN));
let mut remaining = target_secs; let mut remaining = target_secs;
let mut result = Vec::new(); let mut result = Vec::new();
let mut used: HashSet<&MediaItemId> = HashSet::new();
for item in &fresh { for item in items {
if remaining == 0 {
break;
}
if used.contains(item.id()) {
continue;
}
if item.duration_secs() <= remaining {
remaining -= item.duration_secs();
used.insert(item.id());
result.push(*item);
}
}
if remaining > 0 {
let mut rest: Vec<&MediaItem> = all_in_pool
.iter()
.filter(|i| !used.contains(i.id()))
.copied()
.collect();
rest.shuffle(&mut rng);
for item in rest {
if remaining == 0 { if remaining == 0 {
break; break;
} }
@@ -256,7 +239,6 @@ pub(super) fn fill_weighted<'a>(
result.push(item); result.push(item);
} }
} }
}
result result
} }
@@ -311,11 +293,11 @@ pub(super) fn fill_marathon<'a>(
} }
} }
if result.is_empty() { if result.is_empty()
if let Some(&first) = ordered.first() { && let Some(&first) = ordered.first()
{
result.push(first); result.push(first);
} }
}
result result
} }

View File

@@ -9,7 +9,8 @@ use crate::models::{
ScheduledSlot, ScheduledSlot,
}; };
use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery}; use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery};
use crate::value_objects::{BlockId, ChannelId, FillStrategy, LibrarySearchFilter, MediaItemId, RotationPolicy, Weekday}; use crate::models::MediaItem;
use crate::value_objects::{BlockId, ChannelId, FillStrategy, InterstitialRule, LibrarySearchFilter, MediaFilter, MediaItemId, MediaRole, MidRollRule, RotationPolicy, Weekday};
mod fill; mod fill;
mod rotation; mod rotation;
@@ -63,12 +64,83 @@ impl ScheduleEngineService {
channel_id: ChannelId, channel_id: ChannelId,
from: DateTime<Utc>, from: DateTime<Utc>,
) -> DomainResult<GeneratedSchedule> { ) -> DomainResult<GeneratedSchedule> {
let channel = self let channel = self.load_channel(channel_id).await?;
.channel_query let valid_until = from + Duration::days(SCHEDULE_DURATION_DAYS);
let mut schedule = self
.build_schedule(&channel, channel.schedule_config(), from, valid_until)
.await?;
if let Some(gap_filter) = channel.gap_filler() {
let filler_items = self.query_gap_fillers(gap_filter).await?;
if !filler_items.is_empty() {
let generation = schedule.generation();
let mut slots = schedule.into_slots();
Self::fill_gaps(&mut slots, &filler_items, from, valid_until);
schedule = GeneratedSchedule::new(
channel_id,
from,
valid_until,
generation,
slots,
);
}
}
self.schedule_command.save(&schedule).await?;
for slot in schedule.slots() {
let record =
PlaybackRecord::new(channel_id, slot.item().id().clone(), schedule.generation());
self.schedule_command.save_playback_record(&record).await?;
}
Ok(schedule)
}
pub async fn preview_schedule(
&self,
channel_id: ChannelId,
from: DateTime<Utc>,
duration_hours: u32,
) -> DomainResult<GeneratedSchedule> {
let channel = self.load_channel(channel_id).await?;
let valid_until = from + Duration::hours(duration_hours as i64);
self.build_schedule(&channel, channel.schedule_config(), from, valid_until)
.await
}
pub async fn preview_config(
&self,
channel_id: ChannelId,
config: &crate::models::ScheduleConfig,
from: DateTime<Utc>,
duration_hours: u32,
) -> DomainResult<GeneratedSchedule> {
let channel = self.load_channel(channel_id).await?;
let valid_until = from + Duration::hours(duration_hours as i64);
self.build_schedule(&channel, config, from, valid_until)
.await
}
async fn load_channel(
&self,
channel_id: ChannelId,
) -> DomainResult<crate::models::Channel> {
self.channel_query
.find_by_id(channel_id) .find_by_id(channel_id)
.await? .await?
.ok_or(DomainError::ChannelNotFound(channel_id))?; .ok_or(DomainError::ChannelNotFound(channel_id))
}
async fn build_schedule(
&self,
channel: &crate::models::Channel,
config: &crate::models::ScheduleConfig,
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
) -> DomainResult<GeneratedSchedule> {
let channel_id = channel.id();
let tz: Tz = channel let tz: Tz = channel
.timezone() .timezone()
.parse() .parse()
@@ -80,7 +152,6 @@ impl ScheduleEngineService {
.await?; .await?;
let latest_schedule = self.schedule_query.find_latest(channel_id).await?; let latest_schedule = self.schedule_query.find_latest(channel_id).await?;
let generation = latest_schedule let generation = latest_schedule
.as_ref() .as_ref()
.map(|s| s.generation() + 1) .map(|s| s.generation() + 1)
@@ -91,10 +162,7 @@ impl ScheduleEngineService {
.find_last_slot_per_block(channel_id) .find_last_slot_per_block(channel_id)
.await?; .await?;
let valid_from = from; let start_date = valid_from.with_timezone(&tz).date_naive();
let valid_until = from + Duration::days(SCHEDULE_DURATION_DAYS);
let start_date = from.with_timezone(&tz).date_naive();
let end_date = valid_until.with_timezone(&tz).date_naive(); let end_date = valid_until.with_timezone(&tz).date_naive();
let mut slots: Vec<ScheduledSlot> = Vec::new(); let mut slots: Vec<ScheduledSlot> = Vec::new();
@@ -102,10 +170,9 @@ impl ScheduleEngineService {
while current_date <= end_date { while current_date <= end_date {
let weekday = Weekday::from(current_date.weekday()); let weekday = Weekday::from(current_date.weekday());
for block in channel.schedule_config().blocks_for(weekday) { for block in config.blocks_for(weekday) {
let naive_start = current_date.and_time(block.start_time()); let naive_start = current_date.and_time(block.start_time());
// earliest() picks first valid mapping, skipping DST gaps
let block_start_utc = match tz.from_local_datetime(&naive_start).earliest() { let block_start_utc = match tz.from_local_datetime(&naive_start).earliest() {
Some(dt) => dt.with_timezone(&Utc), Some(dt) => dt.with_timezone(&Utc),
None => continue, None => continue,
@@ -153,23 +220,13 @@ impl ScheduleEngineService {
slots.sort_by_key(|s| s.start_at()); slots.sort_by_key(|s| s.start_at());
let schedule = GeneratedSchedule::new( Ok(GeneratedSchedule::new(
channel_id, channel_id,
valid_from, valid_from,
valid_until, valid_until,
generation, generation,
slots, slots,
); ))
self.schedule_command.save(&schedule).await?;
for slot in schedule.slots() {
let record =
PlaybackRecord::new(channel_id, slot.item().id().clone(), generation);
self.schedule_command.save_playback_record(&record).await?;
}
Ok(schedule)
} }
pub fn get_current_broadcast( pub fn get_current_broadcast(
@@ -248,10 +305,10 @@ impl ScheduleEngineService {
window: BlockTimeWindow, window: BlockTimeWindow,
rotation: RotationContext<'_>, rotation: RotationContext<'_>,
) -> DomainResult<Vec<ScheduledSlot>> { ) -> DomainResult<Vec<ScheduledSlot>> {
match block.content() { let program_slots = match block.content() {
BlockContent::Manual { items } => { BlockContent::Manual { items } => {
self.resolve_manual(items, window.start, window.end, block.id()) self.resolve_manual(items, window.start, window.end, block.id())
.await .await?
} }
BlockContent::Algorithmic { BlockContent::Algorithmic {
filter, filter,
@@ -265,11 +322,217 @@ impl ScheduleEngineService {
loop_on_finish: block.loop_on_finish(), loop_on_finish: block.loop_on_finish(),
ignore_rotation_policy: block.ignore_rotation_policy(), ignore_rotation_policy: block.ignore_rotation_policy(),
}, },
window, BlockTimeWindow {
start: window.start,
end: window.end,
},
rotation, rotation,
) )
.await .await?
} }
};
let with_interstitials = if let Some(rule) = block.interstitial_rule() {
self.insert_interstitials(program_slots, rule, block.id(), window.end)
.await?
} else {
program_slots
};
if let Some(rule) = block.mid_roll_rule() {
self.apply_mid_rolls(with_interstitials, rule, block.id(), window.end)
.await
} else {
Ok(with_interstitials)
}
}
async fn insert_interstitials(
&self,
program_slots: Vec<ScheduledSlot>,
rule: &InterstitialRule,
block_id: BlockId,
block_end: DateTime<Utc>,
) -> DomainResult<Vec<ScheduledSlot>> {
if program_slots.len() < 2 {
return Ok(program_slots);
}
let mut filter = media_filter_to_library_search(rule.pool_filter());
filter = filter.with_role(MediaRole::Interstitial);
let (interstitials, _) = self.library_query.search(&filter).await?;
if interstitials.is_empty() {
return Ok(program_slots);
}
let mut result: Vec<ScheduledSlot> = Vec::new();
let mut cursor = program_slots[0].start_at();
let mut interstitial_idx = 0;
for (i, slot) in program_slots.iter().enumerate() {
if cursor >= block_end {
break;
}
let program_duration = slot.item().duration_secs();
let program_end = (cursor + Duration::seconds(program_duration as i64)).min(block_end);
result.push(ScheduledSlot::new(
cursor,
program_end,
slot.item().clone(),
block_id,
));
cursor = program_end;
let should_insert = i + 1 < program_slots.len()
&& program_duration >= rule.min_gap_secs()
&& cursor < block_end;
if should_insert {
let interstitial = &interstitials[interstitial_idx % interstitials.len()];
let interstitial_end =
(cursor + Duration::seconds(interstitial.duration_secs() as i64))
.min(block_end);
if interstitial_end > cursor {
result.push(ScheduledSlot::new(
cursor,
interstitial_end,
interstitial.clone(),
block_id,
));
cursor = interstitial_end;
interstitial_idx += 1;
}
}
}
Ok(result)
}
async fn apply_mid_rolls(
&self,
slots: Vec<ScheduledSlot>,
rule: &MidRollRule,
block_id: BlockId,
block_end: DateTime<Utc>,
) -> DomainResult<Vec<ScheduledSlot>> {
let interval_secs = rule.fallback_interval_mins() as u64 * 60;
if interval_secs == 0 {
return Ok(slots);
}
let mut filter = media_filter_to_library_search(rule.pool_filter());
filter = filter.with_role(MediaRole::Interstitial);
let (break_items, _) = self.library_query.search(&filter).await?;
if break_items.is_empty() {
return Ok(slots);
}
let mut result: Vec<ScheduledSlot> = Vec::new();
let mut break_idx = 0usize;
for slot in &slots {
let item_duration = slot.item().duration_secs() as u64;
if item_duration < interval_secs {
result.push(slot.clone());
continue;
}
let break_points = Self::compute_break_points(
slot.item(),
rule.prefer_chapters(),
interval_secs,
);
if break_points.is_empty() {
result.push(slot.clone());
continue;
}
let mut cursor = slot.start_at();
let mut prev_offset = 0u64;
for bp in &break_points {
if cursor >= block_end {
break;
}
let segment_duration = bp - prev_offset;
let segment_end =
(cursor + Duration::seconds(segment_duration as i64)).min(block_end);
result.push(ScheduledSlot::new(
cursor,
segment_end,
slot.item().clone(),
block_id,
));
cursor = segment_end;
prev_offset = *bp;
if cursor < block_end {
let break_item = &break_items[break_idx % break_items.len()];
let break_end_secs = rule.break_duration_secs().min(break_item.duration_secs());
let break_end =
(cursor + Duration::seconds(break_end_secs as i64)).min(block_end);
if break_end > cursor {
result.push(ScheduledSlot::new(
cursor,
break_end,
break_item.clone(),
block_id,
));
cursor = break_end;
break_idx += 1;
}
}
}
let remaining = item_duration - prev_offset;
if remaining > 0 && cursor < block_end {
let tail_end =
(cursor + Duration::seconds(remaining as i64)).min(block_end);
result.push(ScheduledSlot::new(
cursor,
tail_end,
slot.item().clone(),
block_id,
));
}
}
Ok(result)
}
fn compute_break_points(
item: &MediaItem,
prefer_chapters: bool,
interval_secs: u64,
) -> Vec<u64> {
if prefer_chapters && !item.chapters().is_empty() {
item.chapters()
.iter()
.filter_map(|ch| {
let end = ch.end_secs() as u64;
if end > 0 && end < item.duration_secs() as u64 {
Some(end)
} else {
None
}
})
.collect()
} else {
let duration = item.duration_secs() as u64;
let mut points = Vec::new();
let mut offset = interval_secs;
while offset < duration {
points.push(offset);
offset += interval_secs;
}
points
} }
} }
@@ -304,7 +567,8 @@ impl ScheduleEngineService {
window: BlockTimeWindow, window: BlockTimeWindow,
rotation: RotationContext<'_>, rotation: RotationContext<'_>,
) -> DomainResult<Vec<ScheduledSlot>> { ) -> DomainResult<Vec<ScheduledSlot>> {
let library_filter = media_filter_to_library_search(params.filter); let library_filter =
media_filter_to_library_search(params.filter).with_role(MediaRole::Program);
let (candidates, _total) = self.library_query.search(&library_filter).await?; let (candidates, _total) = self.library_query.search(&library_filter).await?;
if candidates.is_empty() { if candidates.is_empty() {
@@ -324,6 +588,7 @@ impl ScheduleEngineService {
params.strategy, params.strategy,
rotation.last_item_id, rotation.last_item_id,
params.loop_on_finish, params.loop_on_finish,
rotation.history,
); );
let mut slots = Vec::new(); let mut slots = Vec::new();
@@ -341,6 +606,71 @@ impl ScheduleEngineService {
Ok(slots) Ok(slots)
} }
async fn query_gap_fillers(&self, gap_filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
let filter =
media_filter_to_library_search(gap_filter).with_role(MediaRole::Interstitial);
let (items, _) = self.library_query.search(&filter).await?;
Ok(items)
}
fn fill_gaps(
slots: &mut Vec<ScheduledSlot>,
fillers: &[MediaItem],
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
) {
if fillers.is_empty() {
return;
}
let gap_block_id = BlockId::generate();
let mut gap_slots: Vec<ScheduledSlot> = Vec::new();
let mut filler_idx = 0usize;
let mut boundaries: Vec<(DateTime<Utc>, DateTime<Utc>)> = Vec::new();
if slots.is_empty() {
boundaries.push((valid_from, valid_until));
} else {
if slots[0].start_at() > valid_from {
boundaries.push((valid_from, slots[0].start_at()));
}
for pair in slots.windows(2) {
if pair[1].start_at() > pair[0].end_at() {
boundaries.push((pair[0].end_at(), pair[1].start_at()));
}
}
if let Some(last) = slots.last()
&& last.end_at() < valid_until
{
boundaries.push((last.end_at(), valid_until));
}
}
for (gap_start, gap_end) in boundaries {
let mut cursor = gap_start;
while cursor < gap_end {
let filler = &fillers[filler_idx % fillers.len()];
let filler_end =
(cursor + Duration::seconds(filler.duration_secs() as i64)).min(gap_end);
if filler_end <= cursor {
break;
}
gap_slots.push(ScheduledSlot::new(
cursor,
filler_end,
filler.clone(),
gap_block_id,
));
cursor = filler_end;
filler_idx += 1;
}
}
slots.append(&mut gap_slots);
slots.sort_by_key(|s| s.start_at());
}
} }
fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) -> LibrarySearchFilter { fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) -> LibrarySearchFilter {
@@ -362,11 +692,9 @@ fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) ->
if let Some(max) = filter.max_duration_secs { if let Some(max) = filter.max_duration_secs {
lsf = lsf.with_max_duration_secs(max); lsf = lsf.with_max_duration_secs(max);
} }
if !filter.collections.is_empty() {
if let Some(first) = filter.collections.first() { if let Some(first) = filter.collections.first() {
lsf = lsf.with_collection_id(first.clone()); lsf = lsf.with_collection_id(first.clone());
} }
}
if !filter.series_names.is_empty() { if !filter.series_names.is_empty() {
lsf = lsf.with_series_names(filter.series_names.clone()); lsf = lsf.with_series_names(filter.series_names.clone());
} }
@@ -374,7 +702,11 @@ fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) ->
lsf = lsf.with_search_term(term.clone()); lsf = lsf.with_search_term(term.clone());
} }
if !filter.tags.is_empty() { if !filter.tags.is_empty() {
// tags map to the same concept in the library lsf = lsf.with_tags(filter.tags.clone());
} }
lsf lsf
} }
#[cfg(all(test, feature = "test-helpers"))]
#[path = "tests/integration.rs"]
mod integration_tests;

View File

@@ -64,7 +64,7 @@ fn sequential_includes_oversize_first_episode() {
fn random_fill_respects_budget() { fn random_fill_respects_budget() {
let pool = vec![item("a", 100), item("b", 100), item("c", 100)]; let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
let candidates = pool.clone(); let candidates = pool.clone();
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Random, None, true); let result = fill_block(&candidates, &pool, 200, &FillStrategy::Random, None, true, &[]);
let total: u32 = result.iter().map(|i| i.duration_secs()).sum(); let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
assert!(total <= 200); assert!(total <= 200);
} }
@@ -118,7 +118,7 @@ fn alternating_empty_pool() {
fn weighted_respects_budget() { fn weighted_respects_budget() {
let pool = vec![item("a", 100), item("b", 100), item("c", 100)]; let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
let candidates = pool.clone(); let candidates = pool.clone();
let result = fill_weighted(&candidates, &pool, 200); let result = fill_weighted(&pool, 200, &[]);
let total: u32 = result.iter().map(|i| i.duration_secs()).sum(); let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
assert!(total <= 200); assert!(total <= 200);
} }
@@ -127,7 +127,7 @@ fn weighted_respects_budget() {
fn weighted_no_duplicates() { fn weighted_no_duplicates() {
let pool = vec![item("a", 50), item("b", 50), item("c", 50)]; let pool = vec![item("a", 50), item("b", 50), item("c", 50)];
let candidates = pool.clone(); let candidates = pool.clone();
let result = fill_weighted(&candidates, &pool, 150); let result = fill_weighted(&pool, 150, &[]);
let ids: Vec<&str> = result.iter().map(|i| i.id().value()).collect(); let ids: Vec<&str> = result.iter().map(|i| i.id().value()).collect();
let unique: HashSet<&str> = ids.iter().copied().collect(); let unique: HashSet<&str> = ids.iter().copied().collect();
assert_eq!(ids.len(), unique.len()); assert_eq!(ids.len(), unique.len());
@@ -137,7 +137,7 @@ fn weighted_no_duplicates() {
fn weighted_empty_pool() { fn weighted_empty_pool() {
let candidates: Vec<MediaItem> = vec![]; let candidates: Vec<MediaItem> = vec![];
let pool: Vec<MediaItem> = vec![]; let pool: Vec<MediaItem> = vec![];
let result = fill_weighted(&candidates, &pool, 300); let result = fill_weighted(&pool, 300, &[]);
assert!(result.is_empty()); assert!(result.is_empty());
} }
@@ -192,7 +192,7 @@ fn marathon_oversize_single_item() {
fn fill_block_dispatches_alternating() { fn fill_block_dispatches_alternating() {
let candidates = vec![item("a", 100), item("b", 100)]; let candidates = vec![item("a", 100), item("b", 100)];
let pool = candidates.clone(); let pool = candidates.clone();
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Alternating, None, true); let result = fill_block(&candidates, &pool, 200, &FillStrategy::Alternating, None, true, &[]);
let total: u32 = result.iter().map(|i| i.duration_secs()).sum(); let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
assert!(total <= 200); assert!(total <= 200);
} }
@@ -201,7 +201,7 @@ fn fill_block_dispatches_alternating() {
fn fill_block_dispatches_weighted() { fn fill_block_dispatches_weighted() {
let candidates = vec![item("a", 100), item("b", 100)]; let candidates = vec![item("a", 100), item("b", 100)];
let pool = candidates.clone(); let pool = candidates.clone();
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Weighted, None, true); let result = fill_block(&candidates, &pool, 200, &FillStrategy::Weighted, None, true, &[]);
let total: u32 = result.iter().map(|i| i.duration_secs()).sum(); let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
assert!(total <= 200); assert!(total <= 200);
} }
@@ -210,6 +210,6 @@ fn fill_block_dispatches_weighted() {
fn fill_block_dispatches_marathon() { fn fill_block_dispatches_marathon() {
let candidates = vec![item("a", 100), item("b", 100)]; let candidates = vec![item("a", 100), item("b", 100)];
let pool = candidates.clone(); let pool = candidates.clone();
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Marathon, None, true); let result = fill_block(&candidates, &pool, 200, &FillStrategy::Marathon, None, true, &[]);
assert_eq!(result[0].id().value(), "a"); assert_eq!(result[0].id().value(), "a");
} }

View File

@@ -0,0 +1,716 @@
use std::sync::Arc;
use chrono::{Datelike, NaiveTime, Utc};
use crate::models::{Channel, MediaItem, ProgrammingBlock, ScheduleConfig};
use crate::services::schedule::ScheduleEngineService;
use crate::testing::{
InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository,
};
use crate::value_objects::{
Chapter, ContentType, FillStrategy, InterstitialRule, MediaFilter, MediaItemId, MediaRole,
MidRollRule, Weekday,
};
fn episode(id: &str, series: &str, ep: u32, secs: u32) -> MediaItem {
let item = MediaItem::new(MediaItemId::new(id), id, ContentType::Episode, secs);
let mut val = serde_json::to_value(&item).unwrap();
val["series_name"] = serde_json::Value::String(series.into());
val["episode_number"] = serde_json::Value::Number(ep.into());
serde_json::from_value(val).unwrap()
}
fn movie(id: &str, secs: u32) -> MediaItem {
MediaItem::new(MediaItemId::new(id), id, ContentType::Movie, secs)
}
struct TestHarness {
engine: ScheduleEngineService,
channel_repo: Arc<InMemoryChannelRepository>,
library_repo: Arc<InMemoryLibraryRepository>,
}
impl TestHarness {
fn new() -> Self {
let library_repo = Arc::new(InMemoryLibraryRepository::new());
let channel_repo = Arc::new(InMemoryChannelRepository::new());
let schedule_repo = Arc::new(InMemoryScheduleRepository::new());
let engine = ScheduleEngineService::new(
library_repo.clone(),
channel_repo.clone(),
schedule_repo.clone(),
schedule_repo,
);
Self {
engine,
channel_repo,
library_repo,
}
}
fn seed_items(&self, items: Vec<MediaItem>) {
let mut store = self.library_repo.items.lock().unwrap();
for item in items {
store.insert(item.id().value().to_string(), item);
}
}
async fn create_channel_with_block(
&self,
block: ProgrammingBlock,
) -> Channel {
let mut channel = Channel::new(
crate::value_objects::UserId::generate(),
"test-channel",
"UTC",
);
let today = Utc::now()
.with_timezone(&chrono_tz::UTC)
.date_naive();
let weekday = Weekday::from(today.weekday());
let mut config = ScheduleConfig::new();
config.insert_day(weekday, vec![block]);
channel.set_schedule_config(config);
self.channel_repo
.channels
.lock()
.unwrap()
.insert(channel.id(), channel.clone());
channel
}
}
#[tokio::test]
async fn alternating_interleaves_two_series() {
let h = TestHarness::new();
h.seed_items(vec![
episode("a-e1", "Show A", 1, 300),
episode("a-e2", "Show A", 2, 300),
episode("a-e3", "Show A", 3, 300),
episode("b-e1", "Show B", 1, 300),
episode("b-e2", "Show B", 2, 300),
episode("b-e3", "Show B", 3, 300),
]);
let block = ProgrammingBlock::new_algorithmic(
"alternating-block",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::Alternating,
);
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
let slots = schedule.slots();
assert!(slots.len() >= 4, "expected at least 4 slots, got {}", slots.len());
let series: Vec<Option<&str>> = slots.iter().map(|s| s.item().series_name()).collect();
for pair in series.windows(2) {
if pair[0] == pair[1] {
panic!(
"consecutive slots have same series {:?}, expected interleaving",
pair[0]
);
}
}
}
#[tokio::test]
async fn weighted_surfaces_fresh_items() {
let h = TestHarness::new();
h.seed_items(vec![
movie("m1", 600),
movie("m2", 600),
movie("m3", 600),
movie("m4", 600),
]);
let block = ProgrammingBlock::new_algorithmic(
"weighted-block",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::Weighted,
);
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
assert!(!schedule.slots().is_empty(), "weighted strategy produced no slots");
let total_duration: u32 = schedule
.slots()
.iter()
.map(|s| (s.end_at() - s.start_at()).num_seconds() as u32)
.sum();
assert!(total_duration > 0, "schedule has zero total duration");
}
#[tokio::test]
async fn marathon_fills_from_episode_one() {
let h = TestHarness::new();
h.seed_items(vec![
episode("ep1", "Series", 1, 600),
episode("ep2", "Series", 2, 600),
episode("ep3", "Series", 3, 600),
]);
let block = ProgrammingBlock::new_algorithmic(
"marathon-block",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::Marathon,
);
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
let slots = schedule.slots();
assert!(slots.len() >= 3, "expected at least 3 slots, got {}", slots.len());
assert_eq!(slots[0].item().id().value(), "ep1");
assert_eq!(slots[1].item().id().value(), "ep2");
assert_eq!(slots[2].item().id().value(), "ep3");
}
#[tokio::test]
async fn sequential_produces_ordered_schedule() {
let h = TestHarness::new();
h.seed_items(vec![
episode("ep1", "Series", 1, 600),
episode("ep2", "Series", 2, 600),
episode("ep3", "Series", 3, 600),
]);
let block = ProgrammingBlock::new_algorithmic(
"sequential-block",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::Sequential,
);
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
let slots = schedule.slots();
assert!(!slots.is_empty(), "sequential strategy produced no slots");
assert_eq!(slots[0].item().id().value(), "ep1");
}
#[tokio::test]
async fn best_fit_produces_schedule() {
let h = TestHarness::new();
h.seed_items(vec![
movie("m1", 1800),
movie("m2", 1200),
movie("m3", 900),
]);
let block = ProgrammingBlock::new_algorithmic(
"bestfit-block",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::BestFit,
);
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
assert!(!schedule.slots().is_empty(), "best_fit strategy produced no slots");
assert_eq!(
schedule.slots()[0].item().id().value(),
"m1",
"best_fit should pick longest item first"
);
}
#[tokio::test]
async fn random_produces_schedule_within_budget() {
let h = TestHarness::new();
h.seed_items(vec![
movie("m1", 600),
movie("m2", 600),
movie("m3", 600),
]);
let block = ProgrammingBlock::new_algorithmic(
"random-block",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
30,
MediaFilter::default(),
FillStrategy::Random,
);
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
assert!(!schedule.slots().is_empty(), "random strategy produced no slots");
}
fn interstitial(id: &str, secs: u32) -> MediaItem {
let item = MediaItem::new(MediaItemId::new(id), id, ContentType::Short, secs);
let mut val = serde_json::to_value(&item).unwrap();
val["role"] = serde_json::Value::String("interstitial".into());
serde_json::from_value(val).unwrap()
}
#[tokio::test]
async fn interstitial_inserted_between_programs() {
let h = TestHarness::new();
h.seed_items(vec![
movie("prog1", 600),
movie("prog2", 600),
movie("prog3", 600),
interstitial("bump1", 30),
interstitial("bump2", 30),
]);
let block = ProgrammingBlock::new_algorithmic(
"with-interstitials",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::BestFit,
)
.with_interstitial_rule(InterstitialRule::new(
MediaFilter::default(),
FillStrategy::Sequential,
0,
));
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
let today_slots: Vec<_> = schedule.slots().iter().take(10).collect();
let program_count = today_slots
.iter()
.filter(|s| *s.item().role() == MediaRole::Program)
.count();
let interstitial_count = today_slots
.iter()
.filter(|s| *s.item().role() == MediaRole::Interstitial)
.count();
assert!(program_count >= 3, "expected >= 3 programs, got {program_count}");
assert!(
interstitial_count >= 2,
"expected >= 2 interstitials, got {interstitial_count}"
);
for pair in today_slots.windows(2) {
assert!(
pair[0].end_at() <= pair[1].start_at(),
"slots overlap: {} ends at {:?} but {} starts at {:?}",
pair[0].item().title(),
pair[0].end_at(),
pair[1].item().title(),
pair[1].start_at(),
);
}
}
#[tokio::test]
async fn interstitial_no_matching_items_still_generates() {
let h = TestHarness::new();
h.seed_items(vec![
movie("prog1", 600),
movie("prog2", 600),
]);
let block = ProgrammingBlock::new_algorithmic(
"no-interstitials-available",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
30,
MediaFilter::default(),
FillStrategy::BestFit,
)
.with_interstitial_rule(InterstitialRule::new(
MediaFilter::default(),
FillStrategy::Sequential,
0,
));
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
assert!(!schedule.slots().is_empty(), "should still produce program slots");
assert!(
schedule.slots().iter().all(|s| *s.item().role() == MediaRole::Program),
"all slots should be programs when no interstitials available"
);
}
#[tokio::test]
async fn interstitial_min_gap_respected() {
let h = TestHarness::new();
h.seed_items(vec![
movie("short1", 100),
movie("short2", 100),
movie("short3", 100),
interstitial("bump1", 30),
]);
let block = ProgrammingBlock::new_algorithmic(
"min-gap-test",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
30,
MediaFilter::default(),
FillStrategy::BestFit,
)
.with_interstitial_rule(InterstitialRule::new(
MediaFilter::default(),
FillStrategy::Sequential,
200,
));
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
let interstitial_count = schedule
.slots()
.iter()
.filter(|s| *s.item().role() == MediaRole::Interstitial)
.count();
assert_eq!(
interstitial_count, 0,
"no interstitials should be inserted when programs are shorter than min_gap_secs"
);
}
impl TestHarness {
async fn create_channel_with_blocks_and_gap_filler(
&self,
blocks: Vec<ProgrammingBlock>,
gap_filler: Option<MediaFilter>,
) -> Channel {
let mut channel = Channel::new(
crate::value_objects::UserId::generate(),
"test-channel",
"UTC",
);
let today = Utc::now()
.with_timezone(&chrono_tz::UTC)
.date_naive();
let weekday = Weekday::from(today.weekday());
let mut config = ScheduleConfig::new();
config.insert_day(weekday, blocks);
channel.set_schedule_config(config);
channel.set_gap_filler(gap_filler);
self.channel_repo
.channels
.lock()
.unwrap()
.insert(channel.id(), channel.clone());
channel
}
}
#[tokio::test]
async fn gap_filler_fills_between_blocks() {
let h = TestHarness::new();
h.seed_items(vec![
movie("prog1", 1800),
movie("prog2", 1800),
interstitial("filler1", 60),
interstitial("filler2", 60),
]);
let block1 = ProgrammingBlock::new_algorithmic(
"morning",
NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
30,
MediaFilter::default(),
FillStrategy::BestFit,
);
let block2 = ProgrammingBlock::new_algorithmic(
"afternoon",
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
30,
MediaFilter::default(),
FillStrategy::BestFit,
);
let channel = h
.create_channel_with_blocks_and_gap_filler(
vec![block1, block2],
Some(MediaFilter::default()),
)
.await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
let filler_count = schedule
.slots()
.iter()
.filter(|s| *s.item().role() == MediaRole::Interstitial)
.count();
assert!(
filler_count > 0,
"gap filler should produce interstitial slots between blocks"
);
for pair in schedule.slots().windows(2) {
assert!(
pair[0].end_at() <= pair[1].start_at(),
"slots overlap: {} ends at {:?} but {} starts at {:?}",
pair[0].item().title(),
pair[0].end_at(),
pair[1].item().title(),
pair[1].start_at(),
);
}
}
#[tokio::test]
async fn no_gap_filler_leaves_gaps_empty() {
let h = TestHarness::new();
h.seed_items(vec![
movie("prog1", 1800),
movie("prog2", 1800),
interstitial("filler1", 60),
]);
let block1 = ProgrammingBlock::new_algorithmic(
"morning",
NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
30,
MediaFilter::default(),
FillStrategy::BestFit,
);
let block2 = ProgrammingBlock::new_algorithmic(
"afternoon",
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
30,
MediaFilter::default(),
FillStrategy::BestFit,
);
let channel = h
.create_channel_with_blocks_and_gap_filler(vec![block1, block2], None)
.await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
let filler_count = schedule
.slots()
.iter()
.filter(|s| *s.item().role() == MediaRole::Interstitial)
.count();
assert_eq!(
filler_count, 0,
"no gap filler configured, should have no interstitial slots"
);
}
fn movie_with_chapters(id: &str, secs: u32, chapters: Vec<Chapter>) -> MediaItem {
let item = MediaItem::new(MediaItemId::new(id), id, ContentType::Movie, secs);
let mut val = serde_json::to_value(&item).unwrap();
val["chapters"] = serde_json::to_value(&chapters).unwrap();
serde_json::from_value(val).unwrap()
}
#[tokio::test]
async fn mid_roll_splits_long_movie_at_intervals() {
let h = TestHarness::new();
h.seed_items(vec![
movie("long-movie", 7200),
interstitial("ad1", 120),
interstitial("ad2", 120),
]);
let block = ProgrammingBlock::new_algorithmic(
"movie-block",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
180,
MediaFilter::default(),
FillStrategy::BestFit,
)
.with_mid_roll_rule(MidRollRule::new(
false,
30,
120,
MediaFilter::default(),
));
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
let today_slots: Vec<_> = schedule.slots().iter().take(20).collect();
let movie_segments = today_slots
.iter()
.filter(|s| s.item().id().value() == "long-movie")
.count();
let break_count = today_slots
.iter()
.filter(|s| *s.item().role() == MediaRole::Interstitial)
.count();
assert!(
movie_segments >= 2,
"2h movie with 30min interval should produce >= 2 segments, got {movie_segments}"
);
assert!(
break_count >= 1,
"should have at least 1 break slot, got {break_count}"
);
}
#[tokio::test]
async fn mid_roll_prefers_chapter_boundaries() {
let h = TestHarness::new();
h.seed_items(vec![
movie_with_chapters(
"chaptered-movie",
7200,
vec![
Chapter::new(Some("Act 1".into()), 0.0, 2400.0),
Chapter::new(Some("Act 2".into()), 2400.0, 4800.0),
Chapter::new(Some("Act 3".into()), 4800.0, 7200.0),
],
),
interstitial("ad1", 120),
]);
let block = ProgrammingBlock::new_algorithmic(
"chapter-block",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
180,
MediaFilter::default(),
FillStrategy::BestFit,
)
.with_mid_roll_rule(MidRollRule::new(
true,
30,
120,
MediaFilter::default(),
));
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
let today_slots: Vec<_> = schedule.slots().iter().take(20).collect();
let movie_segments = today_slots
.iter()
.filter(|s| s.item().id().value() == "chaptered-movie")
.count();
assert!(
movie_segments >= 3,
"chaptered movie should split at chapter boundaries, got {movie_segments} segments"
);
}
#[tokio::test]
async fn mid_roll_short_item_not_split() {
let h = TestHarness::new();
h.seed_items(vec![
movie("short-movie", 1200),
interstitial("ad1", 120),
]);
let block = ProgrammingBlock::new_algorithmic(
"short-block",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::BestFit,
)
.with_mid_roll_rule(MidRollRule::new(
false,
30,
120,
MediaFilter::default(),
));
let channel = h.create_channel_with_block(block).await;
let schedule = h
.engine
.generate_schedule(channel.id(), Utc::now())
.await
.unwrap();
let today_slots: Vec<_> = schedule.slots().iter().take(10).collect();
let movie_segments = today_slots
.iter()
.filter(|s| s.item().id().value() == "short-movie")
.count();
let break_count = today_slots
.iter()
.filter(|s| *s.item().role() == MediaRole::Interstitial)
.count();
assert_eq!(
movie_segments, 1,
"20min movie under 30min interval should not be split"
);
assert_eq!(
break_count, 0,
"no breaks for short items"
);
}

View File

@@ -0,0 +1,336 @@
use super::*;
use crate::models::{BlockContent, ProgrammingBlock, ScheduleConfig};
use crate::value_objects::{FillStrategy, InterstitialRule, MediaFilter, MidRollRule, Weekday};
use chrono::NaiveTime;
use std::collections::HashMap;
fn make_config_with_blocks(
entries: Vec<(Weekday, Vec<ProgrammingBlock>)>,
) -> ScheduleConfig {
let day_blocks: HashMap<Weekday, Vec<ProgrammingBlock>> = entries.into_iter().collect();
ScheduleConfig::from_day_blocks(day_blocks)
}
#[test]
fn vcalendar_header() {
let config = ScheduleConfig::new();
let ical = generate_ical("Test Channel", "Europe/Warsaw", &config);
assert!(ical.contains("BEGIN:VCALENDAR\r\n"));
assert!(ical.contains("VERSION:2.0\r\n"));
assert!(ical.contains("PRODID:-//K-TV//Schedule Export//EN\r\n"));
assert!(ical.contains("CALSCALE:GREGORIAN\r\n"));
assert!(ical.contains("X-WR-CALNAME:Test Channel\r\n"));
assert!(ical.contains("X-WR-TIMEZONE:Europe/Warsaw\r\n"));
assert!(ical.contains("END:VCALENDAR\r\n"));
}
#[test]
fn empty_config_produces_no_events() {
let config = ScheduleConfig::new();
let ical = generate_ical("Empty", "UTC", &config);
assert!(!ical.contains("BEGIN:VEVENT"));
}
#[test]
fn algorithmic_block_produces_vevent() {
let block = ProgrammingBlock::new_algorithmic(
"Morning Cartoons",
NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
120,
MediaFilter::default(),
FillStrategy::Random,
);
let config = make_config_with_blocks(vec![(Weekday::Monday, vec![block.clone()])]);
let ical = generate_ical("Kids TV", "UTC", &config);
assert!(ical.contains("BEGIN:VEVENT\r\n"));
assert!(ical.contains("END:VEVENT\r\n"));
assert!(ical.contains("DTSTART:080000\r\n"));
assert!(ical.contains("DURATION:PT2H\r\n"));
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO\r\n"));
assert!(ical.contains("SUMMARY:Morning Cartoons\r\n"));
assert!(ical.contains(&format!("UID:{}\r\n", block.id())));
assert!(ical.contains("X-KTV-STRATEGY:random\r\n"));
assert!(ical.contains("X-KTV-CONTENT:"));
}
#[test]
fn different_days_produce_different_byday() {
let mon_block = ProgrammingBlock::new_algorithmic(
"Monday Block",
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::Sequential,
);
let fri_block = ProgrammingBlock::new_algorithmic(
"Friday Block",
NaiveTime::from_hms_opt(20, 0, 0).unwrap(),
90,
MediaFilter::default(),
FillStrategy::BestFit,
);
let config = make_config_with_blocks(vec![
(Weekday::Monday, vec![mon_block]),
(Weekday::Friday, vec![fri_block]),
]);
let ical = generate_ical("Multi-Day", "America/New_York", &config);
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO\r\n"));
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=FR\r\n"));
}
#[test]
fn manual_block_has_no_strategy() {
let block = ProgrammingBlock::new_manual(
"Manual Show",
NaiveTime::from_hms_opt(14, 30, 0).unwrap(),
45,
vec![],
);
let config = make_config_with_blocks(vec![(Weekday::Wednesday, vec![block])]);
let ical = generate_ical("Manual Ch", "UTC", &config);
assert!(ical.contains("SUMMARY:Manual Show\r\n"));
assert!(ical.contains("DTSTART:143000\r\n"));
assert!(ical.contains("DURATION:PT45M\r\n"));
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=WE\r\n"));
assert!(!ical.contains("X-KTV-STRATEGY:"));
}
#[test]
fn duration_with_hours_and_minutes() {
let block = ProgrammingBlock::new_manual(
"Mixed Duration",
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
150,
vec![],
);
let config = make_config_with_blocks(vec![(Weekday::Sunday, vec![block])]);
let ical = generate_ical("Test", "UTC", &config);
assert!(ical.contains("DURATION:PT2H30M\r\n"));
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=SU\r\n"));
}
#[test]
fn all_weekdays_mapped() {
let days_and_byday = [
(Weekday::Monday, "MO"),
(Weekday::Tuesday, "TU"),
(Weekday::Wednesday, "WE"),
(Weekday::Thursday, "TH"),
(Weekday::Friday, "FR"),
(Weekday::Saturday, "SA"),
(Weekday::Sunday, "SU"),
];
for (day, byday) in days_and_byday {
let block = ProgrammingBlock::new_manual(
"Test",
NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
60,
vec![],
);
let config = make_config_with_blocks(vec![(day, vec![block])]);
let ical = generate_ical("Test", "UTC", &config);
assert!(
ical.contains(&format!("BYDAY={}\r\n", byday)),
"Expected BYDAY={} for {:?}",
byday,
day
);
}
}
#[test]
fn roundtrip_algorithmic_block() {
let block = ProgrammingBlock::new_algorithmic(
"Morning Cartoons",
NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
120,
MediaFilter::default(),
FillStrategy::Random,
);
let original = make_config_with_blocks(vec![(Weekday::Monday, vec![block.clone()])]);
let ical = generate_ical("Test", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Monday);
assert_eq!(blocks.len(), 1);
let b = &blocks[0];
assert_eq!(b.name(), "Morning Cartoons");
assert_eq!(b.start_time(), NaiveTime::from_hms_opt(8, 0, 0).unwrap());
assert_eq!(b.duration_mins(), 120);
assert_eq!(b.id(), block.id());
assert!(matches!(b.content(), BlockContent::Algorithmic { strategy: FillStrategy::Random, .. }));
}
#[test]
fn roundtrip_multi_day() {
let mon = ProgrammingBlock::new_algorithmic(
"Mon Block",
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::Sequential,
);
let fri = ProgrammingBlock::new_algorithmic(
"Fri Block",
NaiveTime::from_hms_opt(20, 0, 0).unwrap(),
90,
MediaFilter::default(),
FillStrategy::BestFit,
);
let original = make_config_with_blocks(vec![
(Weekday::Monday, vec![mon]),
(Weekday::Friday, vec![fri]),
]);
let ical = generate_ical("Multi", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
assert_eq!(parsed.blocks_for(Weekday::Monday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Friday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Monday)[0].name(), "Mon Block");
assert_eq!(parsed.blocks_for(Weekday::Friday)[0].name(), "Fri Block");
}
#[test]
fn roundtrip_manual_block() {
let block = ProgrammingBlock::new_manual(
"Manual Show",
NaiveTime::from_hms_opt(14, 30, 0).unwrap(),
45,
vec![],
);
let original = make_config_with_blocks(vec![(Weekday::Wednesday, vec![block])]);
let ical = generate_ical("Manual", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Wednesday);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].name(), "Manual Show");
assert_eq!(blocks[0].duration_mins(), 45);
assert!(matches!(blocks[0].content(), BlockContent::Manual { .. }));
}
#[test]
fn roundtrip_with_interstitial_and_midroll() {
let mut block = ProgrammingBlock::new_algorithmic(
"Full Block",
NaiveTime::from_hms_opt(20, 0, 0).unwrap(),
180,
MediaFilter::default(),
FillStrategy::Random,
);
let interstitial = InterstitialRule::new(MediaFilter::default(), FillStrategy::Random, 30);
let midroll = MidRollRule::new(true, 15, 60, MediaFilter::default());
block = ProgrammingBlock::from_parts(
block.id(),
block.name().to_string(),
block.start_time(),
block.duration_mins(),
block.content().clone(),
Some(interstitial),
Some(midroll),
);
let original = make_config_with_blocks(vec![(Weekday::Saturday, vec![block])]);
let ical = generate_ical("Full", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Saturday);
assert_eq!(blocks.len(), 1);
assert!(blocks[0].interstitial_rule().is_some());
assert!(blocks[0].mid_roll_rule().is_some());
assert_eq!(blocks[0].interstitial_rule().unwrap().min_gap_secs(), 30);
assert!(blocks[0].mid_roll_rule().unwrap().prefer_chapters());
}
#[test]
fn import_without_ktv_properties_uses_defaults() {
let ical = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Generic Event\r\n\
DTSTART:090000\r\n\
DURATION:PT1H\r\n\
RRULE:FREQ=WEEKLY;BYDAY=TU\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let parsed = parse_ical(ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Tuesday);
assert_eq!(blocks.len(), 1);
let b = &blocks[0];
assert_eq!(b.name(), "Generic Event");
assert_eq!(b.start_time(), NaiveTime::from_hms_opt(9, 0, 0).unwrap());
assert_eq!(b.duration_mins(), 60);
assert!(matches!(
b.content(),
BlockContent::Algorithmic { strategy: FillStrategy::Random, .. }
));
assert!(b.interstitial_rule().is_none());
assert!(b.mid_roll_rule().is_none());
}
#[test]
fn import_invalid_ical_missing_vcalendar() {
let result = parse_ical("not an ical file");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("BEGIN:VCALENDAR"), "error: {err}");
}
#[test]
fn import_invalid_ical_bad_duration() {
let ical = "BEGIN:VCALENDAR\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Bad\r\n\
DTSTART:090000\r\n\
DURATION:INVALID\r\n\
RRULE:FREQ=WEEKLY;BYDAY=MO\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let result = parse_ical(ical);
assert!(result.is_err());
}
#[test]
fn import_handles_line_folding() {
let long_summary = "A".repeat(100);
let block = ProgrammingBlock::new_algorithmic(
&long_summary,
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::Random,
);
let original = make_config_with_blocks(vec![(Weekday::Thursday, vec![block])]);
let ical = generate_ical("Fold Test", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Thursday);
assert_eq!(blocks.len(), 1);
}
#[test]
fn import_multiple_byday() {
let ical = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Weekday Show\r\n\
DTSTART:180000\r\n\
DURATION:PT2H\r\n\
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let parsed = parse_ical(ical).unwrap();
assert_eq!(parsed.blocks_for(Weekday::Monday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Wednesday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Friday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Tuesday).len(), 0);
}

View File

@@ -418,6 +418,18 @@ impl LibraryCommand for InMemoryLibraryRepository {
Ok(id) Ok(id)
} }
async fn update_role(&self, item_id: &str, role: crate::value_objects::MediaRole) -> DomainResult<()> {
let mut store = self.items.lock().unwrap();
if let Some(item) = store.get_mut(item_id) {
item.set_role(role);
Ok(())
} else {
Err(crate::errors::DomainError::NotFound(format!(
"Library item {item_id} not found"
)))
}
}
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()> { async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()> {
let mut logs = self.sync_logs.lock().unwrap(); let mut logs = self.sync_logs.lock().unwrap();
if let Some(entry) = logs.iter_mut().find(|e| e.id() == log_id) { if let Some(entry) = logs.iter_mut().find(|e| e.id() == log_id) {
@@ -467,10 +479,21 @@ impl LibraryQuery for InMemoryLibraryRepository {
{ {
return false; return false;
} }
if let Some(role) = filter.role() && item.role() != role {
return false;
}
if !filter.series_names().is_empty()
&& !item
.series_name()
.is_some_and(|sn| filter.series_names().iter().any(|f| f == sn))
{
return false;
}
true true
}) })
.cloned() .cloned()
.collect(); .collect();
items.sort_by(|a, b| a.id().value().cmp(b.id().value()));
let total = items.len() as u32; let total = items.len() as u32;
let offset = filter.offset() as usize; let offset = filter.offset() as usize;
let limit = filter.limit() as usize; let limit = filter.limit() as usize;

View File

@@ -1,4 +1,4 @@
use crate::value_objects::ContentType; use crate::value_objects::{ContentType, MediaRole};
const DEFAULT_SEARCH_LIMIT: u32 = 50; const DEFAULT_SEARCH_LIMIT: u32 = 50;
@@ -13,7 +13,9 @@ pub struct LibrarySearchFilter {
min_duration_secs: Option<u32>, min_duration_secs: Option<u32>,
max_duration_secs: Option<u32>, max_duration_secs: Option<u32>,
search_term: Option<String>, search_term: Option<String>,
tags: Vec<String>,
season_number: Option<u32>, season_number: Option<u32>,
role: Option<MediaRole>,
offset: u32, offset: u32,
limit: u32, limit: u32,
} }
@@ -59,10 +61,18 @@ impl LibrarySearchFilter {
self.search_term = Some(term.into()); self.search_term = Some(term.into());
self self
} }
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn with_season_number(mut self, n: u32) -> Self { pub fn with_season_number(mut self, n: u32) -> Self {
self.season_number = Some(n); self.season_number = Some(n);
self self
} }
pub fn with_role(mut self, role: MediaRole) -> Self {
self.role = Some(role);
self
}
pub fn with_offset(mut self, offset: u32) -> Self { pub fn with_offset(mut self, offset: u32) -> Self {
self.offset = offset; self.offset = offset;
self self
@@ -99,9 +109,15 @@ impl LibrarySearchFilter {
pub fn search_term(&self) -> Option<&str> { pub fn search_term(&self) -> Option<&str> {
self.search_term.as_deref() self.search_term.as_deref()
} }
pub fn tags(&self) -> &[String] {
&self.tags
}
pub fn season_number(&self) -> Option<u32> { pub fn season_number(&self) -> Option<u32> {
self.season_number self.season_number
} }
pub fn role(&self) -> Option<&MediaRole> {
self.role.as_ref()
}
pub fn offset(&self) -> u32 { pub fn offset(&self) -> u32 {
self.offset self.offset
} }
@@ -122,7 +138,9 @@ impl Default for LibrarySearchFilter {
min_duration_secs: None, min_duration_secs: None,
max_duration_secs: None, max_duration_secs: None,
search_term: None, search_term: None,
tags: vec![],
season_number: None, season_number: None,
role: None,
offset: 0, offset: 0,
limit: DEFAULT_SEARCH_LIMIT, limit: DEFAULT_SEARCH_LIMIT,
} }

View File

@@ -14,7 +14,7 @@ use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::tools::{channels, library, schedule}; use crate::tools::{channels, ical, library, schedule};
const SERVER_NAME: &str = "k-tv-mcp"; const SERVER_NAME: &str = "k-tv-mcp";
@@ -47,6 +47,7 @@ pub struct UpdateChannelParams {
pub timezone: Option<String>, pub timezone: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub schedule_config_json: Option<String>, pub schedule_config_json: Option<String>,
pub gap_filler_json: Option<String>,
} }
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
@@ -73,6 +74,89 @@ pub struct ListGenresParams {
pub content_type: Option<String>, pub content_type: Option<String>,
} }
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BrowseLibraryParams {
/// Filter by content type: movie, episode, short
pub content_type: Option<String>,
/// Filter by genre names
pub genres: Option<Vec<String>>,
/// Full-text search term
pub search_term: Option<String>,
/// Filter by series names
pub series_names: Option<Vec<String>>,
/// Filter by collection IDs
pub collections: Option<Vec<String>>,
/// Filter by decade (e.g. 1990)
pub decade: Option<u16>,
/// Filter by role: program or interstitial
pub role: Option<String>,
/// Minimum duration in seconds
pub min_duration_secs: Option<u32>,
/// Maximum duration in seconds
pub max_duration_secs: Option<u32>,
/// Max items to return (default 50)
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct PreviewScheduleParams {
pub channel_id: String,
/// Hours to preview (default 24)
pub duration_hours: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct PreviewConfigParams {
pub channel_id: String,
/// Full ScheduleConfig as JSON
pub schedule_config_json: String,
/// Hours to preview (default 24)
pub duration_hours: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SuggestScheduleParams {
/// Desired genres for the block
pub genres: Option<Vec<String>>,
/// Content type: movie, episode, short
pub content_type: Option<String>,
/// Name for the programming block
pub block_name: Option<String>,
/// Start time in HH:MM format (default "20:00")
pub start_time: Option<String>,
/// Block duration in minutes (default 180)
pub duration_mins: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SetInterstitialRuleParams {
pub channel_id: String,
pub block_id: String,
/// InterstitialRule as JSON. Omit or null to clear the rule.
pub rule_json: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SetMidRollRuleParams {
pub channel_id: String,
pub block_id: String,
/// MidRollRule as JSON. Omit or null to clear the rule.
pub rule_json: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SetGapFillerParams {
pub channel_id: String,
/// MediaFilter as JSON. Omit or null to clear gap filler.
pub filter_json: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ImportIcalParams {
pub channel_id: String,
pub ical_string: String,
}
fn parse_uuid(s: &str) -> Result<Uuid, String> { fn parse_uuid(s: &str) -> Result<Uuid, String> {
s.parse::<Uuid>() s.parse::<Uuid>()
.map_err(|_| serde_json::json!({"error": format!("invalid UUID: {s}")}).to_string()) .map_err(|_| serde_json::json!({"error": format!("invalid UUID: {s}")}).to_string())
@@ -98,7 +182,7 @@ impl KTvMcpServer {
channels::create_channel(&self.channel_cmd_deps, self.owner_id, &p.name, &p.timezone).await channels::create_channel(&self.channel_cmd_deps, self.owner_id, &p.name, &p.timezone).await
} }
#[tool(description = "Update channel name, timezone, description, and/or schedule config")] #[tool(description = "Update channel name, timezone, description, schedule config, and/or gap_filler")]
async fn update_channel(&self, #[tool(aggr)] p: UpdateChannelParams) -> String { async fn update_channel(&self, #[tool(aggr)] p: UpdateChannelParams) -> String {
let id = match parse_uuid(&p.id) { let id = match parse_uuid(&p.id) {
Ok(id) => id, Ok(id) => id,
@@ -114,14 +198,28 @@ impl KTvMcpServer {
}, },
None => None, None => None,
}; };
let gap_filler = match p.gap_filler_json {
Some(json) if json == "null" => Some(None),
Some(json) => match serde_json::from_str(&json) {
Ok(f) => Some(Some(f)),
Err(e) => {
return serde_json::json!({"error": format!("invalid gap_filler_json: {e}")})
.to_string()
}
},
None => None,
};
channels::update_channel( channels::update_channel(
&self.channel_cmd_deps, &self.channel_cmd_deps,
id, channels::UpdateChannelArgs {
self.owner_id, channel_id: id,
p.name, owner_id: self.owner_id,
p.timezone, name: p.name,
p.description, timezone: p.timezone,
description: p.description,
schedule_config, schedule_config,
gap_filler,
},
) )
.await .await
} }
@@ -186,6 +284,174 @@ impl KTvMcpServer {
) )
.await .await
} }
#[tool(
description = "Browse the library with rich filters. Supports genre, decade, series, content type, role (program/interstitial), duration range. Returns concise summaries."
)]
async fn browse_library(&self, #[tool(aggr)] p: BrowseLibraryParams) -> String {
library::browse_library(
&self.library_command_deps,
library::BrowseParams {
content_type: p.content_type,
genres: p.genres.unwrap_or_default(),
search_term: p.search_term,
series_names: p.series_names.unwrap_or_default(),
collections: p.collections.unwrap_or_default(),
decade: p.decade,
role: p.role,
min_duration_secs: p.min_duration_secs,
max_duration_secs: p.max_duration_secs,
limit: p.limit,
},
)
.await
}
#[tool(
description = "Get aggregate library statistics: total items, items by genre/content type/role, series with episode counts, recently synced items, total duration."
)]
async fn library_stats(&self) -> String {
library::library_stats(&self.library_query, &self.library_command_deps).await
}
#[tool(
description = "Analyze a channel's schedule: most-played items, genre distribution, rotation coverage, upcoming gaps."
)]
async fn analyze_schedule(&self, #[tool(aggr)] p: ChannelIdParam) -> String {
match parse_uuid(&p.channel_id) {
Ok(id) => {
schedule::analyze_schedule(&self.channel_query, &self.schedule_query, id).await
}
Err(e) => e,
}
}
#[tool(
description = "Dry-run schedule generation: preview the next N hours of slots without persisting. Uses the channel's current config."
)]
async fn preview_schedule(&self, #[tool(aggr)] p: PreviewScheduleParams) -> String {
match parse_uuid(&p.channel_id) {
Ok(id) => schedule::preview_schedule(&self.schedule_deps, id, p.duration_hours).await,
Err(e) => e,
}
}
#[tool(
description = "Preview a ScheduleConfig without applying it: provide schedule_config_json and see what slots would be generated. Does not persist."
)]
async fn preview_config(&self, #[tool(aggr)] p: PreviewConfigParams) -> String {
match parse_uuid(&p.channel_id) {
Ok(id) => {
schedule::preview_config(
&self.schedule_deps,
id,
&p.schedule_config_json,
p.duration_hours,
)
.await
}
Err(e) => e,
}
}
#[tool(
description = "Suggest a ScheduleConfig block based on constraints (genres, content type, time). Returns a concrete block config."
)]
async fn suggest_schedule(&self, #[tool(aggr)] p: SuggestScheduleParams) -> String {
schedule::suggest_schedule(
&self.library_query,
p.genres.unwrap_or_default(),
p.content_type,
p.block_name,
p.start_time,
p.duration_mins,
)
.await
}
#[tool(
description = "Set or clear an interstitial rule on a programming block. Pass rule_json with {pool_filter, strategy, min_gap_secs} or omit to clear."
)]
async fn set_interstitial_rule(&self, #[tool(aggr)] p: SetInterstitialRuleParams) -> String {
let channel_id = match parse_uuid(&p.channel_id) {
Ok(id) => id,
Err(e) => return e,
};
let block_id = match parse_uuid(&p.block_id) {
Ok(id) => id,
Err(e) => return e,
};
schedule::set_interstitial_rule(
&self.channel_cmd_deps,
channel_id,
self.owner_id,
block_id,
p.rule_json,
)
.await
}
#[tool(
description = "Set or clear a mid-roll break rule on a programming block. Pass rule_json with {prefer_chapters, fallback_interval_mins, break_duration_secs, pool_filter} or omit to clear."
)]
async fn set_mid_roll_rule(&self, #[tool(aggr)] p: SetMidRollRuleParams) -> String {
let channel_id = match parse_uuid(&p.channel_id) {
Ok(id) => id,
Err(e) => return e,
};
let block_id = match parse_uuid(&p.block_id) {
Ok(id) => id,
Err(e) => return e,
};
schedule::set_mid_roll_rule(
&self.channel_cmd_deps,
channel_id,
self.owner_id,
block_id,
p.rule_json,
)
.await
}
#[tool(
description = "Set or clear the gap filler on a channel. Pass filter_json with a MediaFilter or omit to clear."
)]
async fn set_gap_filler(&self, #[tool(aggr)] p: SetGapFillerParams) -> String {
let channel_id = match parse_uuid(&p.channel_id) {
Ok(id) => id,
Err(e) => return e,
};
schedule::set_gap_filler(
&self.channel_cmd_deps,
channel_id,
self.owner_id,
p.filter_json,
)
.await
}
#[tool(
description = "Export a channel's ScheduleConfig as an iCalendar (.ics) string per RFC 5545."
)]
async fn export_schedule_ical(&self, #[tool(aggr)] p: ChannelIdParam) -> String {
match parse_uuid(&p.channel_id) {
Ok(id) => ical::export_schedule_ical(&self.channel_query, id).await,
Err(e) => e,
}
}
#[tool(
description = "Import an iCalendar (.ics) string to replace a channel's ScheduleConfig. VEVENTs map to ProgrammingBlocks."
)]
async fn import_schedule_ical(&self, #[tool(aggr)] p: ImportIcalParams) -> String {
match parse_uuid(&p.channel_id) {
Ok(id) => {
ical::import_schedule_ical(&self.channel_cmd_deps, id, self.owner_id, &p.ical_string)
.await
}
Err(e) => e,
}
}
} }
#[tool(tool_box)] #[tool(tool_box)]
@@ -199,7 +465,8 @@ impl ServerHandler for KTvMcpServer {
version: env!("CARGO_PKG_VERSION").into(), version: env!("CARGO_PKG_VERSION").into(),
}, },
instructions: Some( instructions: Some(
"K-TV MCP server. Create channels, define programming blocks, generate schedules. \ "K-TV MCP server — creative programming director for linear TV channels. \
Browse the library, analyze schedules, preview configs, configure interstitials and gap fillers. \
All operations run as the user configured via MCP_USER_ID." All operations run as the user configured via MCP_USER_ID."
.into(), .into(),
), ),

View File

@@ -45,24 +45,30 @@ pub async fn create_channel(
} }
} }
pub struct UpdateChannelArgs {
pub channel_id: Uuid,
pub owner_id: Uuid,
pub name: Option<String>,
pub timezone: Option<String>,
pub description: Option<String>,
pub schedule_config: Option<domain::ScheduleConfig>,
pub gap_filler: Option<Option<domain::MediaFilter>>,
}
pub async fn update_channel( pub async fn update_channel(
cmd_deps: &Arc<ChannelCommandDeps>, cmd_deps: &Arc<ChannelCommandDeps>,
channel_id: Uuid, args: UpdateChannelArgs,
owner_id: Uuid,
name: Option<String>,
timezone: Option<String>,
description: Option<String>,
schedule_config: Option<domain::ScheduleConfig>,
) -> String { ) -> String {
let cmd = UpdateChannelCommand { let cmd = UpdateChannelCommand {
channel_id: channel_id.into(), channel_id: args.channel_id.into(),
owner_id: owner_id.into(), owner_id: args.owner_id.into(),
name, name: args.name,
description: description.map(Some), description: args.description.map(Some),
timezone, timezone: args.timezone,
schedule_config, schedule_config: args.schedule_config,
rotation_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
gap_filler: args.gap_filler,
}; };
match application::channels::update::execute(cmd_deps, cmd).await { match application::channels::update::execute(cmd_deps, cmd).await {
Ok(channel) => ok_json(&channel), Ok(channel) => ok_json(&channel),

View File

@@ -0,0 +1,48 @@
use std::sync::Arc;
use application::channels::{ChannelCommandDeps, UpdateChannelCommand};
use uuid::Uuid;
use crate::error::{domain_err, ok_json};
pub async fn export_schedule_ical(
channel_query: &Arc<dyn domain::ports::ChannelQuery>,
channel_id: Uuid,
) -> String {
match channel_query.find_by_id(channel_id.into()).await {
Ok(Some(channel)) => domain::generate_ical(
channel.name(),
channel.timezone(),
channel.schedule_config(),
),
Ok(None) => serde_json::json!({"error": "Channel not found"}).to_string(),
Err(e) => domain_err(e),
}
}
pub async fn import_schedule_ical(
cmd_deps: &Arc<ChannelCommandDeps>,
channel_id: Uuid,
owner_id: Uuid,
ical_str: &str,
) -> String {
let config = match domain::parse_ical(ical_str) {
Ok(c) => c,
Err(e) => return domain_err(e),
};
let cmd = UpdateChannelCommand {
channel_id: channel_id.into(),
owner_id: owner_id.into(),
name: None,
description: None,
timezone: None,
schedule_config: Some(config),
rotation_policy: None,
auto_schedule: None,
gap_filler: None,
};
match application::channels::update::execute(cmd_deps, cmd).await {
Ok(channel) => ok_json(&channel),
Err(e) => domain_err(e),
}
}

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use application::library::SearchItemsQuery; use application::library::SearchItemsQuery;
@@ -6,6 +7,7 @@ use serde::Serialize;
use crate::error::{domain_err, ok_json}; use crate::error::{domain_err, ok_json};
const DEFAULT_SEARCH_LIMIT: u32 = 50; const DEFAULT_SEARCH_LIMIT: u32 = 50;
const STATS_SEARCH_LIMIT: u32 = 10_000;
#[derive(Serialize)] #[derive(Serialize)]
struct CollectionDto { struct CollectionDto {
@@ -30,6 +32,7 @@ struct LibraryItemDto {
tags: Vec<String>, tags: Vec<String>,
collection_id: Option<String>, collection_id: Option<String>,
thumbnail_url: Option<String>, thumbnail_url: Option<String>,
role: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -38,11 +41,78 @@ struct SearchResult {
total: u32, total: u32,
} }
fn content_type_to_str(ct: &domain::ContentType) -> &'static str { #[derive(Serialize)]
match ct { struct BrowseResult {
domain::ContentType::Movie => "movie", items: Vec<BrowseItemDto>,
domain::ContentType::Episode => "episode", total: u32,
domain::ContentType::Short => "short", summary: String,
}
#[derive(Serialize)]
struct BrowseItemDto {
id: String,
title: String,
content_type: String,
duration_mins: u32,
series_name: Option<String>,
season_episode: Option<String>,
year: Option<u16>,
genres: Vec<String>,
role: String,
}
#[derive(Serialize)]
struct LibraryStats {
total_items: u32,
by_content_type: HashMap<String, u32>,
by_role: HashMap<String, u32>,
genres: Vec<GenreStat>,
series: Vec<SeriesStat>,
total_duration_hours: f64,
recently_synced: Vec<RecentSyncDto>,
}
#[derive(Serialize)]
struct GenreStat {
genre: String,
count: u32,
}
#[derive(Serialize)]
struct SeriesStat {
name: String,
episode_count: u32,
season_count: u32,
genres: Vec<String>,
}
#[derive(Serialize)]
struct RecentSyncDto {
provider_id: String,
started_at: String,
status: String,
items_found: u32,
}
use super::{content_type_str, role_str};
fn item_to_dto(i: &domain::MediaItem) -> LibraryItemDto {
LibraryItemDto {
id: i.id().value().to_string(),
provider_id: i.provider_id().to_string(),
external_id: i.external_id().to_string(),
title: i.title().to_string(),
content_type: content_type_str(i.content_type()).to_string(),
duration_secs: i.duration_secs(),
series_name: i.series_name().map(|s| s.to_string()),
season_number: i.season_number(),
episode_number: i.episode_number(),
year: i.year(),
genres: i.genres().to_vec(),
tags: i.tags().to_vec(),
collection_id: i.collection_id().map(|s| s.to_string()),
thumbnail_url: i.thumbnail_url().map(|s| s.to_string()),
role: role_str(i.role()).to_string(),
} }
} }
@@ -103,27 +173,209 @@ pub async fn search_media(
}; };
match application::library::search::execute(library_command_deps, query).await { match application::library::search::execute(library_command_deps, query).await {
Ok((items, total)) => { Ok((items, total)) => {
let dtos: Vec<LibraryItemDto> = items let dtos: Vec<LibraryItemDto> = items.iter().map(item_to_dto).collect();
.into_iter()
.map(|i| LibraryItemDto {
id: i.id().value().to_string(),
provider_id: i.provider_id().to_string(),
external_id: i.external_id().to_string(),
title: i.title().to_string(),
content_type: content_type_to_str(i.content_type()).to_string(),
duration_secs: i.duration_secs(),
series_name: i.series_name().map(|s| s.to_string()),
season_number: i.season_number(),
episode_number: i.episode_number(),
year: i.year(),
genres: i.genres().to_vec(),
tags: i.tags().to_vec(),
collection_id: i.collection_id().map(|s| s.to_string()),
thumbnail_url: i.thumbnail_url().map(|s| s.to_string()),
})
.collect();
ok_json(&SearchResult { items: dtos, total }) ok_json(&SearchResult { items: dtos, total })
} }
Err(e) => domain_err(e), Err(e) => domain_err(e),
} }
} }
pub struct BrowseParams {
pub content_type: Option<String>,
pub genres: Vec<String>,
pub search_term: Option<String>,
pub series_names: Vec<String>,
pub collections: Vec<String>,
pub decade: Option<u16>,
pub role: Option<String>,
pub min_duration_secs: Option<u32>,
pub max_duration_secs: Option<u32>,
pub limit: Option<u32>,
}
pub async fn browse_library(
library_command_deps: &Arc<application::library::LibraryCommandDeps>,
params: BrowseParams,
) -> String {
let BrowseParams {
content_type,
genres,
search_term,
series_names,
collections,
decade,
role,
min_duration_secs,
max_duration_secs,
limit,
} = params;
let ct_str = content_type.clone();
let query = SearchItemsQuery {
provider_id: None,
content_type,
genres: genres.clone(),
search_term: search_term.clone(),
series_names: series_names.clone(),
collection_id: collections.first().cloned(),
season_number: None,
decade,
offset: 0,
limit: limit.unwrap_or(DEFAULT_SEARCH_LIMIT),
};
match application::library::search::execute(library_command_deps, query).await {
Ok((items, total)) => {
let filtered: Vec<_> = items
.iter()
.filter(|i| {
if let Some(ref r) = role {
let item_role = role_str(i.role());
if item_role != r.as_str() {
return false;
}
}
if let Some(min) = min_duration_secs {
if i.duration_secs() < min {
return false;
}
}
if let Some(max) = max_duration_secs {
if i.duration_secs() > max {
return false;
}
}
true
})
.collect();
let dtos: Vec<BrowseItemDto> = filtered
.iter()
.map(|i| {
let se = match (i.season_number(), i.episode_number()) {
(Some(s), Some(e)) => Some(format!("S{s:02}E{e:02}")),
_ => None,
};
BrowseItemDto {
id: i.id().value().to_string(),
title: i.title().to_string(),
content_type: content_type_str(i.content_type()).to_string(),
duration_mins: i.duration_secs() / 60,
series_name: i.series_name().map(|s| s.to_string()),
season_episode: se,
year: i.year(),
genres: i.genres().to_vec(),
role: role_str(i.role()).to_string(),
}
})
.collect();
let mut parts = Vec::new();
parts.push(format!("{} items matched", filtered.len()));
if total > filtered.len() as u32 {
parts.push(format!("({total} total before role/duration filter)"));
}
if let Some(ref ct) = ct_str {
parts.push(format!("type={ct}"));
}
if !genres.is_empty() {
parts.push(format!("genres={}", genres.join(",")));
}
if let Some(ref t) = search_term {
parts.push(format!("search=\"{t}\""));
}
ok_json(&BrowseResult {
items: dtos,
total: filtered.len() as u32,
summary: parts.join(", "),
})
}
Err(e) => domain_err(e),
}
}
pub async fn library_stats(
library_query: &Arc<dyn domain::ports::LibraryQuery>,
library_command_deps: &Arc<application::library::LibraryCommandDeps>,
) -> String {
let all_query = SearchItemsQuery {
provider_id: None,
content_type: None,
genres: vec![],
search_term: None,
series_names: vec![],
collection_id: None,
season_number: None,
decade: None,
offset: 0,
limit: STATS_SEARCH_LIMIT,
};
let (items, total) = match application::library::search::execute(library_command_deps, all_query)
.await
{
Ok(r) => r,
Err(e) => return domain_err(e),
};
let mut by_content_type: HashMap<String, u32> = HashMap::new();
let mut by_role: HashMap<String, u32> = HashMap::new();
let mut genre_counts: HashMap<String, u32> = HashMap::new();
let mut total_duration_secs: u64 = 0;
for item in &items {
*by_content_type
.entry(content_type_str(item.content_type()).to_string())
.or_default() += 1;
*by_role
.entry(role_str(item.role()).to_string())
.or_default() += 1;
for genre in item.genres() {
*genre_counts.entry(genre.clone()).or_default() += 1;
}
total_duration_secs += item.duration_secs() as u64;
}
let mut genres: Vec<GenreStat> = genre_counts
.into_iter()
.map(|(genre, count)| GenreStat { genre, count })
.collect();
genres.sort_by_key(|g| std::cmp::Reverse(g.count));
let shows = match library_query.list_shows(None, None, &[]).await {
Ok(s) => s,
Err(e) => return domain_err(e),
};
let series: Vec<SeriesStat> = shows
.iter()
.map(|s| SeriesStat {
name: s.series_name().to_string(),
episode_count: s.episode_count(),
season_count: s.season_count(),
genres: s.genres().to_vec(),
})
.collect();
let recently_synced = match library_query.latest_sync_status().await {
Ok(logs) => logs
.iter()
.map(|l| RecentSyncDto {
provider_id: l.provider_id().to_string(),
started_at: l.started_at().to_string(),
status: l.status().to_string(),
items_found: l.items_found(),
})
.collect(),
Err(_) => vec![],
};
ok_json(&LibraryStats {
total_items: total,
by_content_type,
by_role,
genres,
series,
total_duration_hours: total_duration_secs as f64 / 3600.0,
recently_synced,
})
}

View File

@@ -1,3 +1,19 @@
pub mod channels; pub mod channels;
pub mod ical;
pub mod library; pub mod library;
pub mod schedule; pub mod schedule;
pub(crate) fn content_type_str(ct: &domain::ContentType) -> &'static str {
match ct {
domain::ContentType::Movie => "movie",
domain::ContentType::Episode => "episode",
domain::ContentType::Short => "short",
}
}
pub(crate) fn role_str(r: &domain::MediaRole) -> &'static str {
match r {
domain::MediaRole::Program => "program",
domain::MediaRole::Interstitial => "interstitial",
}
}

View File

@@ -1,11 +1,11 @@
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use application::schedule::{ use application::channels::ChannelCommandDeps;
GenerateScheduleCommand, GetCurrentBroadcastQuery, ScheduleDeps, use application::schedule::{GenerateScheduleCommand, GetCurrentBroadcastQuery, ScheduleDeps};
};
use chrono::Utc; use chrono::Utc;
use domain::ScheduledSlot; use domain::value_objects::{ChannelId, MediaFilter};
use domain::value_objects::ChannelId; use domain::{InterstitialRule, MidRollRule, ScheduleConfig, ScheduledSlot};
use serde::Serialize; use serde::Serialize;
use uuid::Uuid; use uuid::Uuid;
@@ -51,3 +51,522 @@ pub async fn get_current_broadcast(deps: &Arc<ScheduleDeps>, channel_id: Uuid) -
Err(e) => domain_err(e), Err(e) => domain_err(e),
} }
} }
#[derive(Serialize)]
struct ScheduleAnalysis {
channel_id: String,
channel_name: String,
has_schedule: bool,
schedule_valid_from: Option<String>,
schedule_valid_until: Option<String>,
total_slots: usize,
total_hours: f64,
most_played: Vec<ItemPlayCount>,
genre_distribution: HashMap<String, u32>,
block_coverage: Vec<BlockCoverage>,
upcoming_gaps: Vec<GapInfo>,
}
#[derive(Serialize)]
struct ItemPlayCount {
title: String,
content_type: String,
count: u32,
}
#[derive(Serialize)]
struct BlockCoverage {
block_name: String,
slot_count: usize,
total_minutes: f64,
}
#[derive(Serialize)]
struct GapInfo {
from: String,
to: String,
duration_mins: f64,
}
pub async fn analyze_schedule(
channel_query: &Arc<dyn domain::ports::ChannelQuery>,
schedule_query: &Arc<dyn domain::ports::ScheduleQuery>,
channel_id: Uuid,
) -> String {
let cid = ChannelId::from(channel_id);
let channel = match channel_query.find_by_id(cid).await {
Ok(Some(c)) => c,
Ok(None) => {
return serde_json::json!({"error": "Channel not found"}).to_string();
}
Err(e) => return domain_err(e),
};
let schedule = match schedule_query.find_latest(cid).await {
Ok(s) => s,
Err(e) => return domain_err(e),
};
let (has_schedule, valid_from, valid_until, slots) = match &schedule {
Some(s) => (
true,
Some(s.valid_from().to_rfc3339()),
Some(s.valid_until().to_rfc3339()),
s.slots(),
),
None => (false, None, None, [].as_slice()),
};
let mut title_counts: HashMap<String, (String, u32)> = HashMap::new();
let mut genre_distribution: HashMap<String, u32> = HashMap::new();
let mut block_slots: HashMap<String, (usize, f64)> = HashMap::new();
let mut total_secs: f64 = 0.0;
for slot in slots {
let duration =
(slot.end_at() - slot.start_at()).num_seconds() as f64;
total_secs += duration;
let title_key = slot.item().title().to_string();
let entry = title_counts
.entry(title_key)
.or_insert_with(|| {
(super::content_type_str(slot.item().content_type()).to_string(), 0)
});
entry.1 += 1;
for genre in slot.item().genres() {
*genre_distribution.entry(genre.clone()).or_default() += 1;
}
let block_name = slot.source_block_id().to_string();
let block_entry = block_slots.entry(block_name).or_insert((0, 0.0));
block_entry.0 += 1;
block_entry.1 += duration / 60.0;
}
let config = channel.schedule_config();
let block_name_map: HashMap<String, String> = config
.all_blocks()
.map(|b| (b.id().to_string(), b.name().to_string()))
.collect();
let mut most_played: Vec<ItemPlayCount> = title_counts
.into_iter()
.map(|(title, (ct, count))| ItemPlayCount {
title,
content_type: ct,
count,
})
.collect();
most_played.sort_by_key(|i| std::cmp::Reverse(i.count));
most_played.truncate(20);
let block_coverage: Vec<BlockCoverage> = block_slots
.into_iter()
.map(|(block_id, (slot_count, total_minutes))| {
let name = block_name_map
.get(&block_id)
.cloned()
.unwrap_or(block_id);
BlockCoverage {
block_name: name,
slot_count,
total_minutes,
}
})
.collect();
let mut upcoming_gaps = Vec::new();
let now = Utc::now();
let future_slots: Vec<&ScheduledSlot> = slots
.iter()
.filter(|s| s.end_at() > now)
.collect();
for window in future_slots.windows(2) {
let gap_secs = (window[1].start_at() - window[0].end_at()).num_seconds();
if gap_secs > 60 {
upcoming_gaps.push(GapInfo {
from: window[0].end_at().to_rfc3339(),
to: window[1].start_at().to_rfc3339(),
duration_mins: gap_secs as f64 / 60.0,
});
}
}
upcoming_gaps.truncate(10);
ok_json(&ScheduleAnalysis {
channel_id: channel_id.to_string(),
channel_name: channel.name().to_string(),
has_schedule,
schedule_valid_from: valid_from,
schedule_valid_until: valid_until,
total_slots: slots.len(),
total_hours: total_secs / 3600.0,
most_played,
genre_distribution,
block_coverage,
upcoming_gaps,
})
}
#[derive(Serialize)]
struct PreviewResult {
slot_count: usize,
total_hours: f64,
slots: Vec<PreviewSlotDto>,
}
#[derive(Serialize)]
struct PreviewSlotDto {
start_at: String,
end_at: String,
title: String,
content_type: String,
duration_mins: f64,
block_id: String,
}
pub async fn preview_schedule(
schedule_deps: &Arc<ScheduleDeps>,
channel_id: Uuid,
duration_hours: Option<u32>,
) -> String {
let cid = ChannelId::from(channel_id);
let hours = duration_hours.unwrap_or(24);
match schedule_deps
.schedule_engine
.preview_schedule(cid, Utc::now(), hours)
.await
{
Ok(schedule) => {
let total_secs: f64 = schedule
.slots()
.iter()
.map(|s| (s.end_at() - s.start_at()).num_seconds() as f64)
.sum();
let slots: Vec<PreviewSlotDto> = schedule
.slots()
.iter()
.map(|s| PreviewSlotDto {
start_at: s.start_at().to_rfc3339(),
end_at: s.end_at().to_rfc3339(),
title: s.item().title().to_string(),
content_type: super::content_type_str(s.item().content_type()).to_string(),
duration_mins: (s.end_at() - s.start_at()).num_seconds() as f64 / 60.0,
block_id: s.source_block_id().to_string(),
})
.collect();
ok_json(&PreviewResult {
slot_count: slots.len(),
total_hours: total_secs / 3600.0,
slots,
})
}
Err(e) => domain_err(e),
}
}
pub async fn preview_config(
schedule_deps: &Arc<ScheduleDeps>,
channel_id: Uuid,
config_json: &str,
duration_hours: Option<u32>,
) -> String {
let config: ScheduleConfig = match serde_json::from_str(config_json) {
Ok(c) => c,
Err(e) => {
return serde_json::json!({"error": format!("invalid schedule config: {e}")})
.to_string();
}
};
let cid = ChannelId::from(channel_id);
let hours = duration_hours.unwrap_or(24);
match schedule_deps
.schedule_engine
.preview_config(cid, &config, Utc::now(), hours)
.await
{
Ok(schedule) => {
let total_secs: f64 = schedule
.slots()
.iter()
.map(|s| (s.end_at() - s.start_at()).num_seconds() as f64)
.sum();
let slots: Vec<PreviewSlotDto> = schedule
.slots()
.iter()
.map(|s| PreviewSlotDto {
start_at: s.start_at().to_rfc3339(),
end_at: s.end_at().to_rfc3339(),
title: s.item().title().to_string(),
content_type: super::content_type_str(s.item().content_type()).to_string(),
duration_mins: (s.end_at() - s.start_at()).num_seconds() as f64 / 60.0,
block_id: s.source_block_id().to_string(),
})
.collect();
ok_json(&PreviewResult {
slot_count: slots.len(),
total_hours: total_secs / 3600.0,
slots,
})
}
Err(e) => domain_err(e),
}
}
pub async fn suggest_schedule(
library_query: &Arc<dyn domain::ports::LibraryQuery>,
genres: Vec<String>,
content_type: Option<String>,
time_block_name: Option<String>,
start_time: Option<String>,
duration_mins: Option<u32>,
) -> String {
let ct = match content_type
.as_deref()
.map(application::library::parse_content_type)
.transpose()
{
Ok(ct) => ct,
Err(e) => return domain_err(e),
};
let available_genres = if genres.is_empty() {
match library_query.list_genres(ct.as_ref(), None).await {
Ok(g) => g,
Err(e) => return domain_err(e),
}
} else {
genres.clone()
};
let filter = MediaFilter {
content_type: ct.clone(),
genres: genres.clone(),
..Default::default()
};
let block_name = time_block_name.unwrap_or_else(|| {
if !genres.is_empty() {
format!("{} Block", genres.join("/"))
} else if let Some(ref c) = ct {
let label = match c {
domain::ContentType::Movie => "Movie",
domain::ContentType::Episode => "Episode",
domain::ContentType::Short => "Short",
};
format!("{label} Block")
} else {
"Programming Block".to_string()
}
});
let start = start_time.unwrap_or_else(|| "20:00".to_string());
let dur = duration_mins.unwrap_or(180);
let strategy = if ct.as_ref() == Some(&domain::ContentType::Movie) {
"best_fit"
} else if ct.as_ref() == Some(&domain::ContentType::Episode) {
"sequential"
} else {
"random"
};
let suggestion = serde_json::json!({
"suggested_block": {
"name": block_name,
"start_time": start,
"duration_mins": dur,
"content": {
"type": "algorithmic",
"filter": filter,
"strategy": strategy,
},
"loop_on_finish": true,
},
"available_genres": available_genres,
"notes": format!(
"Suggested a {} block with {} strategy. Adjust start_time, duration_mins, and filter as needed. Apply via update_channel with schedule_config_json.",
block_name, strategy
),
});
ok_json(&suggestion)
}
pub async fn set_interstitial_rule(
channel_cmd_deps: &Arc<ChannelCommandDeps>,
channel_id: Uuid,
owner_id: Uuid,
block_id: Uuid,
rule_json: Option<String>,
) -> String {
let cid = ChannelId::from(channel_id);
let channel = match channel_cmd_deps
.channel_query
.find_by_id(cid)
.await
{
Ok(Some(c)) => c,
Ok(None) => {
return serde_json::json!({"error": "Channel not found"}).to_string();
}
Err(e) => return domain_err(e),
};
if channel.owner_id() != domain::value_objects::UserId::from(owner_id) {
return serde_json::json!({"error": "Forbidden"}).to_string();
}
let rule: Option<InterstitialRule> = match rule_json {
Some(json) => match serde_json::from_str(&json) {
Ok(r) => Some(r),
Err(e) => {
return serde_json::json!({"error": format!("invalid interstitial rule: {e}")})
.to_string();
}
},
None => None,
};
let mut config = channel.schedule_config().clone();
let bid = domain::value_objects::BlockId::from(block_id);
match config.find_block_mut(bid) {
Some(block) => {
block.set_interstitial_rule(rule);
}
None => {
return serde_json::json!({"error": "Block not found in schedule config"}).to_string();
}
}
let cmd = application::channels::UpdateChannelCommand {
channel_id: cid,
owner_id: owner_id.into(),
name: None,
description: None,
timezone: None,
schedule_config: Some(config),
rotation_policy: None,
auto_schedule: None,
gap_filler: None,
};
match application::channels::update::execute(channel_cmd_deps, cmd).await {
Ok(ch) => ok_json(&ch),
Err(e) => domain_err(e),
}
}
pub async fn set_mid_roll_rule(
channel_cmd_deps: &Arc<ChannelCommandDeps>,
channel_id: Uuid,
owner_id: Uuid,
block_id: Uuid,
rule_json: Option<String>,
) -> String {
let cid = ChannelId::from(channel_id);
let channel = match channel_cmd_deps
.channel_query
.find_by_id(cid)
.await
{
Ok(Some(c)) => c,
Ok(None) => {
return serde_json::json!({"error": "Channel not found"}).to_string();
}
Err(e) => return domain_err(e),
};
if channel.owner_id() != domain::value_objects::UserId::from(owner_id) {
return serde_json::json!({"error": "Forbidden"}).to_string();
}
let rule: Option<MidRollRule> = match rule_json {
Some(json) => match serde_json::from_str(&json) {
Ok(r) => Some(r),
Err(e) => {
return serde_json::json!({"error": format!("invalid mid-roll rule: {e}")})
.to_string();
}
},
None => None,
};
let mut config = channel.schedule_config().clone();
let bid = domain::value_objects::BlockId::from(block_id);
match config.find_block_mut(bid) {
Some(block) => {
block.set_mid_roll_rule(rule);
}
None => {
return serde_json::json!({"error": "Block not found in schedule config"}).to_string();
}
}
let cmd = application::channels::UpdateChannelCommand {
channel_id: cid,
owner_id: owner_id.into(),
name: None,
description: None,
timezone: None,
schedule_config: Some(config),
rotation_policy: None,
auto_schedule: None,
gap_filler: None,
};
match application::channels::update::execute(channel_cmd_deps, cmd).await {
Ok(ch) => ok_json(&ch),
Err(e) => domain_err(e),
}
}
pub async fn set_gap_filler(
channel_cmd_deps: &Arc<ChannelCommandDeps>,
channel_id: Uuid,
owner_id: Uuid,
filter_json: Option<String>,
) -> String {
let filter: Option<MediaFilter> = match filter_json {
Some(json) => match serde_json::from_str(&json) {
Ok(f) => Some(f),
Err(e) => {
return serde_json::json!({"error": format!("invalid media filter: {e}")})
.to_string();
}
},
None => None,
};
let cmd = application::channels::UpdateChannelCommand {
channel_id: channel_id.into(),
owner_id: owner_id.into(),
name: None,
description: None,
timezone: None,
schedule_config: None,
rotation_policy: None,
auto_schedule: None,
gap_filler: Some(filter),
};
match application::channels::update::execute(channel_cmd_deps, cmd).await {
Ok(ch) => ok_json(&ch),
Err(e) => domain_err(e),
}
}

33
crates/playout/Cargo.toml Normal file
View File

@@ -0,0 +1,33 @@
[package]
name = "playout"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "k-tv-playout"
path = "src/main.rs"
[dependencies]
domain = { workspace = true }
axum = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }
anyhow = "1"
dotenvy = "0.15"
bytes = "1"
futures = "0.3"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }

View File

@@ -0,0 +1,50 @@
use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct PlayoutConfig {
pub listen_addr: String,
pub segment_duration_secs: u32,
pub window_size: usize,
pub storage_path: PathBuf,
pub tick_interval_ms: u64,
pub overlay_trigger_offset: Duration,
}
impl Default for PlayoutConfig {
fn default() -> Self {
Self {
listen_addr: "0.0.0.0:9090".into(),
segment_duration_secs: 6,
window_size: 10,
storage_path: PathBuf::from("/tmp/k-tv-playout"),
tick_interval_ms: 1000,
overlay_trigger_offset: Duration::from_secs(30),
}
}
}
impl PlayoutConfig {
pub fn from_env() -> Self {
let mut config = Self::default();
if let Ok(v) = std::env::var("PLAYOUT_LISTEN_ADDR") {
config.listen_addr = v;
}
if let Some(v) = std::env::var("PLAYOUT_SEGMENT_DURATION").ok().and_then(|v| v.parse().ok()) {
config.segment_duration_secs = v;
}
if let Some(v) = std::env::var("PLAYOUT_WINDOW_SIZE").ok().and_then(|v| v.parse().ok()) {
config.window_size = v;
}
if let Ok(v) = std::env::var("PLAYOUT_STORAGE_PATH") {
config.storage_path = PathBuf::from(v);
}
if let Some(v) = std::env::var("PLAYOUT_TICK_INTERVAL_MS").ok().and_then(|v| v.parse().ok()) {
config.tick_interval_ms = v;
}
if let Some(v) = std::env::var("PLAYOUT_OVERLAY_OFFSET_SECS").ok().and_then(|v| v.parse::<u64>().ok()) {
config.overlay_trigger_offset = Duration::from_secs(v);
}
config
}
}

View File

@@ -0,0 +1,263 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use chrono::Utc;
use domain::models::{CurrentBroadcast, GeneratedSchedule};
use domain::ports::ScheduleQuery;
use domain::value_objects::{ChannelId, SourceUri};
use domain::{ScheduleEngineService, SlotId};
use serde::Serialize;
use tokio::sync::RwLock;
use tracing::{error, info, warn};
use crate::config::PlayoutConfig;
use crate::ffmpeg::{FfmpegConfig, FfmpegHandle};
use crate::segment_store::SegmentStore;
pub trait SourceUriResolver: Send + Sync {
fn resolve(&self, provider_id: &str, external_id: &str) -> Option<SourceUri>;
}
struct ChannelState {
ffmpeg: FfmpegHandle,
current_slot_id: SlotId,
schedule_id: domain::value_objects::ScheduleId,
}
#[derive(Debug, Clone, Serialize)]
pub struct ChannelStatus {
pub channel_id: String,
pub current_slot_id: String,
pub schedule_id: String,
pub ffmpeg_running: bool,
}
pub struct PlayoutEngine {
config: PlayoutConfig,
schedule_query: Arc<dyn ScheduleQuery>,
source_resolver: Arc<dyn SourceUriResolver>,
store: Arc<dyn SegmentStore>,
channels: Arc<RwLock<HashMap<ChannelId, ChannelState>>>,
}
impl PlayoutEngine {
pub fn new(
config: PlayoutConfig,
schedule_query: Arc<dyn ScheduleQuery>,
source_resolver: Arc<dyn SourceUriResolver>,
store: Arc<dyn SegmentStore>,
) -> Self {
Self {
config,
schedule_query,
source_resolver,
store,
channels: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn add_channel(&self, channel_id: ChannelId) -> anyhow::Result<()> {
{
let channels = self.channels.read().await;
if channels.contains_key(&channel_id) {
return Err(anyhow::anyhow!("channel {channel_id} already active"));
}
}
let now = Utc::now();
let schedule = self
.schedule_query
.find_active(channel_id, now)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?
.ok_or_else(|| anyhow::anyhow!("no active schedule for {channel_id}"))?;
let broadcast = ScheduleEngineService::get_current_broadcast(&schedule, now)
.ok_or_else(|| anyhow::anyhow!("no current slot for {channel_id}"))?;
let handle = self.start_slot(channel_id, &broadcast, &schedule)?;
let state = ChannelState {
ffmpeg: handle,
current_slot_id: broadcast.slot().id(),
schedule_id: schedule.id(),
};
self.channels.write().await.insert(channel_id, state);
info!(%channel_id, "channel added");
Ok(())
}
pub async fn remove_channel(&self, channel_id: ChannelId) {
if let Some(mut state) = self.channels.write().await.remove(&channel_id) {
state.ffmpeg.stop().await;
info!(%channel_id, "channel removed");
}
}
pub async fn active_channels(&self) -> Vec<ChannelId> {
self.channels.read().await.keys().copied().collect()
}
pub async fn channel_statuses(&self) -> Vec<ChannelStatus> {
let mut channels = self.channels.write().await;
channels
.iter_mut()
.map(|(id, state)| ChannelStatus {
channel_id: id.to_string(),
current_slot_id: state.current_slot_id.to_string(),
schedule_id: state.schedule_id.to_string(),
ffmpeg_running: state.ffmpeg.is_running(),
})
.collect()
}
pub async fn tick(&self) {
let now = Utc::now();
let channel_ids: Vec<ChannelId> = self.channels.read().await.keys().copied().collect();
let futures: Vec<_> = channel_ids
.into_iter()
.map(|id| self.tick_channel(id, now))
.collect();
let results = futures::future::join_all(futures).await;
for result in results {
if let Err(e) = result {
warn!(%e, "channel tick failed");
}
}
}
async fn tick_channel(
&self,
channel_id: ChannelId,
now: chrono::DateTime<Utc>,
) -> anyhow::Result<()> {
let schedule = match self.schedule_query.find_active(channel_id, now).await {
Ok(Some(s)) => s,
Ok(None) => {
warn!(%channel_id, "no active schedule, removing");
self.remove_channel(channel_id).await;
return Ok(());
}
Err(e) => return Err(anyhow::anyhow!("{e}")),
};
let broadcast = match ScheduleEngineService::get_current_broadcast(&schedule, now) {
Some(b) => b,
None => return Ok(()),
};
let current_slot_id = broadcast.slot().id();
let needs_transition = {
let channels = self.channels.read().await;
channels
.get(&channel_id)
.map(|s| s.current_slot_id != current_slot_id || s.schedule_id != schedule.id())
.unwrap_or(false)
};
let ffmpeg_dead = {
let mut channels = self.channels.write().await;
channels
.get_mut(&channel_id)
.map(|s| !s.ffmpeg.is_running())
.unwrap_or(false)
};
if needs_transition || ffmpeg_dead {
if needs_transition {
info!(%channel_id, %current_slot_id, "slot transition");
} else {
warn!(%channel_id, "ffmpeg process died, restarting");
}
let mut channels = self.channels.write().await;
if let Some(state) = channels.get_mut(&channel_id) {
state.ffmpeg.stop().await;
state.ffmpeg = self.start_slot(channel_id, &broadcast, &schedule)?;
state.current_slot_id = current_slot_id;
state.schedule_id = schedule.id();
}
}
self.cleanup_old_segments(channel_id).await;
Ok(())
}
fn start_slot(
&self,
channel_id: ChannelId,
broadcast: &CurrentBroadcast,
_schedule: &GeneratedSchedule,
) -> anyhow::Result<FfmpegHandle> {
let slot = broadcast.slot();
let item = slot.item();
let source_uri = self
.source_resolver
.resolve(item.provider_id(), item.external_id())
.ok_or_else(|| {
anyhow::anyhow!(
"no source uri for {}::{}",
item.provider_id(),
item.external_id()
)
})?;
let uri_str = match &source_uri {
SourceUri::NetworkUrl { url } => url.clone(),
SourceUri::FilePath { path } => path.clone(),
};
let output_dir = self.output_dir(channel_id);
let config = FfmpegConfig {
source_uri: uri_str,
start_offset_secs: broadcast.offset_secs(),
segment_duration_secs: self.config.segment_duration_secs,
output_dir,
segment_prefix: "seg".into(),
};
let handle = FfmpegHandle::spawn(config, self.store.clone(), channel_id.to_string());
Ok(handle)
}
fn output_dir(&self, channel_id: ChannelId) -> PathBuf {
self.config.storage_path.join(channel_id.to_string())
}
async fn cleanup_old_segments(&self, channel_id: ChannelId) {
let channel_str = channel_id.to_string();
let segments = match self.store.list_segments(&channel_str).await {
Ok(s) => s,
Err(_) => return,
};
if segments.len() <= self.config.window_size {
return;
}
let to_delete = segments.len() - self.config.window_size;
for name in segments.iter().take(to_delete) {
if let Err(e) = self.store.delete_segment(&channel_str, name).await {
error!(%e, segment = %name, "failed to delete old segment");
}
}
}
pub async fn shutdown(&self) {
let mut channels = self.channels.write().await;
for (id, state) in channels.iter_mut() {
info!(%id, "stopping channel");
state.ffmpeg.stop().await;
}
channels.clear();
}
}

View File

@@ -0,0 +1,161 @@
use std::path::PathBuf;
use std::sync::Arc;
use bytes::Bytes;
use tokio::process::{Child, Command};
use tokio::sync::Notify;
use tracing::{error, info, warn};
use crate::segment_store::SegmentStore;
pub struct FfmpegConfig {
pub source_uri: String,
pub start_offset_secs: u32,
pub segment_duration_secs: u32,
pub output_dir: PathBuf,
pub segment_prefix: String,
}
pub struct FfmpegHandle {
child: Option<Child>,
stop: Arc<Notify>,
}
impl FfmpegHandle {
pub fn spawn(
config: FfmpegConfig,
store: Arc<dyn SegmentStore>,
channel_id: String,
) -> Self {
let stop = Arc::new(Notify::new());
let stop_clone = stop.clone();
let child = match Self::start_ffmpeg(&config) {
Ok(child) => {
let output_dir = config.output_dir.clone();
tokio::spawn(Self::ingest_loop(
store,
channel_id,
output_dir,
stop_clone,
));
Some(child)
}
Err(e) => {
error!(%e, "failed to start ffmpeg");
None
}
};
Self {
child,
stop,
}
}
fn start_ffmpeg(config: &FfmpegConfig) -> std::io::Result<Child> {
std::fs::create_dir_all(&config.output_dir)?;
let mut cmd = Command::new("ffmpeg");
cmd.args(["-re"])
.args(["-ss", &config.start_offset_secs.to_string()])
.args(["-i", &config.source_uri])
.args(["-map", "0:v?"])
.args(["-map", "0:a?"])
.args(["-map", "0:s?"])
.args(["-c:v", "copy"])
.args(["-c:a", "aac"])
.args(["-c:s", "webvtt"])
.args(["-f", "hls"])
.args([
"-hls_time",
&config.segment_duration_secs.to_string(),
])
.args(["-hls_list_size", "0"])
.args(["-hls_flags", "independent_segments"])
.args([
"-hls_segment_filename",
&config
.output_dir
.join(format!("{}%05d.ts", config.segment_prefix))
.to_string_lossy(),
])
.arg(
config
.output_dir
.join("live.m3u8")
.to_string_lossy()
.to_string(),
)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
info!(source = %config.source_uri, "starting ffmpeg");
cmd.spawn()
}
async fn ingest_loop(
store: Arc<dyn SegmentStore>,
channel_id: String,
output_dir: PathBuf,
stop: Arc<Notify>,
) {
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(500));
let mut known_segments: std::collections::HashSet<String> =
std::collections::HashSet::new();
loop {
tokio::select! {
_ = stop.notified() => break,
_ = interval.tick() => {}
}
let entries = match tokio::fs::read_dir(&output_dir).await {
Ok(e) => e,
Err(_) => continue,
};
let mut entries = entries;
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".ts") || known_segments.contains(&name) {
continue;
}
match tokio::fs::read(entry.path()).await {
Ok(data) => {
if let Err(e) = store
.write_segment(&channel_id, &name, Bytes::from(data))
.await
{
warn!(%e, segment = %name, "failed to write segment to store");
} else {
known_segments.insert(name);
}
}
Err(e) => {
warn!(%e, segment = %name, "failed to read segment file");
}
}
}
}
}
pub async fn stop(&mut self) {
self.stop.notify_one();
if let Some(ref mut child) = self.child {
let _ = child.kill().await;
let _ = child.wait().await;
}
self.child = None;
}
pub fn is_running(&mut self) -> bool {
match &mut self.child {
Some(child) => child.try_wait().ok().flatten().is_none(),
None => false,
}
}
}

120
crates/playout/src/http.rs Normal file
View File

@@ -0,0 +1,120 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::Router;
use domain::value_objects::ChannelId;
use crate::config::PlayoutConfig;
use crate::engine::PlayoutEngine;
use crate::playlist::generate_m3u8;
use crate::segment_store::SegmentStore;
#[derive(Clone)]
pub struct AppState {
pub engine: Arc<PlayoutEngine>,
pub store: Arc<dyn SegmentStore>,
pub config: PlayoutConfig,
}
pub fn router(state: AppState) -> Router {
Router::new()
.route(
"/playout/{channel_id}/playlist.m3u8",
get(get_playlist),
)
.route("/playout/{channel_id}/{segment}", get(get_segment))
.route("/playout/channels", get(list_channels))
.route("/playout/channels/status", get(channel_statuses))
.route("/playout/channels/{channel_id}", post(add_channel))
.route(
"/playout/channels/{channel_id}",
delete(remove_channel),
)
.with_state(state)
}
async fn get_playlist(
State(state): State<AppState>,
Path(channel_id): Path<String>,
) -> Response {
let segments = match state.store.list_segments(&channel_id).await {
Ok(s) => s,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let body = generate_m3u8(
&channel_id,
&segments,
state.config.segment_duration_secs,
state.config.window_size,
);
let mut response = body.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/vnd.apple.mpegurl"),
);
response
}
async fn get_segment(
State(state): State<AppState>,
Path((channel_id, segment)): Path<(String, String)>,
) -> Response {
match state.store.read_segment(&channel_id, &segment).await {
Ok(data) => {
let mut response = data.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("video/mp2t"),
);
response
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
async fn list_channels(State(state): State<AppState>) -> Response {
let channels = state.engine.active_channels().await;
let ids: Vec<String> = channels.into_iter().map(|c| c.to_string()).collect();
axum::Json(ids).into_response()
}
async fn channel_statuses(State(state): State<AppState>) -> Response {
let statuses = state.engine.channel_statuses().await;
axum::Json(statuses).into_response()
}
async fn add_channel(
State(state): State<AppState>,
Path(channel_id): Path<String>,
) -> Response {
let id: ChannelId = match channel_id.parse() {
Ok(id) => id,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
match state.engine.add_channel(id).await {
Ok(()) => StatusCode::CREATED.into_response(),
Err(e) => {
tracing::error!(%e, "add channel failed");
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
}
}
async fn remove_channel(
State(state): State<AppState>,
Path(channel_id): Path<String>,
) -> Response {
let id: ChannelId = match channel_id.parse() {
Ok(id) => id,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
state.engine.remove_channel(id).await;
StatusCode::NO_CONTENT.into_response()
}

View File

@@ -0,0 +1,8 @@
pub mod config;
pub mod engine;
pub mod ffmpeg;
pub mod http;
pub mod metadata;
pub mod playlist;
pub mod scte35;
pub mod segment_store;

132
crates/playout/src/main.rs Normal file
View File

@@ -0,0 +1,132 @@
use std::sync::Arc;
use tokio::net::TcpListener;
use tracing::info;
use playout::config::PlayoutConfig;
use playout::engine::{PlayoutEngine, SourceUriResolver};
use playout::http::{self, AppState};
use playout::segment_store::filesystem::FilesystemSegmentStore;
use domain::value_objects::SourceUri;
struct StubResolver;
impl SourceUriResolver for StubResolver {
fn resolve(&self, _provider_id: &str, _external_id: &str) -> Option<SourceUri> {
None
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let config = PlayoutConfig::from_env();
info!(addr = %config.listen_addr, "starting k-tv-playout");
let store = Arc::new(FilesystemSegmentStore::new(config.storage_path.clone()));
let resolver: Arc<dyn SourceUriResolver> = Arc::new(StubResolver);
// TODO: wire real ScheduleQuery from adapter-sqlite once DB URL is configured
let schedule_query: Arc<dyn domain::ports::ScheduleQuery> =
Arc::new(NoopScheduleQuery);
let engine = Arc::new(PlayoutEngine::new(
config.clone(),
schedule_query,
resolver,
store.clone(),
));
let tick_engine = engine.clone();
let tick_interval = config.tick_interval_ms;
tokio::spawn(async move {
let mut interval =
tokio::time::interval(tokio::time::Duration::from_millis(tick_interval));
loop {
interval.tick().await;
tick_engine.tick().await;
}
});
let state = AppState {
engine: engine.clone(),
store,
config: config.clone(),
};
let app = http::router(state);
let listener = TcpListener::bind(&config.listen_addr).await?;
info!("listening on {}", config.listen_addr);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(engine))
.await?;
Ok(())
}
async fn shutdown_signal(engine: Arc<PlayoutEngine>) {
tokio::signal::ctrl_c()
.await
.expect("failed to install ctrl+c handler");
info!("shutting down");
engine.shutdown().await;
}
struct NoopScheduleQuery;
#[async_trait::async_trait]
impl domain::ports::ScheduleQuery for NoopScheduleQuery {
async fn find_active(
&self,
_channel_id: domain::value_objects::ChannelId,
_at: chrono::DateTime<chrono::Utc>,
) -> domain::DomainResult<Option<domain::GeneratedSchedule>> {
Ok(None)
}
async fn find_latest(
&self,
_channel_id: domain::value_objects::ChannelId,
) -> domain::DomainResult<Option<domain::GeneratedSchedule>> {
Ok(None)
}
async fn find_playback_history(
&self,
_channel_id: domain::value_objects::ChannelId,
) -> domain::DomainResult<Vec<domain::PlaybackRecord>> {
Ok(vec![])
}
async fn find_last_slot_per_block(
&self,
_channel_id: domain::value_objects::ChannelId,
) -> domain::DomainResult<std::collections::HashMap<domain::BlockId, domain::MediaItemId>> {
Ok(std::collections::HashMap::new())
}
async fn list_schedule_history(
&self,
_channel_id: domain::value_objects::ChannelId,
) -> domain::DomainResult<Vec<domain::GeneratedSchedule>> {
Ok(vec![])
}
async fn get_schedule_by_id(
&self,
_channel_id: domain::value_objects::ChannelId,
_schedule_id: domain::value_objects::ScheduleId,
) -> domain::DomainResult<Option<domain::GeneratedSchedule>> {
Ok(None)
}
}

View File

@@ -0,0 +1,47 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OverlayPayload {
pub title: String,
pub thumbnail_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TimedMetadata {
pub trigger_at: DateTime<Utc>,
pub payload: OverlayPayload,
}
impl TimedMetadata {
pub fn to_id3_json(&self) -> String {
serde_json::to_string(&self.payload).expect("OverlayPayload is always serializable")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payload_serializes_to_json() {
let payload = OverlayPayload {
title: "Next: The Matrix".into(),
thumbnail_url: Some("https://example.com/matrix.jpg".into()),
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("Next: The Matrix"));
assert!(json.contains("matrix.jpg"));
}
#[test]
fn payload_without_thumbnail() {
let payload = OverlayPayload {
title: "Coming up: News".into(),
thumbnail_url: None,
};
let json = serde_json::to_string(&payload).unwrap();
let roundtrip: OverlayPayload = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip, payload);
}
}

View File

@@ -0,0 +1,67 @@
pub fn generate_m3u8(
channel_id: &str,
segments: &[String],
segment_duration_secs: u32,
window_size: usize,
) -> String {
let visible: Vec<&String> = if segments.len() > window_size {
segments[segments.len() - window_size..].iter().collect()
} else {
segments.iter().collect()
};
let target_duration = segment_duration_secs;
let media_sequence = if segments.len() > window_size {
segments.len() - window_size
} else {
0
};
let mut out = String::new();
out.push_str("#EXTM3U\n");
out.push_str("#EXT-X-VERSION:3\n");
out.push_str(&format!("#EXT-X-TARGETDURATION:{target_duration}\n"));
out.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{media_sequence}\n"));
for seg in &visible {
out.push_str(&format!("#EXTINF:{target_duration},\n"));
out.push_str(&format!("/playout/{channel_id}/{seg}\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generates_valid_m3u8() {
let segments: Vec<String> = (0..5).map(|i| format!("seg{i:05}.ts")).collect();
let playlist = generate_m3u8("ch1", &segments, 6, 10);
assert!(playlist.starts_with("#EXTM3U\n"));
assert!(playlist.contains("#EXT-X-TARGETDURATION:6"));
assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:0"));
assert!(playlist.contains("/playout/ch1/seg00000.ts"));
assert!(playlist.contains("/playout/ch1/seg00004.ts"));
}
#[test]
fn sliding_window_trims_old_segments() {
let segments: Vec<String> = (0..15).map(|i| format!("seg{i:05}.ts")).collect();
let playlist = generate_m3u8("ch1", &segments, 6, 5);
assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:10"));
assert!(!playlist.contains("seg00000.ts"));
assert!(playlist.contains("seg00010.ts"));
assert!(playlist.contains("seg00014.ts"));
}
#[test]
fn empty_segments() {
let playlist = generate_m3u8("ch1", &[], 6, 10);
assert!(playlist.contains("#EXTM3U"));
assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:0"));
}
}

View File

@@ -0,0 +1,230 @@
use chrono::{DateTime, Duration, Utc};
use domain::{MediaRole, ScheduledSlot};
use crate::config::PlayoutConfig;
use crate::metadata::{OverlayPayload, TimedMetadata};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Scte35Event {
SpliceOut {
id: String,
start: DateTime<Utc>,
duration_secs: u32,
},
SpliceIn {
id: String,
time: DateTime<Utc>,
},
}
#[derive(Debug, Clone)]
pub(crate) struct PlayoutPlan {
pub slots: Vec<ScheduledSlot>,
pub scte35_events: Vec<Scte35Event>,
pub timed_metadata: Vec<TimedMetadata>,
}
pub(crate) fn build_playout_plan(slots: &[ScheduledSlot], config: &PlayoutConfig) -> PlayoutPlan {
let scte35_events = detect_midroll_breaks(slots);
let timed_metadata = generate_overlay_triggers(slots, config);
PlayoutPlan {
slots: slots.to_vec(),
scte35_events,
timed_metadata,
}
}
fn detect_midroll_breaks(slots: &[ScheduledSlot]) -> Vec<Scte35Event> {
let mut events = Vec::new();
for window in slots.windows(3) {
let before = &window[0];
let break_slot = &window[1];
let after = &window[2];
let is_break = *break_slot.item().role() == MediaRole::Interstitial;
let same_source = before.source_block_id() == after.source_block_id()
&& before.source_block_id() == break_slot.source_block_id();
if is_break && same_source {
let break_duration = (break_slot.end_at() - break_slot.start_at())
.num_seconds()
.max(0) as u32;
let event_id = format!("midroll-{}", break_slot.id());
events.push(Scte35Event::SpliceOut {
id: event_id.clone(),
start: break_slot.start_at(),
duration_secs: break_duration,
});
events.push(Scte35Event::SpliceIn {
id: event_id,
time: break_slot.end_at(),
});
}
}
events
}
fn generate_overlay_triggers(
slots: &[ScheduledSlot],
config: &PlayoutConfig,
) -> Vec<TimedMetadata> {
let mut triggers = Vec::new();
let offset =
Duration::from_std(config.overlay_trigger_offset).unwrap_or(Duration::seconds(30));
for i in 0..slots.len().saturating_sub(1) {
let current = &slots[i];
let next = &slots[i + 1];
if *next.item().role() == MediaRole::Interstitial {
continue;
}
let trigger_at = current.end_at() - offset;
if trigger_at <= current.start_at() {
continue;
}
triggers.push(TimedMetadata {
trigger_at,
payload: OverlayPayload {
title: format!("Coming up next: {}", next.item().title()),
thumbnail_url: next.item().thumbnail_url().map(String::from),
},
});
}
triggers
}
pub(crate) fn render_scte35_daterange(event: &Scte35Event) -> String {
match event {
Scte35Event::SpliceOut {
id,
start,
duration_secs,
} => {
format!(
"#EXT-X-DATERANGE:ID=\"{id}\",START-DATE=\"{}\",PLANNED-DURATION={duration_secs},SCTE35-OUT=0xFC30",
start.format("%Y-%m-%dT%H:%M:%S%.3fZ")
)
}
Scte35Event::SpliceIn { id, time } => {
format!(
"#EXT-X-DATERANGE:ID=\"{id}-in\",START-DATE=\"{}\",SCTE35-IN=0xFC30",
time.format("%Y-%m-%dT%H:%M:%S%.3fZ")
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use domain::{BlockId, ContentType, MediaItem, MediaItemId, MediaItemRow};
fn make_item(title: &str, duration_secs: u32, role: MediaRole) -> MediaItem {
MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new(format!("item-{title}")),
title: title.into(),
content_type: ContentType::Movie,
duration_secs,
description: None,
genres: vec![],
year: None,
tags: vec![],
series_name: None,
season_number: None,
episode_number: None,
thumbnail_url: None,
collection_id: None,
provider_id: String::new(),
external_id: String::new(),
collection_name: None,
collection_type: None,
synced_at: None,
role,
chapters: vec![],
})
}
fn make_slot(
start_min: i64,
end_min: i64,
title: &str,
role: MediaRole,
block_id: BlockId,
) -> ScheduledSlot {
let base = Utc.with_ymd_and_hms(2026, 7, 12, 20, 0, 0).unwrap();
let duration = ((end_min - start_min) * 60) as u32;
let item = make_item(title, duration, role);
ScheduledSlot::new(
base + Duration::minutes(start_min),
base + Duration::minutes(end_min),
item,
block_id,
)
}
#[test]
fn detects_midroll_break_between_program_segments() {
let block_id = BlockId::generate();
let slots = vec![
make_slot(0, 30, "Movie Part 1", MediaRole::Program, block_id),
make_slot(30, 32, "Ad Break", MediaRole::Interstitial, block_id),
make_slot(32, 62, "Movie Part 2", MediaRole::Program, block_id),
];
let events = detect_midroll_breaks(&slots);
assert_eq!(events.len(), 2);
assert!(matches!(&events[0], Scte35Event::SpliceOut { duration_secs: 120, .. }));
assert!(matches!(&events[1], Scte35Event::SpliceIn { .. }));
}
#[test]
fn no_midroll_for_different_blocks() {
let block_a = BlockId::generate();
let block_b = BlockId::generate();
let slots = vec![
make_slot(0, 30, "Show A", MediaRole::Program, block_a),
make_slot(30, 32, "Bumper", MediaRole::Interstitial, block_b),
make_slot(32, 62, "Show B", MediaRole::Program, block_a),
];
assert!(detect_midroll_breaks(&slots).is_empty());
}
#[test]
fn overlay_trigger_fires_before_slot_end() {
let block_id = BlockId::generate();
let slots = vec![
make_slot(0, 60, "Current Movie", MediaRole::Program, block_id),
make_slot(60, 120, "Next Movie", MediaRole::Program, block_id),
];
let config = PlayoutConfig {
overlay_trigger_offset: std::time::Duration::from_secs(30),
..Default::default()
};
let triggers = generate_overlay_triggers(&slots, &config);
assert_eq!(triggers.len(), 1);
assert!(triggers[0].payload.title.contains("Next Movie"));
}
#[test]
fn no_overlay_before_interstitial() {
let block_id = BlockId::generate();
let slots = vec![
make_slot(0, 30, "Movie Part 1", MediaRole::Program, block_id),
make_slot(30, 32, "Ad Break", MediaRole::Interstitial, block_id),
make_slot(32, 62, "Movie Part 2", MediaRole::Program, block_id),
];
let triggers = generate_overlay_triggers(&slots, &PlayoutConfig::default());
assert_eq!(triggers.len(), 1);
assert!(triggers[0].payload.title.contains("Movie Part 2"));
}
}

View File

@@ -0,0 +1,78 @@
use std::path::PathBuf;
use async_trait::async_trait;
use bytes::Bytes;
use tokio::fs;
use super::{SegmentStore, SegmentStoreError, SegmentStoreResult};
pub struct FilesystemSegmentStore {
base_path: PathBuf,
}
impl FilesystemSegmentStore {
pub fn new(base_path: PathBuf) -> Self {
Self { base_path }
}
fn channel_dir(&self, channel_id: &str) -> PathBuf {
self.base_path.join(channel_id)
}
fn segment_path(&self, channel_id: &str, name: &str) -> PathBuf {
self.channel_dir(channel_id).join(name)
}
}
#[async_trait]
impl SegmentStore for FilesystemSegmentStore {
async fn write_segment(
&self,
channel_id: &str,
name: &str,
data: Bytes,
) -> SegmentStoreResult<()> {
let dir = self.channel_dir(channel_id);
fs::create_dir_all(&dir).await?;
fs::write(self.segment_path(channel_id, name), &data).await?;
Ok(())
}
async fn read_segment(&self, channel_id: &str, name: &str) -> SegmentStoreResult<Bytes> {
let path = self.segment_path(channel_id, name);
match fs::read(&path).await {
Ok(data) => Ok(Bytes::from(data)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(SegmentStoreError::NotFound(name.to_string()))
}
Err(e) => Err(e.into()),
}
}
async fn list_segments(&self, channel_id: &str) -> SegmentStoreResult<Vec<String>> {
let dir = self.channel_dir(channel_id);
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries = fs::read_dir(&dir).await?;
let mut names = Vec::new();
while let Some(entry) = entries.next_entry().await? {
if let Some(name) = entry.file_name().to_str()
&& name.ends_with(".ts")
{
names.push(name.to_string());
}
}
names.sort();
Ok(names)
}
async fn delete_segment(&self, channel_id: &str, name: &str) -> SegmentStoreResult<()> {
let path = self.segment_path(channel_id, name);
match fs::remove_file(&path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
}

View File

@@ -0,0 +1,125 @@
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use bytes::Bytes;
use tokio::sync::RwLock;
use super::{SegmentStore, SegmentStoreError, SegmentStoreResult};
type ChannelSegments = HashMap<String, HashMap<String, Bytes>>;
pub struct InMemorySegmentStore {
data: Arc<RwLock<ChannelSegments>>,
}
impl InMemorySegmentStore {
pub fn new() -> Self {
Self {
data: Arc::new(RwLock::new(HashMap::new())),
}
}
}
impl Default for InMemorySegmentStore {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl SegmentStore for InMemorySegmentStore {
async fn write_segment(
&self,
channel_id: &str,
name: &str,
data: Bytes,
) -> SegmentStoreResult<()> {
let mut store = self.data.write().await;
store
.entry(channel_id.to_string())
.or_default()
.insert(name.to_string(), data);
Ok(())
}
async fn read_segment(&self, channel_id: &str, name: &str) -> SegmentStoreResult<Bytes> {
let store = self.data.read().await;
store
.get(channel_id)
.and_then(|segs| segs.get(name))
.cloned()
.ok_or_else(|| SegmentStoreError::NotFound(name.to_string()))
}
async fn list_segments(&self, channel_id: &str) -> SegmentStoreResult<Vec<String>> {
let store = self.data.read().await;
let mut names: Vec<String> = store
.get(channel_id)
.map(|segs| segs.keys().filter(|k| k.ends_with(".ts")).cloned().collect())
.unwrap_or_default();
names.sort();
Ok(names)
}
async fn delete_segment(&self, channel_id: &str, name: &str) -> SegmentStoreResult<()> {
let mut store = self.data.write().await;
if let Some(segs) = store.get_mut(channel_id) {
segs.remove(name);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn write_read_delete_lifecycle() {
let store = InMemorySegmentStore::new();
let channel = "test-chan";
store
.write_segment(channel, "seg0.ts", Bytes::from_static(b"aaa"))
.await
.unwrap();
store
.write_segment(channel, "seg1.ts", Bytes::from_static(b"bbb"))
.await
.unwrap();
let data = store.read_segment(channel, "seg0.ts").await.unwrap();
assert_eq!(data.as_ref(), b"aaa");
let list = store.list_segments(channel).await.unwrap();
assert_eq!(list, vec!["seg0.ts", "seg1.ts"]);
store.delete_segment(channel, "seg0.ts").await.unwrap();
let list = store.list_segments(channel).await.unwrap();
assert_eq!(list, vec!["seg1.ts"]);
let err = store.read_segment(channel, "seg0.ts").await;
assert!(err.is_err());
}
#[tokio::test]
async fn read_nonexistent_returns_not_found() {
let store = InMemorySegmentStore::new();
let err = store.read_segment("chan", "nope.ts").await.unwrap_err();
assert!(matches!(err, SegmentStoreError::NotFound(_)));
}
#[tokio::test]
async fn delete_nonexistent_is_ok() {
let store = InMemorySegmentStore::new();
store.delete_segment("chan", "nope.ts").await.unwrap();
}
#[tokio::test]
async fn list_empty_channel() {
let store = InMemorySegmentStore::new();
let list = store.list_segments("empty").await.unwrap();
assert!(list.is_empty());
}
}

View File

@@ -0,0 +1,28 @@
pub mod filesystem;
pub mod memory;
use async_trait::async_trait;
use bytes::Bytes;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SegmentStoreError {
#[error("segment not found: {0}")]
NotFound(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
pub type SegmentStoreResult<T> = Result<T, SegmentStoreError>;
#[async_trait]
pub trait SegmentStore: Send + Sync {
async fn write_segment(&self, channel_id: &str, name: &str, data: Bytes)
-> SegmentStoreResult<()>;
async fn read_segment(&self, channel_id: &str, name: &str) -> SegmentStoreResult<Bytes>;
async fn list_segments(&self, channel_id: &str) -> SegmentStoreResult<Vec<String>>;
async fn delete_segment(&self, channel_id: &str, name: &str) -> SegmentStoreResult<()>;
}

View File

@@ -19,6 +19,7 @@ domain = { workspace = true }
application = { workspace = true } application = { workspace = true }
api-types = { workspace = true } api-types = { workspace = true }
infra-wiring = { workspace = true } infra-wiring = { workspace = true }
adapter-common = { workspace = true }
adapter-auth = { workspace = true } adapter-auth = { workspace = true }
adapter-event-publisher = { workspace = true } adapter-event-publisher = { workspace = true }
@@ -27,6 +28,10 @@ adapter-sqlite = { workspace = true, optional = true }
adapter-jellyfin = { workspace = true, optional = true } adapter-jellyfin = { workspace = true, optional = true }
adapter-local-files = { workspace = true, optional = true } adapter-local-files = { workspace = true, optional = true }
# OpenAPI
utoipa = { workspace = true }
utoipa-scalar = { workspace = true }
# Framework # Framework
axum = { workspace = true } axum = { workspace = true }
axum-extra = { workspace = true, features = ["typed-header"] } axum-extra = { workspace = true, features = ["typed-header"] }

View File

@@ -435,10 +435,16 @@ impl IProviderRegistry for SimpleProviderRegistry {
} }
} }
fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> domain::MediaItem { fn provider_item_to_library_item(
item: domain::MediaItem,
provider_id: &str,
role_config: &adapter_common::role_detector::RoleDetectionConfig,
) -> domain::MediaItem {
let external_id = item.id().value().to_string(); let external_id = item.id().value().to_string();
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
let role = adapter_common::role_detector::detect_role(&item, role_config);
domain::MediaItem::from_persistence(domain::MediaItemRow { domain::MediaItem::from_persistence(domain::MediaItemRow {
id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)), id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)),
provider_id: provider_id.to_string(), provider_id: provider_id.to_string(),
@@ -454,22 +460,26 @@ fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) ->
genres: item.genres().to_vec(), genres: item.genres().to_vec(),
tags: item.tags().to_vec(), tags: item.tags().to_vec(),
collection_id: item.collection_id().map(|s| s.to_string()), collection_id: item.collection_id().map(|s| s.to_string()),
collection_name: None, collection_name: item.collection_name().map(|s| s.to_string()),
collection_type: None, collection_type: item.collection_type().map(|s| s.to_string()),
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()), thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
synced_at: Some(now), synced_at: Some(now),
role: domain::MediaRole::default(), role,
chapters: item.chapters().to_vec(), chapters: item.chapters().to_vec(),
}) })
} }
struct SimpleSyncAdapter { struct SimpleSyncAdapter {
library_command: Arc<dyn domain::ports::LibraryCommand>, library_command: Arc<dyn domain::ports::LibraryCommand>,
role_config: adapter_common::role_detector::RoleDetectionConfig,
} }
impl SimpleSyncAdapter { impl SimpleSyncAdapter {
fn new(library_command: Arc<dyn domain::ports::LibraryCommand>) -> Self { fn new(library_command: Arc<dyn domain::ports::LibraryCommand>) -> Self {
Self { library_command } Self {
library_command,
role_config: adapter_common::role_detector::RoleDetectionConfig::default(),
}
} }
} }
@@ -520,11 +530,23 @@ impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter {
return result; return result;
} }
let library_items: Vec<domain::MediaItem> = items let mut library_items: Vec<domain::MediaItem> = items
.into_iter() .into_iter()
.map(|item| provider_item_to_library_item(item, provider_id)) .map(|item| provider_item_to_library_item(item, provider_id, &self.role_config))
.collect(); .collect();
for item in &mut library_items {
if adapter_common::ffprobe::should_probe_chapters(
item.content_type(),
item.duration_secs(),
) {
if let Ok(uri) = provider.get_source_uri(item.id()).await {
let chapters = adapter_common::ffprobe::extract_chapters(&uri).await;
item.set_chapters(chapters);
}
}
}
if let Err(e) = self if let Err(e) = self
.library_command .library_command
.upsert_items(provider_id, library_items) .upsert_items(provider_id, library_items)

View File

@@ -11,6 +11,17 @@ use crate::state::AppState;
const DEFAULT_ACTIVITY_LIMIT: u32 = 50; const DEFAULT_ACTIVITY_LIMIT: u32 = 50;
#[utoipa::path(
get,
path = "/api/v1/admin/settings",
tag = "admin",
security(("bearer" = [])),
responses(
(status = 200, body = SettingsResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn get_settings( pub async fn get_settings(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,
@@ -20,6 +31,18 @@ pub async fn get_settings(
Ok(Json(SettingsResponse { settings })) Ok(Json(SettingsResponse { settings }))
} }
#[utoipa::path(
put,
path = "/api/v1/admin/settings",
tag = "admin",
security(("bearer" = [])),
request_body = HashMap<String, String>,
responses(
(status = 200, body = SettingsResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn update_settings( pub async fn update_settings(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,
@@ -34,6 +57,18 @@ pub async fn update_settings(
Ok(Json(SettingsResponse { settings })) Ok(Json(SettingsResponse { settings }))
} }
#[utoipa::path(
get,
path = "/api/v1/admin/activity",
tag = "admin",
security(("bearer" = [])),
params(ActivityLogParams),
responses(
(status = 200, body = Vec<ActivityEventResponse>),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn get_activity_log( pub async fn get_activity_log(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,

View File

@@ -10,6 +10,16 @@ use crate::state::AppState;
const TOKEN_TYPE_BEARER: &str = "Bearer"; const TOKEN_TYPE_BEARER: &str = "Bearer";
#[utoipa::path(
post,
path = "/api/v1/auth/register",
tag = "auth",
request_body = RegisterRequest,
responses(
(status = 200, body = UserResponse),
(status = 409, body = api_types::ErrorResponse),
)
)]
pub async fn register( pub async fn register(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RegisterRequest>, Json(req): Json<RegisterRequest>,
@@ -22,6 +32,16 @@ pub async fn register(
Ok(Json(UserResponse::from(user))) Ok(Json(UserResponse::from(user)))
} }
#[utoipa::path(
post,
path = "/api/v1/auth/login",
tag = "auth",
request_body = LoginRequest,
responses(
(status = 200, body = TokenResponse),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn login( pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<LoginRequest>, Json(req): Json<LoginRequest>,
@@ -40,15 +60,43 @@ pub async fn login(
})) }))
} }
#[utoipa::path(
post,
path = "/api/v1/auth/logout",
tag = "auth",
responses(
(status = 200, body = serde_json::Value),
)
)]
pub async fn logout() -> Result<Json<serde_json::Value>, AppError> { pub async fn logout() -> Result<Json<serde_json::Value>, AppError> {
Ok(Json(serde_json::json!({"message": "logged out"}))) Ok(Json(serde_json::json!({"message": "logged out"})))
} }
#[utoipa::path(
get,
path = "/api/v1/auth/me",
tag = "auth",
security(("bearer" = [])),
responses(
(status = 200, body = UserResponse),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn me(CurrentUser(user): CurrentUser) -> Result<Json<UserResponse>, AppError> { pub async fn me(CurrentUser(user): CurrentUser) -> Result<Json<UserResponse>, AppError> {
Ok(Json(UserResponse::from(user))) Ok(Json(UserResponse::from(user)))
} }
#[cfg(feature = "auth-jwt")] #[cfg(feature = "auth-jwt")]
#[utoipa::path(
post,
path = "/api/v1/auth/refresh",
tag = "auth",
request_body = RefreshRequest,
responses(
(status = 200, body = TokenResponse),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn refresh_token( pub async fn refresh_token(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RefreshRequest>, Json(req): Json<RefreshRequest>,

View File

@@ -13,6 +13,16 @@ use crate::errors::AppError;
use crate::extractors::CurrentUser; use crate::extractors::CurrentUser;
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(
get,
path = "/api/v1/channels",
tag = "channels",
security(("bearer" = [])),
responses(
(status = 200, body = Vec<ChannelResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_channels( pub async fn list_channels(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -21,6 +31,16 @@ pub async fn list_channels(
Ok(Json(channels.into_iter().map(ChannelResponse::from).collect())) Ok(Json(channels.into_iter().map(ChannelResponse::from).collect()))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/mine",
tag = "channels",
security(("bearer" = [])),
responses(
(status = 200, body = Vec<ChannelResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_my_channels( pub async fn list_my_channels(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
@@ -29,6 +49,18 @@ pub async fn list_my_channels(
Ok(Json(channels.into_iter().map(ChannelResponse::from).collect())) Ok(Json(channels.into_iter().map(ChannelResponse::from).collect()))
} }
#[utoipa::path(
post,
path = "/api/v1/channels",
tag = "channels",
security(("bearer" = [])),
request_body = CreateChannelRequest,
responses(
(status = 200, body = ChannelResponse),
(status = 400, body = api_types::ErrorResponse),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn create_channel( pub async fn create_channel(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
@@ -43,6 +75,20 @@ pub async fn create_channel(
Ok(Json(ChannelResponse::from(channel))) Ok(Json(ChannelResponse::from(channel)))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = ChannelResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_channel( pub async fn get_channel(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -56,6 +102,22 @@ pub async fn get_channel(
Ok(Json(ChannelResponse::from(channel))) Ok(Json(ChannelResponse::from(channel)))
} }
#[utoipa::path(
put,
path = "/api/v1/channels/{id}",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
request_body = UpdateChannelRequest,
responses(
(status = 200, body = ChannelResponse),
(status = 400, body = api_types::ErrorResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn update_channel( pub async fn update_channel(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
@@ -71,11 +133,27 @@ pub async fn update_channel(
schedule_config: req.schedule_config.map(Into::into), schedule_config: req.schedule_config.map(Into::into),
rotation_policy: req.rotation_policy, rotation_policy: req.rotation_policy,
auto_schedule: req.auto_schedule, auto_schedule: req.auto_schedule,
gap_filler: req.gap_filler,
}; };
let channel = application::channels::update::execute(&state.channel_command_deps, cmd).await?; let channel = application::channels::update::execute(&state.channel_command_deps, cmd).await?;
Ok(Json(ChannelResponse::from(channel))) Ok(Json(ChannelResponse::from(channel)))
} }
#[utoipa::path(
delete,
path = "/api/v1/channels/{id}",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 204),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn delete_channel( pub async fn delete_channel(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
@@ -89,6 +167,20 @@ pub async fn delete_channel(
Ok(axum::http::StatusCode::NO_CONTENT) Ok(axum::http::StatusCode::NO_CONTENT)
} }
#[utoipa::path(
post,
path = "/api/v1/channels/{id}/snapshots",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = ConfigSnapshotResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn save_snapshot( pub async fn save_snapshot(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -102,6 +194,20 @@ pub async fn save_snapshot(
Ok(Json(ConfigSnapshotResponse::from(snap))) Ok(Json(ConfigSnapshotResponse::from(snap)))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/snapshots",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = Vec<ConfigSnapshotResponse>),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn list_snapshots( pub async fn list_snapshots(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -111,6 +217,21 @@ pub async fn list_snapshots(
Ok(Json(snaps.into_iter().map(ConfigSnapshotResponse::from).collect())) Ok(Json(snaps.into_iter().map(ConfigSnapshotResponse::from).collect()))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/snapshots/{snapshot_id}",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
("snapshot_id" = uuid::Uuid, Path, description = "Snapshot ID"),
),
responses(
(status = 200, body = ConfigSnapshotResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_snapshot( pub async fn get_snapshot(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -124,6 +245,22 @@ pub async fn get_snapshot(
Ok(Json(ConfigSnapshotResponse::from(snap))) Ok(Json(ConfigSnapshotResponse::from(snap)))
} }
#[utoipa::path(
patch,
path = "/api/v1/channels/{id}/snapshots/{snapshot_id}",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
("snapshot_id" = uuid::Uuid, Path, description = "Snapshot ID"),
),
request_body = PatchSnapshotRequest,
responses(
(status = 200, body = ConfigSnapshotResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn patch_snapshot( pub async fn patch_snapshot(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -139,6 +276,21 @@ pub async fn patch_snapshot(
Ok(Json(ConfigSnapshotResponse::from(snap))) Ok(Json(ConfigSnapshotResponse::from(snap)))
} }
#[utoipa::path(
post,
path = "/api/v1/channels/{id}/snapshots/{snapshot_id}/restore",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
("snapshot_id" = uuid::Uuid, Path, description = "Snapshot ID"),
),
responses(
(status = 200, body = ChannelResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn restore_snapshot( pub async fn restore_snapshot(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,

View File

@@ -7,6 +7,14 @@ use application::config::GetConfigQuery;
use crate::errors::AppError; use crate::errors::AppError;
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(
get,
path = "/api/v1/config",
tag = "config",
responses(
(status = 200, body = ConfigResponse),
)
)]
pub async fn get_config( pub async fn get_config(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<ConfigResponse>, AppError> { ) -> Result<Json<ConfigResponse>, AppError> {

View File

@@ -12,6 +12,15 @@ use crate::state::AppState;
const M3U_CONTENT_TYPE: &str = "audio/x-mpegurl; charset=utf-8"; const M3U_CONTENT_TYPE: &str = "audio/x-mpegurl; charset=utf-8";
const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8"; const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8";
#[utoipa::path(
get,
path = "/api/v1/iptv/playlist.m3u",
tag = "iptv",
params(IptvParams),
responses(
(status = 200, content_type = "audio/x-mpegurl", body = String),
)
)]
pub async fn m3u_playlist( pub async fn m3u_playlist(
State(state): State<AppState>, State(state): State<AppState>,
OptionalCurrentUser(_user): OptionalCurrentUser, OptionalCurrentUser(_user): OptionalCurrentUser,
@@ -25,6 +34,14 @@ pub async fn m3u_playlist(
Ok(([(header::CONTENT_TYPE, M3U_CONTENT_TYPE)], content)) Ok(([(header::CONTENT_TYPE, M3U_CONTENT_TYPE)], content))
} }
#[utoipa::path(
get,
path = "/api/v1/iptv/epg.xml",
tag = "iptv",
responses(
(status = 200, content_type = "application/xml", body = String),
)
)]
pub async fn xmltv_epg( pub async fn xmltv_epg(
State(state): State<AppState>, State(state): State<AppState>,
OptionalCurrentUser(_user): OptionalCurrentUser, OptionalCurrentUser(_user): OptionalCurrentUser,

View File

@@ -4,9 +4,10 @@ use axum::extract::{Path, Query, State};
use api_types::{ use api_types::{
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, PaginatedResponse, CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, PaginatedResponse,
ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry, ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
UpdateRoleRequest,
}; };
use application::library::{SearchItemsQuery, TriggerSyncCommand}; use application::library::{SearchItemsQuery, TriggerSyncCommand};
use domain::DomainError; use domain::{DomainError, MediaRole};
use crate::errors::AppError; use crate::errors::AppError;
use crate::extractors::{AdminUser, CurrentUser}; use crate::extractors::{AdminUser, CurrentUser};
@@ -14,6 +15,17 @@ use crate::state::AppState;
const DEFAULT_SEARCH_LIMIT: u32 = 50; const DEFAULT_SEARCH_LIMIT: u32 = 50;
#[utoipa::path(
get,
path = "/api/v1/library/items",
tag = "library",
security(("bearer" = [])),
params(LibrarySearchParams),
responses(
(status = 200, body = PaginatedResponse<LibraryItemResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn search_items( pub async fn search_items(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -38,6 +50,20 @@ pub async fn search_items(
))) )))
} }
#[utoipa::path(
get,
path = "/api/v1/library/items/{id}",
tag = "library",
security(("bearer" = [])),
params(
("id" = String, Path, description = "Library item ID"),
),
responses(
(status = 200, body = LibraryItemResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_item( pub async fn get_item(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -51,6 +77,17 @@ pub async fn get_item(
Ok(Json(LibraryItemResponse::from(item))) Ok(Json(LibraryItemResponse::from(item)))
} }
#[utoipa::path(
get,
path = "/api/v1/library/collections",
tag = "library",
security(("bearer" = [])),
params(ProviderParam),
responses(
(status = 200, body = Vec<CollectionResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_collections( pub async fn list_collections(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -68,6 +105,17 @@ pub async fn list_collections(
)) ))
} }
#[utoipa::path(
get,
path = "/api/v1/library/shows",
tag = "library",
security(("bearer" = [])),
params(ShowsParams),
responses(
(status = 200, body = Vec<ShowResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_shows( pub async fn list_shows(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -84,6 +132,17 @@ pub async fn list_shows(
Ok(Json(shows.into_iter().map(ShowResponse::from).collect())) Ok(Json(shows.into_iter().map(ShowResponse::from).collect()))
} }
#[utoipa::path(
get,
path = "/api/v1/library/seasons",
tag = "library",
security(("bearer" = [])),
params(SeasonsParams),
responses(
(status = 200, body = Vec<SeasonResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_seasons( pub async fn list_seasons(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -98,6 +157,17 @@ pub async fn list_seasons(
)) ))
} }
#[utoipa::path(
get,
path = "/api/v1/library/genres",
tag = "library",
security(("bearer" = [])),
params(GenresParams),
responses(
(status = 200, body = Vec<String>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_genres( pub async fn list_genres(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -115,6 +185,16 @@ pub async fn list_genres(
Ok(Json(genres)) Ok(Json(genres))
} }
#[utoipa::path(
get,
path = "/api/v1/library/sync/status",
tag = "library",
security(("bearer" = [])),
responses(
(status = 200, body = Vec<SyncStatusEntry>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn sync_status( pub async fn sync_status(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -123,6 +203,17 @@ pub async fn sync_status(
Ok(Json(entries.into_iter().map(SyncStatusEntry::from).collect())) Ok(Json(entries.into_iter().map(SyncStatusEntry::from).collect()))
} }
#[utoipa::path(
post,
path = "/api/v1/library/sync",
tag = "library",
security(("bearer" = [])),
responses(
(status = 202),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn trigger_sync( pub async fn trigger_sync(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,
@@ -131,3 +222,32 @@ pub async fn trigger_sync(
application::library::sync::execute(&state.library_command_deps, cmd).await?; application::library::sync::execute(&state.library_command_deps, cmd).await?;
Ok(axum::http::StatusCode::ACCEPTED) Ok(axum::http::StatusCode::ACCEPTED)
} }
pub async fn update_role(
State(state): State<AppState>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
Json(body): Json<UpdateRoleRequest>,
) -> Result<Json<LibraryItemResponse>, AppError> {
let role: MediaRole = serde_json::from_value(serde_json::Value::String(body.role.clone()))
.map_err(|_| {
AppError(DomainError::ValidationError(format!(
"Invalid role '{}'. Must be 'program' or 'interstitial'",
body.role
)))
})?;
state
.library_command_deps
.library_command
.update_role(&id, role)
.await?;
let item = state
.library_query
.get_by_id(&id)
.await?
.ok_or_else(|| AppError(DomainError::NotFound(format!("Library item {id} not found"))))?;
Ok(Json(LibraryItemResponse::from(item)))
}

View File

@@ -9,6 +9,17 @@ use crate::errors::AppError;
use crate::extractors::AdminUser; use crate::extractors::AdminUser;
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(
get,
path = "/api/v1/admin/providers",
tag = "providers",
security(("bearer" = [])),
responses(
(status = 200, body = Vec<ProviderConfigResponse>),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn list_providers( pub async fn list_providers(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,
@@ -22,6 +33,21 @@ pub async fn list_providers(
)) ))
} }
#[utoipa::path(
get,
path = "/api/v1/admin/providers/{id}",
tag = "providers",
security(("bearer" = [])),
params(
("id" = String, Path, description = "Provider ID"),
),
responses(
(status = 200, body = ProviderConfigResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_provider( pub async fn get_provider(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,
@@ -35,6 +61,21 @@ pub async fn get_provider(
Ok(Json(ProviderConfigResponse::from(provider))) Ok(Json(ProviderConfigResponse::from(provider)))
} }
#[utoipa::path(
put,
path = "/api/v1/admin/providers/{id}",
tag = "providers",
security(("bearer" = [])),
params(
("id" = String, Path, description = "Provider ID"),
),
request_body = ProviderConfigRequest,
responses(
(status = 200, body = serde_json::Value),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn upsert_provider( pub async fn upsert_provider(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,
@@ -51,6 +92,21 @@ pub async fn upsert_provider(
Ok(Json(serde_json::json!({"status": "ok"}))) Ok(Json(serde_json::json!({"status": "ok"})))
} }
#[utoipa::path(
delete,
path = "/api/v1/admin/providers/{id}",
tag = "providers",
security(("bearer" = [])),
params(
("id" = String, Path, description = "Provider ID"),
),
responses(
(status = 204),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn delete_provider( pub async fn delete_provider(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,

View File

@@ -16,6 +16,20 @@ use crate::errors::AppError;
use crate::extractors::CurrentUser; use crate::extractors::CurrentUser;
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(
post,
path = "/api/v1/channels/{id}/schedule",
tag = "schedule",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = ScheduleResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn generate_schedule( pub async fn generate_schedule(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -26,6 +40,20 @@ pub async fn generate_schedule(
Ok(Json(ScheduleResponse::from(schedule))) Ok(Json(ScheduleResponse::from(schedule)))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/schedule",
tag = "schedule",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = ScheduleResponse),
(status = 204),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn get_active_schedule( pub async fn get_active_schedule(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -38,6 +66,19 @@ pub async fn get_active_schedule(
} }
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/now",
tag = "schedule",
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = CurrentBroadcastResponse),
(status = 204),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_current_broadcast( pub async fn get_current_broadcast(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<uuid::Uuid>, Path(id): Path<uuid::Uuid>,
@@ -57,6 +98,18 @@ pub async fn get_current_broadcast(
} }
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/epg",
tag = "schedule",
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = Vec<SlotResponse>),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_epg( pub async fn get_epg(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<uuid::Uuid>, Path(id): Path<uuid::Uuid>,
@@ -66,6 +119,19 @@ pub async fn get_epg(
Ok(Json(slots.into_iter().map(SlotResponse::from).collect())) Ok(Json(slots.into_iter().map(SlotResponse::from).collect()))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/stream",
tag = "schedule",
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = String),
(status = 204),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_stream( pub async fn get_stream(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<uuid::Uuid>, Path(id): Path<uuid::Uuid>,
@@ -77,6 +143,19 @@ pub async fn get_stream(
} }
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/schedule/history",
tag = "schedule",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = Vec<ScheduleHistoryEntry>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_schedule_history( pub async fn list_schedule_history(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -91,3 +170,87 @@ pub async fn list_schedule_history(
.collect(), .collect(),
)) ))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/export.ics",
tag = "schedule",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, description = "iCalendar file", content_type = "text/calendar"),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn export_ical(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(id): Path<uuid::Uuid>,
) -> impl IntoResponse {
let channel_id = ChannelId::from(id);
match state.channel_query.find_by_id(channel_id).await {
Ok(Some(channel)) => {
let ical = domain::generate_ical(
channel.name(),
channel.timezone(),
channel.schedule_config(),
);
(
StatusCode::OK,
[
("content-type", "text/calendar; charset=utf-8"),
(
"content-disposition",
&format!("attachment; filename=\"{}.ics\"", channel.name()),
),
],
ical,
)
.into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
#[utoipa::path(
post,
path = "/api/v1/channels/{id}/import",
tag = "schedule",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
request_body(content = String, content_type = "text/calendar"),
responses(
(status = 200, body = api_types::ChannelResponse),
(status = 400, body = api_types::ErrorResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn import_ical(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<uuid::Uuid>,
body: String,
) -> Result<Json<api_types::ChannelResponse>, AppError> {
let config = domain::parse_ical(&body)?;
let cmd = application::channels::UpdateChannelCommand {
channel_id: id.into(),
owner_id: user.id(),
name: None,
description: None,
timezone: None,
schedule_config: Some(config),
rotation_policy: None,
auto_schedule: None,
gap_filler: None,
};
let channel =
application::channels::update::execute(&state.channel_command_deps, cmd).await?;
Ok(Json(api_types::ChannelResponse::from(channel)))
}

View File

@@ -9,6 +9,7 @@ mod extractors;
mod factory; mod factory;
mod handlers; mod handlers;
mod mappers; mod mappers;
mod openapi;
mod routes; mod routes;
mod state; mod state;
@@ -52,6 +53,7 @@ async fn main() -> anyhow::Result<()> {
let app = axum::Router::new() let app = axum::Router::new()
.nest("/api/v1", routes::api_v1_router()) .nest("/api/v1", routes::api_v1_router())
.nest("/api", routes::docs_router())
.layer(cors) .layer(cors)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(app_state); .with_state(app_state);

View File

@@ -0,0 +1,126 @@
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa::{Modify, OpenApi};
struct BearerAuth;
impl Modify for BearerAuth {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"bearer",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
}
#[derive(OpenApi)]
#[openapi(
info(
title = "K-TV API",
version = "1.0.0",
description = "Self-hosted linear TV channel orchestration",
),
modifiers(&BearerAuth),
paths(
crate::handlers::auth::register,
crate::handlers::auth::login,
crate::handlers::auth::logout,
crate::handlers::auth::me,
crate::handlers::auth::refresh_token,
crate::handlers::channels::list_channels,
crate::handlers::channels::list_my_channels,
crate::handlers::channels::create_channel,
crate::handlers::channels::get_channel,
crate::handlers::channels::update_channel,
crate::handlers::channels::delete_channel,
crate::handlers::channels::save_snapshot,
crate::handlers::channels::list_snapshots,
crate::handlers::channels::get_snapshot,
crate::handlers::channels::patch_snapshot,
crate::handlers::channels::restore_snapshot,
crate::handlers::schedule::generate_schedule,
crate::handlers::schedule::get_active_schedule,
crate::handlers::schedule::get_current_broadcast,
crate::handlers::schedule::get_epg,
crate::handlers::schedule::get_stream,
crate::handlers::schedule::list_schedule_history,
crate::handlers::schedule::export_ical,
crate::handlers::schedule::import_ical,
crate::handlers::admin::get_settings,
crate::handlers::admin::update_settings,
crate::handlers::admin::get_activity_log,
crate::handlers::providers::list_providers,
crate::handlers::providers::get_provider,
crate::handlers::providers::upsert_provider,
crate::handlers::providers::delete_provider,
crate::handlers::config::get_config,
crate::handlers::iptv::m3u_playlist,
crate::handlers::iptv::xmltv_epg,
crate::handlers::library::search_items,
crate::handlers::library::get_item,
crate::handlers::library::list_collections,
crate::handlers::library::list_shows,
crate::handlers::library::list_seasons,
crate::handlers::library::list_genres,
crate::handlers::library::sync_status,
crate::handlers::library::trigger_sync,
),
components(schemas(
api_types::LoginRequest,
api_types::RegisterRequest,
api_types::RefreshRequest,
api_types::TokenResponse,
api_types::UserResponse,
api_types::ChannelResponse,
api_types::CreateChannelRequest,
api_types::UpdateChannelRequest,
api_types::ConfigSnapshotResponse,
api_types::PatchSnapshotRequest,
api_types::ScheduleResponse,
api_types::SlotResponse,
api_types::MediaItemResponse,
api_types::CurrentBroadcastResponse,
api_types::ScheduleHistoryEntry,
api_types::SettingsResponse,
api_types::ActivityEventResponse,
api_types::ActivityLogParams,
api_types::ProviderConfigRequest,
api_types::ProviderConfigResponse,
api_types::ConfigResponse,
api_types::ProviderCapabilitiesResponse,
api_types::ProviderInfo,
api_types::IptvParams,
api_types::LibraryItemResponse,
api_types::CollectionResponse,
api_types::ShowResponse,
api_types::SeasonResponse,
api_types::SyncStatusEntry,
api_types::LibrarySearchParams,
api_types::ProviderParam,
api_types::ShowsParams,
api_types::SeasonsParams,
api_types::GenresParams,
api_types::PaginatedResponse<api_types::LibraryItemResponse>,
api_types::ErrorResponse,
)),
security(
("bearer" = []),
),
tags(
(name = "auth", description = "Authentication"),
(name = "channels", description = "Channel management"),
(name = "schedule", description = "Schedule generation and playback"),
(name = "admin", description = "Admin settings and activity"),
(name = "providers", description = "Media provider configuration"),
(name = "config", description = "Public system configuration"),
(name = "iptv", description = "IPTV playlist and EPG feeds"),
(name = "library", description = "Media library browsing and sync"),
),
)]
pub struct ApiDoc;

View File

@@ -1,6 +1,9 @@
use axum::{Router, routing::{delete, get, post, put}}; use axum::{Json, Router, routing::{delete, get, post, put}};
use utoipa::OpenApi;
use utoipa_scalar::{Scalar, Servable};
use crate::handlers; use crate::handlers;
use crate::openapi::ApiDoc;
use crate::state::AppState; use crate::state::AppState;
pub fn api_v1_router() -> Router<AppState> { pub fn api_v1_router() -> Router<AppState> {
@@ -15,6 +18,12 @@ pub fn api_v1_router() -> Router<AppState> {
.merge(local_files_router()) .merge(local_files_router())
} }
pub fn docs_router() -> Router<AppState> {
Router::new()
.route("/docs", get(|| async { Json(ApiDoc::openapi()) }))
.merge(Scalar::with_url("/docs/ui", ApiDoc::openapi()))
}
fn auth_router() -> Router<AppState> { fn auth_router() -> Router<AppState> {
let r = Router::new() let r = Router::new()
.route("/register", post(handlers::auth::register)) .route("/register", post(handlers::auth::register))
@@ -49,6 +58,8 @@ fn channel_router() -> Router<AppState> {
.route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot)) .route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot))
.route("/{id}/snapshots/{snapshot_id}", axum::routing::patch(handlers::channels::patch_snapshot)) .route("/{id}/snapshots/{snapshot_id}", axum::routing::patch(handlers::channels::patch_snapshot))
.route("/{id}/snapshots/{snapshot_id}/restore", post(handlers::channels::restore_snapshot)) .route("/{id}/snapshots/{snapshot_id}/restore", post(handlers::channels::restore_snapshot))
.route("/{id}/export.ics", get(handlers::schedule::export_ical))
.route("/{id}/import", post(handlers::schedule::import_ical))
} }
fn admin_router() -> Router<AppState> { fn admin_router() -> Router<AppState> {
@@ -80,6 +91,7 @@ fn library_router() -> Router<AppState> {
Router::new() Router::new()
.route("/items", get(handlers::library::search_items)) .route("/items", get(handlers::library::search_items))
.route("/items/{id}", get(handlers::library::get_item)) .route("/items/{id}", get(handlers::library::get_item))
.route("/items/{id}/role", put(handlers::library::update_role))
.route("/collections", get(handlers::library::list_collections)) .route("/collections", get(handlers::library::list_collections))
.route("/shows", get(handlers::library::list_shows)) .route("/shows", get(handlers::library::list_shows))
.route("/seasons", get(handlers::library::list_seasons)) .route("/seasons", get(handlers::library::list_seasons))

View File

@@ -17,6 +17,7 @@ local-files = ["dep:adapter-local-files"]
domain = { workspace = true } domain = { workspace = true }
application = { workspace = true } application = { workspace = true }
infra-wiring = { workspace = true } infra-wiring = { workspace = true }
adapter-common = { workspace = true }
adapter-sqlite = { workspace = true, optional = true } adapter-sqlite = { workspace = true, optional = true }
adapter-auth = { workspace = true } adapter-auth = { workspace = true }
adapter-jellyfin = { workspace = true, optional = true } adapter-jellyfin = { workspace = true, optional = true }

View File

@@ -341,10 +341,16 @@ impl IProviderRegistry for SimpleProviderRegistry {
} }
} }
fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> domain::MediaItem { fn provider_item_to_library_item(
item: domain::MediaItem,
provider_id: &str,
role_config: &adapter_common::role_detector::RoleDetectionConfig,
) -> domain::MediaItem {
let external_id = item.id().value().to_string(); let external_id = item.id().value().to_string();
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
let role = adapter_common::role_detector::detect_role(&item, role_config);
domain::MediaItem::from_persistence(domain::MediaItemRow { domain::MediaItem::from_persistence(domain::MediaItemRow {
id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)), id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)),
provider_id: provider_id.to_string(), provider_id: provider_id.to_string(),
@@ -360,22 +366,26 @@ fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) ->
genres: item.genres().to_vec(), genres: item.genres().to_vec(),
tags: item.tags().to_vec(), tags: item.tags().to_vec(),
collection_id: item.collection_id().map(|s| s.to_string()), collection_id: item.collection_id().map(|s| s.to_string()),
collection_name: None, collection_name: item.collection_name().map(|s| s.to_string()),
collection_type: None, collection_type: item.collection_type().map(|s| s.to_string()),
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()), thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
synced_at: Some(now), synced_at: Some(now),
role: domain::MediaRole::default(), role,
chapters: item.chapters().to_vec(), chapters: item.chapters().to_vec(),
}) })
} }
struct SimpleSyncAdapter { struct SimpleSyncAdapter {
library_command: Arc<dyn domain::ports::LibraryCommand>, library_command: Arc<dyn domain::ports::LibraryCommand>,
role_config: adapter_common::role_detector::RoleDetectionConfig,
} }
impl SimpleSyncAdapter { impl SimpleSyncAdapter {
fn new(library_command: Arc<dyn domain::ports::LibraryCommand>) -> Self { fn new(library_command: Arc<dyn domain::ports::LibraryCommand>) -> Self {
Self { library_command } Self {
library_command,
role_config: adapter_common::role_detector::RoleDetectionConfig::default(),
}
} }
} }
@@ -386,6 +396,7 @@ impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter {
provider: &dyn IMediaProvider, provider: &dyn IMediaProvider,
provider_id: &str, provider_id: &str,
) -> domain::LibrarySyncResult { ) -> domain::LibrarySyncResult {
use adapter_common::ffprobe;
use std::time::Instant; use std::time::Instant;
let start = Instant::now(); let start = Instant::now();
@@ -426,11 +437,20 @@ impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter {
return result; return result;
} }
let library_items: Vec<domain::MediaItem> = items let mut library_items: Vec<domain::MediaItem> = items
.into_iter() .into_iter()
.map(|item| provider_item_to_library_item(item, provider_id)) .map(|item| provider_item_to_library_item(item, provider_id, &self.role_config))
.collect(); .collect();
for item in &mut library_items {
if ffprobe::should_probe_chapters(item.content_type(), item.duration_secs()) {
if let Ok(uri) = provider.get_source_uri(item.id()).await {
let chapters = ffprobe::extract_chapters(&uri).await;
item.set_chapters(chapters);
}
}
}
if let Err(e) = self if let Err(e) = self
.library_command .library_command
.upsert_items(provider_id, library_items) .upsert_items(provider_id, library_items)

View File

@@ -0,0 +1,69 @@
# K-TV Parallel Execution Plan
## Strategy
3 parallel worktree tracks (zero file overlap), then a sequential tail after merging.
## Parallel Tracks
### Track A: Schedule Engine (`feat/schedule-engine`)
**Files:** `domain/src/services/schedule/`, `domain/src/value_objects/scheduling.rs`
1. **#3** — Wire Alternating, Weighted, Marathon into schedule engine dispatch
2. **#4** — Interstitial insertion in schedule engine
3. **#8** — Gap filler logic
4. **#6** — Mid-roll break logic (blocked by #4 and #5, but #5 is Track C — merge C first or stub chapters)
### Track B: Playout Service (`feat/playout`)
**Files:** `crates/playout/` (entirely new crate — zero overlap)
1. **#9** — Core HLS streaming (single channel, FFmpeg, SegmentStore port)
2. **#11** — Multi-channel + shared broadcast loop
3. **#10** — SCTE-35 markers + overlay metadata (blocked by #6, so merge Track A first)
### Track C: Library Sync (`feat/library-sync`)
**Files:** `adapters/*/src/` (sync logic only)
1. **#5** — Chapter extraction during library sync (ffprobe)
2. **#7** — MediaRole auto-detection during library sync
## Merge Order
```
┌─ Track A (schedule engine) ──────────┐
master ──────┤ Track B (playout) ──────────────────┤── merge A ── merge C ── merge B ── sequential tail
└─ Track C (library sync) ─────────────┘
```
1. Merge **Track C** first (library sync — smallest, no downstream deps)
2. Merge **Track A** second (schedule engine — #6 needs chapters from Track C)
3. Merge **Track B** third (playout — #10 needs mid-roll from Track A)
Track B can merge whenever since it's a new crate, but #10 and #11 depend on other tracks, so practically it merges last.
## Sequential Tail (after all tracks merge)
On master, in order:
5. **#14** — OpenAPI: wire utoipa in presentation
6. **#15** — iCalendar export
7. **#16** — iCalendar import (blocked by #15)
8. **#17** — MCP: expand tools for creative scheduling (blocked by #3, #4)
## Conflict Prevention Rules
- Each track works on its own feature branch
- No two tracks edit the same file
- `domain/src/services/mod.rs` — only Track A adds to it during parallel phase; iCal (#15) adds to it during the sequential tail after Track A is merged
- `domain/src/lib.rs` — re-exports may need updating at merge time; resolve in merge commit
- Run `cargo check --workspace` after each merge before starting the next
## Worktree Setup
```bash
git worktree add .worktrees/schedule-engine -b feat/schedule-engine
git worktree add .worktrees/playout -b feat/playout
git worktree add .worktrees/library-sync -b feat/library-sync
```
Each worktree starts from the same master commit. Work independently. Merge back to master one at a time.

View File

@@ -1,31 +1,30 @@
FROM rust:1.92 AS builder FROM rust:1.92 AS builder
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN cargo build --release -p presentation -p worker -p playout
# Build the release binary # Presentation image
RUN cargo build --release -p api FROM debian:bookworm-slim AS presentation
FROM debian:bookworm-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libssl3 ca-certificates curl && rm -rf /var/lib/apt/lists/*
# Install OpenSSL, CA certs, and ffmpeg (provides ffprobe for local-files duration scanning) COPY --from=builder /app/target/release/k-tv .
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \
ca-certificates \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/api .
# Create data directory for SQLite
RUN mkdir -p /app/data RUN mkdir -p /app/data
ENV DATABASE_URL=sqlite:///app/data/template.db
ENV SESSION_SECRET=supersecretchangeinproduction
EXPOSE 3000 EXPOSE 3000
CMD ["./k-tv"]
CMD ["./api"] # Worker image
FROM debian:bookworm-slim AS worker
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libssl3 ca-certificates ffmpeg && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/k-tv-worker .
RUN mkdir -p /app/data
CMD ["./k-tv-worker"]
# Playout image
FROM debian:bookworm-slim AS playout
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libssl3 ca-certificates ffmpeg && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/k-tv-playout .
RUN mkdir -p /app/data /tmp/k-tv-playout
EXPOSE 9090
CMD ["./k-tv-playout"]

View File

@@ -0,0 +1 @@
ALTER TABLE library_items ADD COLUMN role TEXT NOT NULL DEFAULT 'program';

View File

@@ -0,0 +1 @@
ALTER TABLE channels RENAME COLUMN recycle_policy TO rotation_policy;

View File

@@ -12,12 +12,8 @@ interface AccessSettingsEditorProps {
export function AccessSettingsEditor({ export function AccessSettingsEditor({
accessMode, accessMode,
accessPassword,
onAccessModeChange, onAccessModeChange,
onAccessPasswordChange,
label = "Access", label = "Access",
passwordLabel = "Password",
passwordHint = "Leave blank to keep existing password",
}: AccessSettingsEditorProps) { }: AccessSettingsEditorProps) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -25,33 +21,13 @@ export function AccessSettingsEditor({
<label className="block text-xs font-medium text-zinc-400">{label}</label> <label className="block text-xs font-medium text-zinc-400">{label}</label>
<select <select
value={accessMode} value={accessMode}
onChange={(e) => { onChange={(e) => onAccessModeChange(e.target.value as AccessMode)}
onAccessModeChange(e.target.value as AccessMode);
onAccessPasswordChange("");
}}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none" className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
> >
<option value="public">Public</option> <option value="public">Public</option>
<option value="password_protected">Password protected</option> <option value="private">Private</option>
<option value="account_required">Account required</option>
<option value="owner_only">Owner only</option>
</select> </select>
</div> </div>
{accessMode === "password_protected" && (
<div className="space-y-1.5">
<label className="block text-xs font-medium text-zinc-400">
{passwordLabel}
</label>
<input
type="password"
placeholder={passwordHint}
value={accessPassword}
onChange={(e) => onAccessPasswordChange(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</div>
)}
</div> </div>
); );
} }

View File

@@ -176,6 +176,9 @@ export function AlgorithmicFilterEditor({
<option value="random">Random</option> <option value="random">Random</option>
<option value="best_fit">Best fit</option> <option value="best_fit">Best fit</option>
<option value="sequential">Sequential</option> <option value="sequential">Sequential</option>
<option value="alternating">Alternating</option>
<option value="weighted">Weighted</option>
<option value="marathon">Marathon</option>
</NativeSelect> </NativeSelect>
</Field> </Field>
</div> </div>

View File

@@ -19,7 +19,6 @@ interface CreateChannelDialogProps {
timezone: string; timezone: string;
description: string; description: string;
access_mode?: AccessMode; access_mode?: AccessMode;
access_password?: string;
}) => void; }) => void;
isPending: boolean; isPending: boolean;
error?: string | null; error?: string | null;
@@ -36,7 +35,6 @@ export function CreateChannelDialog({
const [timezone, setTimezone] = useState("UTC"); const [timezone, setTimezone] = useState("UTC");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [accessMode, setAccessMode] = useState<AccessMode>("public"); const [accessMode, setAccessMode] = useState<AccessMode>("public");
const [accessPassword, setAccessPassword] = useState("");
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -45,7 +43,6 @@ export function CreateChannelDialog({
timezone, timezone,
description, description,
access_mode: accessMode !== "public" ? accessMode : undefined, access_mode: accessMode !== "public" ? accessMode : undefined,
access_password: accessMode === "password_protected" && accessPassword ? accessPassword : undefined,
}); });
}; };
@@ -57,7 +54,6 @@ export function CreateChannelDialog({
setTimezone("UTC"); setTimezone("UTC");
setDescription(""); setDescription("");
setAccessMode("public"); setAccessMode("public");
setAccessPassword("");
} }
} }
}; };
@@ -120,25 +116,10 @@ export function CreateChannelDialog({
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none" className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
> >
<option value="public">Public</option> <option value="public">Public</option>
<option value="password_protected">Password protected</option> <option value="private">Private</option>
<option value="account_required">Account required</option>
<option value="owner_only">Owner only</option>
</select> </select>
</div> </div>
{accessMode === "password_protected" && (
<div className="space-y-1.5">
<label className="block text-xs font-medium text-zinc-400">Password</label>
<input
type="password"
value={accessPassword}
onChange={(e) => setAccessPassword(e.target.value)}
placeholder="Channel password"
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</div>
)}
{error && <p className="text-xs text-red-400">{error}</p>} {error && <p className="text-xs text-red-400">{error}</p>}
<DialogFooter> <DialogFooter>
@@ -151,7 +132,7 @@ export function CreateChannelDialog({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending ? "Creating" : "Create channel"} {isPending ? "Creating..." : "Create channel"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

@@ -11,7 +11,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { BlockTimeline, BLOCK_COLORS } from "./block-timeline"; import { BlockTimeline, BLOCK_COLORS } from "./block-timeline";
import { AlgorithmicFilterEditor } from "./algorithmic-filter-editor"; import { AlgorithmicFilterEditor } from "./algorithmic-filter-editor";
import { RecyclePolicyEditor } from "./recycle-policy-editor"; import { RotationPolicyEditor } from "./rotation-policy-editor";
import { WebhookEditor } from "./webhook-editor"; import { WebhookEditor } from "./webhook-editor";
import { AccessSettingsEditor } from "./access-settings-editor"; import { AccessSettingsEditor } from "./access-settings-editor";
import { LogoEditor } from "./logo-editor"; import { LogoEditor } from "./logo-editor";
@@ -27,7 +27,7 @@ import type {
FillStrategy, FillStrategy,
MediaFilter, MediaFilter,
ProviderInfo, ProviderInfo,
RecyclePolicy, RotationPolicy,
Weekday, Weekday,
} from "@/lib/types"; } from "@/lib/types";
import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types"; import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types";
@@ -261,9 +261,9 @@ function BlockEditor({ block, index, errors, providers, onChange }: BlockEditorP
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={block.ignore_recycle_policy ?? false} checked={block.ignore_rotation_policy ?? false}
onChange={(e) => onChange={(e) =>
onChange({ ...block, ignore_recycle_policy: e.target.checked }) onChange({ ...block, ignore_rotation_policy: e.target.checked })
} }
className="accent-zinc-400" className="accent-zinc-400"
/> />
@@ -301,7 +301,7 @@ function BlockEditor({ block, index, errors, providers, onChange }: BlockEditorP
className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/> />
<p className="text-[11px] text-zinc-600"> <p className="text-[11px] text-zinc-600">
One Jellyfin item ID per line, played in order. One item ID per line, played in order.
</p> </p>
</div> </div>
)} )}
@@ -339,12 +339,11 @@ interface EditChannelSheetProps {
description: string; description: string;
timezone: string; timezone: string;
schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> }; schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> };
recycle_policy: RecyclePolicy; rotation_policy: RotationPolicy;
auto_schedule: boolean; auto_schedule: boolean;
access_mode?: AccessMode; access_mode?: string;
access_password?: string;
logo?: string | null; logo?: string | null;
logo_position?: LogoPosition; logo_position?: string;
logo_opacity?: number; logo_opacity?: number;
webhook_url?: string | null; webhook_url?: string | null;
webhook_poll_interval_secs?: number; webhook_poll_interval_secs?: number;
@@ -401,7 +400,7 @@ export function EditChannelSheet({
description: form.description, description: form.description,
timezone: form.timezone, timezone: form.timezone,
day_blocks: form.dayBlocks, day_blocks: form.dayBlocks,
recycle_policy: form.recyclePolicy, rotation_policy: form.rotationPolicy,
auto_schedule: form.autoSchedule, auto_schedule: form.autoSchedule,
access_mode: form.accessMode, access_mode: form.accessMode,
access_password: form.accessPassword, access_password: form.accessPassword,
@@ -418,10 +417,9 @@ export function EditChannelSheet({
description: form.description, description: form.description,
timezone: form.timezone, timezone: form.timezone,
schedule_config: { day_blocks: form.dayBlocks }, schedule_config: { day_blocks: form.dayBlocks },
recycle_policy: form.recyclePolicy, rotation_policy: form.rotationPolicy,
auto_schedule: form.autoSchedule, auto_schedule: form.autoSchedule,
access_mode: form.accessMode !== "public" ? form.accessMode : "public", access_mode: form.accessMode,
access_password: form.accessPassword || "",
logo: form.logo, logo: form.logo,
logo_position: form.logoPosition, logo_position: form.logoPosition,
logo_opacity: form.logoOpacity / 100, logo_opacity: form.logoOpacity / 100,
@@ -540,12 +538,12 @@ export function EditChannelSheet({
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500"> <h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
Recycle policy Rotation policy
</h3> </h3>
<RecyclePolicyEditor <RotationPolicyEditor
policy={form.recyclePolicy} policy={form.rotationPolicy}
errors={fieldErrors} errors={fieldErrors}
onChange={form.setRecyclePolicy} onChange={form.setRotationPolicy}
/> />
</section> </section>

View File

@@ -11,7 +11,7 @@ import {
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { ProgrammingBlock, RecyclePolicy } from "@/lib/types"; import type { ProgrammingBlock, RotationPolicy } from "@/lib/types";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Import schema — lenient so LLM output and community exports both work // Import schema — lenient so LLM output and community exports both work
@@ -37,7 +37,7 @@ const importBlockSchema = z.object({
max_duration_secs: z.number().nullable().optional(), max_duration_secs: z.number().nullable().optional(),
collections: z.array(z.string()).default([]), collections: z.array(z.string()).default([]),
}), }),
strategy: z.enum(["best_fit", "sequential", "random"]).default("random"), strategy: z.enum(["best_fit", "sequential", "random", "alternating", "weighted", "marathon"]).default("random"),
}), }),
z.object({ z.object({
type: z.literal("manual"), type: z.literal("manual"),
@@ -46,7 +46,7 @@ const importBlockSchema = z.object({
]), ]),
}); });
const recyclePolicySchema = z const rotationPolicySchema = z
.object({ .object({
cooldown_days: z.number().int().min(0).nullable().optional(), cooldown_days: z.number().int().min(0).nullable().optional(),
cooldown_generations: z.number().int().min(0).nullable().optional(), cooldown_generations: z.number().int().min(0).nullable().optional(),
@@ -62,7 +62,7 @@ const importSchema = z
timezone: z.string().default("UTC"), timezone: z.string().default("UTC"),
blocks: z.array(importBlockSchema).optional(), blocks: z.array(importBlockSchema).optional(),
schedule_config: z.object({ blocks: z.array(importBlockSchema).optional() }).optional(), schedule_config: z.object({ blocks: z.array(importBlockSchema).optional() }).optional(),
recycle_policy: recyclePolicySchema, rotation_policy: rotationPolicySchema,
}) })
.transform((d) => ({ .transform((d) => ({
name: d.name, name: d.name,
@@ -72,7 +72,7 @@ const importSchema = z
...b, ...b,
id: b.id ?? crypto.randomUUID(), id: b.id ?? crypto.randomUUID(),
})) as ProgrammingBlock[], })) as ProgrammingBlock[],
recycle_policy: d.recycle_policy as RecyclePolicy, rotation_policy: d.rotation_policy as RotationPolicy,
})); }));
export type ChannelImportData = z.output<typeof importSchema>; export type ChannelImportData = z.output<typeof importSchema>;

View File

@@ -1,4 +1,4 @@
import type { RecyclePolicy } from "@/lib/types"; import type { RotationPolicy } from "@/lib/types";
import type { FieldErrors } from "@/lib/schemas"; import type { FieldErrors } from "@/lib/schemas";
function NumberInput({ function NumberInput({
@@ -58,17 +58,17 @@ function Field({
); );
} }
interface RecyclePolicyEditorProps { interface RotationPolicyEditorProps {
policy: RecyclePolicy; policy: RotationPolicy;
errors: FieldErrors; errors: FieldErrors;
onChange: (policy: RecyclePolicy) => void; onChange: (policy: RotationPolicy) => void;
} }
export function RecyclePolicyEditor({ export function RotationPolicyEditor({
policy, policy,
errors, errors,
onChange, onChange,
}: RecyclePolicyEditorProps) { }: RotationPolicyEditorProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -96,7 +96,7 @@ export function RecyclePolicyEditor({
<Field <Field
label="Min available ratio" label="Min available ratio"
hint="0.01.0 · Fraction of the pool kept selectable even if cooldown is active" hint="0.01.0 · Fraction of the pool kept selectable even if cooldown is active"
error={errors["recycle_policy.min_available_ratio"]} error={errors["rotation_policy.min_available_ratio"]}
> >
<NumberInput <NumberInput
value={policy.min_available_ratio} value={policy.min_available_ratio}
@@ -107,7 +107,7 @@ export function RecyclePolicyEditor({
max={1} max={1}
step={0.01} step={0.01}
placeholder="0.1" placeholder="0.1"
error={!!errors["recycle_policy.min_available_ratio"]} error={!!errors["rotation_policy.min_available_ratio"]}
/> />
</Field> </Field>
</div> </div>

View File

@@ -6,12 +6,12 @@ import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types"; import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
import { BLOCK_COLORS } from "./block-timeline"; import { BLOCK_COLORS } from "./block-timeline";
// Stable color per block_id within a schedule // Stable color per source_block_id
function makeColorMap(slots: ScheduledSlotResponse[]): Map<string, string> { function makeColorMap(slots: ScheduledSlotResponse[]): Map<string, string> {
const seen = new Map<string, string>(); const seen = new Map<string, string>();
slots.forEach((slot) => { slots.forEach((slot) => {
if (!seen.has(slot.block_id)) { if (!seen.has(slot.source_block_id)) {
seen.set(slot.block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]); seen.set(slot.source_block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]);
} }
}); });
return seen; return seen;
@@ -66,7 +66,7 @@ function DayRow({ label, dayStart, slots, colorMap, now }: DayRowProps) {
const clampedEnd = Math.min(slotEnd.getTime(), dayEnd.getTime()); const clampedEnd = Math.min(slotEnd.getTime(), dayEnd.getTime());
const leftPct = ((clampedStart - dayStart.getTime()) / DAY_MS) * 100; const leftPct = ((clampedStart - dayStart.getTime()) / DAY_MS) * 100;
const widthPct = ((clampedEnd - clampedStart) / DAY_MS) * 100; const widthPct = ((clampedEnd - clampedStart) / DAY_MS) * 100;
const color = colorMap.get(slot.block_id) ?? "#6b7280"; const color = colorMap.get(slot.source_block_id) ?? "#6b7280";
const startTime = slotStart.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }); const startTime = slotStart.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
const endTime = slotEnd.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }); const endTime = slotEnd.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
@@ -201,7 +201,7 @@ export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProp
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Slots</h3> <h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Slots</h3>
<div className="rounded-md border border-zinc-800 divide-y divide-zinc-800"> <div className="rounded-md border border-zinc-800 divide-y divide-zinc-800">
{schedule.slots.map((slot) => { {schedule.slots.map((slot) => {
const color = colorMap.get(slot.block_id) ?? "#6b7280"; const color = colorMap.get(slot.source_block_id) ?? "#6b7280";
const start = new Date(slot.start_at).toLocaleString(undefined, { const start = new Date(slot.start_at).toLocaleString(undefined, {
weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false, weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false,
}); });

View File

@@ -32,7 +32,7 @@ import { ScheduleHistoryDialog } from "./components/schedule-history-dialog";
import type { import type {
ChannelResponse, ChannelResponse,
ProgrammingBlock, ProgrammingBlock,
RecyclePolicy, RotationPolicy,
Weekday, Weekday,
} from "@/lib/types"; } from "@/lib/types";
@@ -67,7 +67,6 @@ export default function DashboardPage() {
timezone: string; timezone: string;
description: string; description: string;
access_mode?: import("@/lib/types").AccessMode; access_mode?: import("@/lib/types").AccessMode;
access_password?: string;
}) => { }) => {
createChannel.mutate( createChannel.mutate(
{ {
@@ -75,7 +74,6 @@ export default function DashboardPage() {
timezone: data.timezone, timezone: data.timezone,
description: data.description || undefined, description: data.description || undefined,
access_mode: data.access_mode, access_mode: data.access_mode,
access_password: data.access_password,
}, },
{ onSuccess: () => setCreateOpen(false) }, { onSuccess: () => setCreateOpen(false) },
); );
@@ -88,12 +86,11 @@ export default function DashboardPage() {
description: string; description: string;
timezone: string; timezone: string;
schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> }; schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> };
recycle_policy: RecyclePolicy; rotation_policy: RotationPolicy;
auto_schedule: boolean; auto_schedule: boolean;
access_mode?: import("@/lib/types").AccessMode; access_mode?: string;
access_password?: string;
logo?: string | null; logo?: string | null;
logo_position?: import("@/lib/types").LogoPosition; logo_position?: string;
logo_opacity?: number; logo_opacity?: number;
webhook_url?: string | null; webhook_url?: string | null;
webhook_poll_interval_secs?: number; webhook_poll_interval_secs?: number;

View File

@@ -128,7 +128,7 @@ const TOC = [
{ id: "blocks", label: "Programming blocks" }, { id: "blocks", label: "Programming blocks" },
{ id: "filters", label: "Filters reference" }, { id: "filters", label: "Filters reference" },
{ id: "strategies", label: "Fill strategies" }, { id: "strategies", label: "Fill strategies" },
{ id: "recycle-policy", label: "Recycle policy" }, { id: "rotation-policy", label: "Rotation policy" },
{ id: "import-export", label: "Import & export" }, { id: "import-export", label: "Import & export" },
{ id: "iptv", label: "IPTV export" }, { id: "iptv", label: "IPTV export" },
{ id: "access-control", label: "Access control" }, { id: "access-control", label: "Access control" },
@@ -356,6 +356,11 @@ npm run dev`}</Pre>
"Falls back to NEXT_PUBLIC_API_URL", "Falls back to NEXT_PUBLIC_API_URL",
"Server-side API URL used by Next.js API routes. Set this if the frontend container reaches the backend via a private hostname.", "Server-side API URL used by Next.js API routes. Set this if the frontend container reaches the backend via a private hostname.",
], ],
[
<Code key="po">NEXT_PUBLIC_PLAYOUT_URL</Code>,
<Code key="po2">http://localhost:9090</Code>,
"Base URL of the Playout Service. The TV page connects directly to this for HLS streams.",
],
]} ]}
/> />
@@ -760,10 +765,10 @@ Authorization: Bearer <token>
</Section> </Section>
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
<Section id="recycle-policy"> <Section id="rotation-policy">
<H2>Recycle policy</H2> <H2>Rotation policy</H2>
<P> <P>
The recycle policy controls how soon the same item can reappear The rotation policy controls how soon the same item can reappear
across schedule generations, preventing a small library from cycling across schedule generations, preventing a small library from cycling
the same content every day. the same content every day.
</P> </P>
@@ -807,7 +812,7 @@ Authorization: Bearer <token>
<P> <P>
Click the download icon on any channel card in the Dashboard. A{" "} Click the download icon on any channel card in the Dashboard. A{" "}
<Code>.json</Code> file is saved containing the channel name, <Code>.json</Code> file is saved containing the channel name,
timezone, all programming blocks, and the recycle policy. timezone, all programming blocks, and the rotation policy.
</P> </P>
<H3>Importing</H3> <H3>Importing</H3>
@@ -848,7 +853,7 @@ Authorization: Bearer <token>
} }
} }
], ],
"recycle_policy": { "rotation_policy": {
"cooldown_days": 7, "cooldown_days": 7,
"cooldown_generations": null, "cooldown_generations": null,
"min_available_ratio": 0.15 "min_available_ratio": 0.15
@@ -886,11 +891,11 @@ Output only valid JSON matching this structure:
"max_duration_secs": number | null, "max_duration_secs": number | null,
"collections": [] "collections": []
}, },
"strategy": "random" | "sequential" | "best_fit" "strategy": "random" | "sequential" | "best_fit" | "alternating" | "weighted" | "marathon"
} }
} }
], ],
"recycle_policy": { "rotation_policy": {
"cooldown_days": number | null, "cooldown_days": number | null,
"cooldown_generations": number | null, "cooldown_generations": number | null,
"min_available_ratio": number "min_available_ratio": number
@@ -963,34 +968,11 @@ Output only valid JSON matching this structure:
"Anyone can watch. This is the default.", "Anyone can watch. This is the default.",
], ],
[ [
<Code key="pp">password_protected</Code>, <Code key="priv">private</Code>,
"Viewers must enter a password before the stream plays.", "Only authenticated users can watch.",
],
[
<Code key="ar">account_required</Code>,
"Viewers must be logged in to any K-TV account.",
],
[
<Code key="oo">owner_only</Code>,
"Only the channel owner can watch.",
], ],
]} ]}
/> />
<H3>Setting a password</H3>
<P>
When <Code>access_mode</Code> is{" "}
<Code>password_protected</Code>, enter a value in the{" "}
<strong className="text-zinc-300">Password</strong> field in the
edit sheet. Leave the field blank to remove an existing password.
</P>
<Warn>
Channel passwords are not end-to-end encrypted. They prevent casual
access someone who can intercept network traffic or extract the
JWT from an IPTV URL can still reach the stream. Do not use channel
passwords as the sole protection for sensitive content.
</Warn>
</Section> </Section>
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
@@ -1210,7 +1192,7 @@ Output only valid JSON matching this structure:
Clearing <Code>collections</Code> to search all libraries. Clearing <Code>collections</Code> to search all libraries.
</Li> </Li>
<Li> <Li>
Lowering <Code>min_available_ratio</Code> if the recycle cooldown Lowering <Code>min_available_ratio</Code> if the rotation cooldown
is excluding too many items. is excluding too many items.
</Li> </Li>
</Ul> </Ul>

View File

@@ -1,6 +1,4 @@
import type { LogoPosition } from "@/lib/types"; function logoPositionClass(pos?: string) {
function logoPositionClass(pos?: LogoPosition) {
switch (pos) { switch (pos) {
case "top_left": case "top_left":
return "top-0 left-0"; return "top-0 left-0";
@@ -15,7 +13,7 @@ function logoPositionClass(pos?: LogoPosition) {
interface LogoWatermarkProps { interface LogoWatermarkProps {
logo: string; logo: string;
position?: LogoPosition; position?: string;
opacity?: number; opacity?: number;
} }

View File

@@ -89,8 +89,6 @@ function TvPageContent() {
error: broadcastError, error: broadcastError,
} = useCurrentBroadcast(channel?.id ?? "", passwords.channelPassword); } = useCurrentBroadcast(channel?.id ?? "", passwords.channelPassword);
const blockPassword = passwords.getBlockPassword(broadcast?.slot.id);
const { data: epgSlots } = useEpg( const { data: epgSlots } = useEpg(
channel?.id ?? "", channel?.id ?? "",
undefined, undefined,
@@ -102,14 +100,7 @@ function TvPageContent() {
const volume = useVolume(videoRef, isCasting); const volume = useVolume(videoRef, isCasting);
const subtitles = useSubtitlePicker(channelIdx, broadcast?.slot.id); const subtitles = useSubtitlePicker(channelIdx, broadcast?.slot.id);
const { data: streamUrl, error: streamUrlError } = useStreamUrl( const { data: streamUrl, error: streamUrlError } = useStreamUrl(channel?.id);
channel?.id,
token,
broadcast?.slot.id,
passwords.channelPassword,
blockPassword,
quality.quality,
);
const channelCount = channels?.length ?? 0; const channelCount = channels?.length ?? 0;
@@ -176,13 +167,6 @@ function TvPageContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [broadcastError]); }, [broadcastError]);
useEffect(() => {
if ((streamUrlError as Error)?.message === "password_required") {
passwords.setShowBlockPasswordModal(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streamUrlError]);
// Clear transient states when slot changes // Clear transient states when slot changes
useEffect(() => { useEffect(() => {
setStreamError(false); setStreamError(false);

View File

@@ -1,67 +0,0 @@
import { NextRequest } from "next/server";
// Server-side URL of the K-TV backend (never exposed to the browser).
// Falls back to the public URL if the internal one isn't set.
const API_URL =
process.env.API_URL ??
process.env.NEXT_PUBLIC_API_URL ??
"http://localhost:4000/api/v1";
/**
* GET /api/stream/[channelId]?token=<bearer>
*
* Resolves the backend's 307 stream redirect and returns the final
* Jellyfin URL as JSON. Browsers can't read the Location header from a
* redirected fetch, so this server-side route does it for them.
*
* Returns:
* 200 { url: string } — stream URL ready to use as <video src>
* 204 — channel is in a gap (no-signal)
* 401 — missing token
* 502 — backend error
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ channelId: string }> },
) {
const { channelId } = await params;
const token = request.nextUrl.searchParams.get("token");
const channelPassword = request.nextUrl.searchParams.get("channel_password");
const blockPassword = request.nextUrl.searchParams.get("block_password");
const quality = request.nextUrl.searchParams.get("quality");
let res: Response;
try {
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
if (channelPassword) headers["X-Channel-Password"] = channelPassword;
if (blockPassword) headers["X-Block-Password"] = blockPassword;
const backendParams = new URLSearchParams();
if (quality) backendParams.set("quality", quality);
const backendQuery = backendParams.toString() ? `?${backendParams}` : "";
res = await fetch(`${API_URL}/channels/${channelId}/stream${backendQuery}`, {
headers,
redirect: "manual",
});
} catch {
return new Response(null, { status: 502 });
}
if (res.status === 204) {
return new Response(null, { status: 204 });
}
if (res.status === 401 || res.status === 403) {
const body = await res.json().catch(() => ({}));
return Response.json(body, { status: res.status });
}
if (res.status === 307 || res.status === 302 || res.status === 301) {
const location = res.headers.get("Location");
if (location) {
return Response.json({ url: location });
}
}
return new Response(null, { status: 502 });
}

View File

@@ -61,7 +61,7 @@ export default function LandingPage() {
</h3> </h3>
<p className="text-sm leading-relaxed text-zinc-400"> <p className="text-sm leading-relaxed text-zinc-400">
Draw time blocks on a 24-hour timeline. Each block has its own Draw time blocks on a 24-hour timeline. Each block has its own
filters, fill strategy, and recycle policy. Schedules are filters, fill strategy, and rotation policy. Schedules are
generated on demand and valid for 48 hours. generated on demand and valid for 48 hours.
</p> </p>
</div> </div>

View File

@@ -8,7 +8,7 @@ import type {
LogoPosition, LogoPosition,
ProgrammingBlock, ProgrammingBlock,
MediaFilter, MediaFilter,
RecyclePolicy, RotationPolicy,
Weekday, Weekday,
} from "@/lib/types"; } from "@/lib/types";
import { WEEKDAYS } from "@/lib/types"; import { WEEKDAYS } from "@/lib/types";
@@ -51,7 +51,7 @@ export function defaultBlock(startMins = 20 * 60, durationMins = 60): Programmin
duration_mins: durationMins, duration_mins: durationMins,
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" }, content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
loop_on_finish: true, loop_on_finish: true,
ignore_recycle_policy: false, ignore_rotation_policy: false,
access_mode: "public", access_mode: "public",
}; };
} }
@@ -67,7 +67,7 @@ export function useChannelForm(channel: ChannelResponse | null) {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [timezone, setTimezone] = useState("UTC"); const [timezone, setTimezone] = useState("UTC");
const [dayBlocks, setDayBlocks] = useState<Record<Weekday, ProgrammingBlock[]>>(emptyDayBlocks); const [dayBlocks, setDayBlocks] = useState<Record<Weekday, ProgrammingBlock[]>>(emptyDayBlocks);
const [recyclePolicy, setRecyclePolicy] = useState<RecyclePolicy>({ const [rotationPolicy, setRotationPolicy] = useState<RotationPolicy>({
cooldown_days: null, cooldown_days: null,
cooldown_generations: null, cooldown_generations: null,
min_available_ratio: 0.1, min_available_ratio: 0.1,
@@ -96,12 +96,12 @@ export function useChannelForm(channel: ChannelResponse | null) {
...emptyDayBlocks(), ...emptyDayBlocks(),
...channel.schedule_config.day_blocks, ...channel.schedule_config.day_blocks,
}); });
setRecyclePolicy(channel.recycle_policy); setRotationPolicy(channel.rotation_policy);
setAutoSchedule(channel.auto_schedule); setAutoSchedule(channel.auto_schedule);
setAccessMode(channel.access_mode ?? "public"); setAccessMode((channel.access_mode as AccessMode) ?? "public");
setAccessPassword(""); setAccessPassword("");
setLogo(channel.logo ?? null); setLogo(channel.logo ?? null);
setLogoPosition(channel.logo_position ?? "top_right"); setLogoPosition((channel.logo_position as LogoPosition) ?? "top_right");
setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100)); setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100));
setWebhookUrl(channel.webhook_url ?? ""); setWebhookUrl(channel.webhook_url ?? "");
setWebhookPollInterval(channel.webhook_poll_interval_secs ?? 5); setWebhookPollInterval(channel.webhook_poll_interval_secs ?? 5);
@@ -164,7 +164,7 @@ export function useChannelForm(channel: ChannelResponse | null) {
// Blocks (day-keyed) // Blocks (day-keyed)
dayBlocks, setDayBlocks, dayBlocks, setDayBlocks,
selectedBlockId, setSelectedBlockId, selectedBlockId, setSelectedBlockId,
recyclePolicy, setRecyclePolicy, rotationPolicy, setRotationPolicy,
addBlock, addBlock,
updateBlock, updateBlock,
removeBlock, removeBlock,

View File

@@ -33,7 +33,7 @@ export function useImportChannel(token: string | null) {
WEEKDAYS.map(d => [d, d === 'monday' ? data.blocks : []]) WEEKDAYS.map(d => [d, d === 'monday' ? data.blocks : []])
) as Record<Weekday, typeof data.blocks>, ) as Record<Weekday, typeof data.blocks>,
}, },
recycle_policy: data.recycle_policy, rotation_policy: data.rotation_policy,
}, },
token, token,
); );

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query";
import type { ScheduleSlot } from "@/app/(main)/tv/components"; import type { ScheduleSlot } from "@/app/(main)/tv/components";
import type { ScheduledSlotResponse } from "@/lib/types"; import type { ScheduledSlotResponse } from "@/lib/types";
@@ -99,63 +98,17 @@ export function findNextSlot(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// useStreamUrl — resolves the 307 stream redirect via a Next.js API route // useStreamUrl — HLS playlist from the Playout Service
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** const PLAYOUT_URL =
* Resolves the live stream URL for a channel, starting at the correct process.env.NEXT_PUBLIC_PLAYOUT_URL ?? "http://localhost:9090";
* broadcast offset so refresh doesn't replay from the beginning.
* export function useStreamUrl(channelId: string | undefined) {
* The backend's GET /channels/:id/stream endpoint returns a 307 redirect to if (!channelId) return { data: null, isLoading: false, error: null };
* the Jellyfin stream URL. Since browsers can't read redirect Location headers return {
* from fetch(), we proxy through /api/stream/[channelId] (a Next.js route that data: `${PLAYOUT_URL}/playout/${channelId}/playlist.m3u8`,
* runs server-side) and return the final URL as JSON. isLoading: false,
* error: null,
* slotId is included in the query key so the URL is re-fetched automatically };
* when the current item changes (the next scheduled item starts playing).
* Within the same slot, the URL stays stable — no mid-item restarts.
*
* Returns null when the channel is in a gap (no-signal / 204).
*/
/**
* Resolves the stream URL for the current slot, with StartTimeTicks set so
* Jellyfin begins transcoding at the correct broadcast offset.
*
* slotId is in the query key: the URL refetches when the item changes (new
* slot), but stays stable while the same slot is playing — no mid-item
* restarts. offsetSecs is captured once when the query first runs for a
* given slot, so 30-second broadcast refetches don't disturb playback.
*/
export function useStreamUrl(
channelId: string | undefined,
token: string | null,
slotId: string | undefined,
channelPassword?: string,
blockPassword?: string,
bitrateBps?: number,
) {
return useQuery({
queryKey: ["stream-url", channelId, slotId, channelPassword, blockPassword, bitrateBps],
queryFn: async (): Promise<string | null> => {
const params = new URLSearchParams();
if (token) params.set("token", token);
if (channelPassword) params.set("channel_password", channelPassword);
if (blockPassword) params.set("block_password", blockPassword);
if (quality) params.set("quality", quality);
const res = await fetch(`/api/stream/${channelId}?${params}`, {
cache: "no-store",
});
if (res.status === 204) return null;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const msg = body?.error ?? `Stream resolve failed: ${res.status}`;
throw new Error(msg);
}
const { url } = (await res.json()) as { url: string };
return bitrateBps ? `${url}&VideoBitRate=${bitrateBps}` : url;
},
enabled: !!channelId && !!slotId,
staleTime: Infinity,
retry: false,
});
} }

View File

@@ -6,7 +6,7 @@ export function exportChannel(channel: ChannelResponse): void {
description: channel.description ?? undefined, description: channel.description ?? undefined,
timezone: channel.timezone, timezone: channel.timezone,
day_blocks: channel.schedule_config.day_blocks, day_blocks: channel.schedule_config.day_blocks,
recycle_policy: channel.recycle_policy, rotation_policy: channel.rotation_policy,
}; };
const blob = new Blob([JSON.stringify(payload, null, 2)], { const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json", type: "application/json",

View File

@@ -26,9 +26,7 @@ export const mediaFilterSchema = z.object({
export const accessModeSchema = z.enum([ export const accessModeSchema = z.enum([
"public", "public",
"password_protected", "private",
"account_required",
"owner_only",
]); ]);
export const blockSchema = z.object({ export const blockSchema = z.object({
@@ -40,7 +38,7 @@ export const blockSchema = z.object({
z.object({ z.object({
type: z.literal("algorithmic"), type: z.literal("algorithmic"),
filter: mediaFilterSchema, filter: mediaFilterSchema,
strategy: z.enum(["best_fit", "sequential", "random"]), strategy: z.enum(["best_fit", "sequential", "random", "alternating", "weighted", "marathon"]),
provider_id: z.string().optional(), provider_id: z.string().optional(),
}), }),
z.object({ z.object({
@@ -50,7 +48,7 @@ export const blockSchema = z.object({
}), }),
]), ]),
loop_on_finish: z.boolean().optional(), loop_on_finish: z.boolean().optional(),
ignore_recycle_policy: z.boolean().optional(), ignore_rotation_policy: z.boolean().optional(),
access_mode: accessModeSchema.optional(), access_mode: accessModeSchema.optional(),
access_password: z.string().optional(), access_password: z.string().optional(),
}); });
@@ -63,7 +61,7 @@ export const channelFormSchema = z.object({
.default(() => .default(() =>
Object.fromEntries(WEEKDAYS.map(d => [d, []])) as unknown as Record<Weekday, z.infer<typeof blockSchema>[]> Object.fromEntries(WEEKDAYS.map(d => [d, []])) as unknown as Record<Weekday, z.infer<typeof blockSchema>[]>
), ),
recycle_policy: z.object({ rotation_policy: z.object({
cooldown_days: z.number().int().min(0).nullable().optional(), cooldown_days: z.number().int().min(0).nullable().optional(),
cooldown_generations: z.number().int().min(0).nullable().optional(), cooldown_generations: z.number().int().min(0).nullable().optional(),
min_available_ratio: z min_available_ratio: z

View File

@@ -1,5 +1,3 @@
// API response and request types matching the backend DTOs
export interface ActivityEvent { export interface ActivityEvent {
id: string; id: string;
timestamp: string; timestamp: string;
@@ -17,11 +15,11 @@ export interface LogLine {
export type ContentType = "movie" | "episode" | "short"; export type ContentType = "movie" | "episode" | "short";
export type AccessMode = "public" | "password_protected" | "account_required" | "owner_only"; export type AccessMode = "public" | "private";
export type LogoPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right"; export type LogoPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right";
export type FillStrategy = "best_fit" | "sequential" | "random"; export type FillStrategy = "best_fit" | "sequential" | "random" | "alternating" | "weighted" | "marathon";
export interface MediaFilter { export interface MediaFilter {
content_type?: ContentType | null; content_type?: ContentType | null;
@@ -31,9 +29,7 @@ export interface MediaFilter {
min_duration_secs?: number | null; min_duration_secs?: number | null;
max_duration_secs?: number | null; max_duration_secs?: number | null;
collections: string[]; collections: string[];
/** Filter to one or more TV series by name. OR-combined: any listed show is eligible. */
series_names?: string[]; series_names?: string[];
/** Free-text search, used for library browsing only. */
search_term?: string | null; search_term?: string | null;
} }
@@ -55,22 +51,43 @@ export interface SeriesResponse {
export interface LibraryItemResponse { export interface LibraryItemResponse {
id: string; id: string;
provider_id: string;
external_id: string;
title: string; title: string;
content_type: ContentType; content_type: string;
duration_secs: number; duration_secs: number;
series_name?: string | null; series_name?: string | null;
season_number?: number | null; season_number?: number | null;
episode_number?: number | null; episode_number?: number | null;
year?: number | null; year?: number | null;
genres: string[]; genres: string[];
tags: string[];
collection_id?: string | null;
collection_name?: string | null;
collection_type?: string | null;
thumbnail_url?: string | null;
synced_at?: string | null;
} }
export interface RecyclePolicy { export interface RotationPolicy {
cooldown_days?: number | null; cooldown_days?: number | null;
cooldown_generations?: number | null; cooldown_generations?: number | null;
min_available_ratio: number; min_available_ratio: number;
} }
export interface InterstitialRule {
pool_filter: MediaFilter;
strategy: FillStrategy;
min_gap_secs: number;
}
export interface MidRollRule {
prefer_chapters: boolean;
fallback_interval_mins: number;
break_duration_secs: number;
pool_filter: MediaFilter;
}
export type BlockContent = export type BlockContent =
| { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy; provider_id?: string } | { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy; provider_id?: string }
| { type: "manual"; items: string[]; provider_id?: string }; | { type: "manual"; items: string[]; provider_id?: string };
@@ -78,16 +95,14 @@ export type BlockContent =
export interface ProgrammingBlock { export interface ProgrammingBlock {
id: string; id: string;
name: string; name: string;
/** "HH:MM:SS" */
start_time: string; start_time: string;
duration_mins: number; duration_mins: number;
content: BlockContent; content: BlockContent;
/** Sequential only: loop back to episode 1 after the last episode. Default true on backend. */
loop_on_finish?: boolean; loop_on_finish?: boolean;
/** When true, skip the channel-level recycle policy for this block. Default false on backend. */ ignore_rotation_policy?: boolean;
ignore_recycle_policy?: boolean; interstitial_rule?: InterstitialRule | null;
mid_roll_rule?: MidRollRule | null;
access_mode?: AccessMode; access_mode?: AccessMode;
/** Plain-text password sent to API; hashed server-side. Only set on write operations. */
access_password?: string; access_password?: string;
} }
@@ -154,9 +169,7 @@ export interface ProviderInfo {
export interface ConfigResponse { export interface ConfigResponse {
allow_registration: boolean; allow_registration: boolean;
/** All registered providers. Added in multi-provider update. */
providers: ProviderInfo[]; providers: ProviderInfo[];
/** Primary provider capabilities — kept for backward compat. */
provider_capabilities: ProviderCapabilities; provider_capabilities: ProviderCapabilities;
available_provider_types: string[]; available_provider_types: string[];
} }
@@ -198,16 +211,17 @@ export interface ChannelResponse {
description?: string | null; description?: string | null;
timezone: string; timezone: string;
schedule_config: ScheduleConfig; schedule_config: ScheduleConfig;
recycle_policy: RecyclePolicy; rotation_policy: RotationPolicy;
auto_schedule: boolean; auto_schedule: boolean;
access_mode: AccessMode; access_mode: string;
logo?: string | null; logo?: string | null;
logo_position: LogoPosition; logo_position: string;
logo_opacity: number; logo_opacity: number;
webhook_url?: string | null; webhook_url?: string | null;
webhook_poll_interval_secs?: number; webhook_poll_interval_secs: number;
webhook_body_template?: string | null; webhook_body_template?: string | null;
webhook_headers?: string | null; webhook_headers?: string | null;
gap_filler?: MediaFilter | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -229,22 +243,17 @@ export interface UpdateChannelRequest {
description?: string; description?: string;
timezone?: string; timezone?: string;
schedule_config?: ScheduleConfig; schedule_config?: ScheduleConfig;
recycle_policy?: RecyclePolicy; rotation_policy?: RotationPolicy;
auto_schedule?: boolean; auto_schedule?: boolean;
access_mode?: AccessMode; access_mode?: string;
/** Empty string clears the password. */
access_password?: string;
/** null = clear logo */
logo?: string | null; logo?: string | null;
logo_position?: LogoPosition; logo_position?: string;
logo_opacity?: number; logo_opacity?: number;
/** null = clear webhook */
webhook_url?: string | null; webhook_url?: string | null;
webhook_poll_interval_secs?: number; webhook_poll_interval_secs?: number;
/** null = clear template */
webhook_body_template?: string | null; webhook_body_template?: string | null;
/** null = clear headers */
webhook_headers?: string | null; webhook_headers?: string | null;
gap_filler?: MediaFilter | null;
} }
// Media & Schedule // Media & Schedule
@@ -252,56 +261,40 @@ export interface UpdateChannelRequest {
export interface MediaItemResponse { export interface MediaItemResponse {
id: string; id: string;
title: string; title: string;
content_type: ContentType; content_type: string;
duration_secs: number; duration_secs: number;
description?: string | null; description?: string | null;
genres: string[]; genres: string[];
tags: string[]; tags: string[];
year?: number | null; year?: number | null;
/** Episodes only: the parent TV show name. */
series_name?: string | null; series_name?: string | null;
/** Episodes only: season number (1-based). */
season_number?: number | null; season_number?: number | null;
/** Episodes only: episode number within the season (1-based). */
episode_number?: number | null; episode_number?: number | null;
} }
export interface ScheduledSlotResponse { export interface ScheduledSlotResponse {
id: string; id: string;
block_id: string;
item: MediaItemResponse;
/** RFC3339 */
start_at: string; start_at: string;
/** RFC3339 */
end_at: string; end_at: string;
block_access_mode: AccessMode; item: MediaItemResponse;
source_block_id: string;
} }
export interface ScheduleResponse { export interface ScheduleResponse {
id: string; id: string;
channel_id: string; channel_id: string;
generation: number;
generated_at: string;
valid_from: string; valid_from: string;
valid_until: string; valid_until: string;
generation: number;
slots: ScheduledSlotResponse[]; slots: ScheduledSlotResponse[];
} }
export interface CurrentBroadcastResponse { export interface CurrentBroadcastResponse {
slot: ScheduledSlotResponse; slot: ScheduledSlotResponse;
offset_secs: number; offset_secs: number;
block_access_mode: AccessMode;
} }
// Library management export type LibraryItemFull = LibraryItemResponse;
// 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 { export interface ShowSummary {
series_name: string; series_name: string;

View File

@@ -0,0 +1 @@
ALTER TABLE library_items ADD COLUMN role TEXT NOT NULL DEFAULT 'program';

View File

@@ -0,0 +1 @@
ALTER TABLE channels RENAME COLUMN recycle_policy TO rotation_policy;