Compare commits
29 Commits
60524c56f7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2698cb3ad4 | |||
| abd6bf4bc4 | |||
| 25ff9d2779 | |||
| 2995aea606 | |||
| f52e024e98 | |||
| 5561b70e1b | |||
| 8de7d33007 | |||
| 8dabbdf280 | |||
| 9558f04f73 | |||
| a823a79f6b | |||
| 5bc1e5e44b | |||
| 711f0e4411 | |||
| a7fa2ec4aa | |||
| 6b0d060945 | |||
| 6dbf7ab98c | |||
| e27e2ab6d1 | |||
| b00272aceb | |||
| affd7f0223 | |||
| 8db70df0a0 | |||
| c14e01e0f2 | |||
| 874de68fb5 | |||
| 79e975f057 | |||
| 8edd7a9c0b | |||
| 8e4f724562 | |||
| f21d70c559 | |||
| 607d311375 | |||
| 84dd05a8a6 | |||
| 1b3ecc10e1 | |||
| de7f3092d2 |
40
Cargo.lock
generated
40
Cargo.lock
generated
@@ -25,6 +25,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
@@ -1766,6 +1767,29 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -1795,6 +1819,7 @@ name = "presentation"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"adapter-auth",
|
||||
"adapter-common",
|
||||
"adapter-event-publisher",
|
||||
"adapter-jellyfin",
|
||||
"adapter-local-files",
|
||||
@@ -1818,6 +1843,8 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
"utoipa-scalar",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -3004,6 +3031,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "uuid"
|
||||
version = "1.23.4"
|
||||
@@ -3372,6 +3411,7 @@ name = "worker"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"adapter-auth",
|
||||
"adapter-common",
|
||||
"adapter-event-publisher",
|
||||
"adapter-jellyfin",
|
||||
"adapter-local-files",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[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"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -24,6 +24,7 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
utoipa = { version = "5", features = ["chrono", "uuid"] }
|
||||
utoipa-scalar = { version = "0.3", features = ["axum"] }
|
||||
jsonwebtoken = "9"
|
||||
|
||||
# Internal crates
|
||||
|
||||
@@ -11,24 +11,38 @@
|
||||
# TRAEFIK_CERT_RESOLVER cert resolver name for TLS (default: letsencrypt)
|
||||
# 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
|
||||
# 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,
|
||||
# e.g. https://tv-api.example.com/api/v1, and you must rebuild after changing it.
|
||||
|
||||
services:
|
||||
|
||||
backend:
|
||||
ports: [] # Traefik handles ingress; no direct port exposure needed
|
||||
presentation:
|
||||
ports: []
|
||||
networks:
|
||||
- default
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}"
|
||||
- "traefik.http.routers.ktv-backend.rule=Host(`${BACKEND_HOST}`)"
|
||||
- "traefik.http.routers.ktv-backend.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.ktv-backend.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
|
||||
- "traefik.http.services.ktv-backend.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.ktv-presentation.rule=Host(`${BACKEND_HOST}`)"
|
||||
- "traefik.http.routers.ktv-presentation.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.ktv-presentation.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
|
||||
- "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:
|
||||
ports: []
|
||||
|
||||
77
compose.yml
77
compose.yml
@@ -1,19 +1,19 @@
|
||||
services:
|
||||
|
||||
# ── Backend (Rust / Axum) ──────────────────────────────────────────────────
|
||||
backend:
|
||||
build: ./k-tv-backend
|
||||
# ── Presentation (Rust / Axum — HTTP API) ────────────────────────────────
|
||||
presentation:
|
||||
build:
|
||||
context: ./k-tv-backend
|
||||
target: presentation
|
||||
image: registry.gabrielkaszewski.dev/k-tv-presentation:latest
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- 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}
|
||||
# Auth — generate with: openssl rand -hex 32
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
# Cookie secret — generate with: openssl rand -base64 64
|
||||
- COOKIE_SECRET=${COOKIE_SECRET}
|
||||
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24}
|
||||
- SECURE_COOKIE=${SECURE_COOKIE:-false}
|
||||
@@ -21,7 +21,6 @@ services:
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
|
||||
- DB_MAX_CONNECTIONS=${DB_MAX_CONNECTIONS:-5}
|
||||
- DB_MIN_CONNECTIONS=${DB_MIN_CONNECTIONS:-1}
|
||||
# Jellyfin — all three required for schedule generation
|
||||
- JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL}
|
||||
- JELLYFIN_API_KEY=${JELLYFIN_API_KEY}
|
||||
- JELLYFIN_USER_ID=${JELLYFIN_USER_ID}
|
||||
@@ -34,40 +33,60 @@ services:
|
||||
timeout: 5s
|
||||
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:
|
||||
build:
|
||||
context: ./k-tv-frontend
|
||||
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_PLAYOUT_URL: ${NEXT_PUBLIC_PLAYOUT_URL:-http://localhost:9090}
|
||||
image: registry.gabrielkaszewski.dev/k-tv-frontend:latest
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3001}:3001"
|
||||
environment:
|
||||
# Server-side API URL — uses Docker's internal network, never exposed.
|
||||
# Next.js API routes (e.g. /api/stream/[channelId]) use this.
|
||||
API_URL: http://backend:3000/api/v1
|
||||
API_URL: http://presentation:3000/api/v1
|
||||
depends_on:
|
||||
backend:
|
||||
presentation:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
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:
|
||||
|
||||
@@ -11,3 +11,4 @@ uuid = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
180
crates/adapters/adapter-common/src/ffprobe.rs
Normal file
180
crates/adapters/adapter-common/src/ffprobe.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
pub mod ffprobe;
|
||||
pub mod role_detector;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{DomainError, RotationPolicy, ScheduleConfig, ScheduleConfigCompat};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
158
crates/adapters/adapter-common/src/role_detector.rs
Normal file
158
crates/adapters/adapter-common/src/role_detector.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
struct ChannelRow {
|
||||
@@ -126,7 +126,7 @@ impl ChannelCommand for SqliteChannelRepository {
|
||||
sqlx::query(
|
||||
r#"
|
||||
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,
|
||||
logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template,
|
||||
webhook_headers, gap_filler, created_at, updated_at)
|
||||
@@ -136,7 +136,7 @@ impl ChannelCommand for SqliteChannelRepository {
|
||||
description = excluded.description,
|
||||
timezone = excluded.timezone,
|
||||
schedule_config = excluded.schedule_config,
|
||||
recycle_policy = excluded.recycle_policy,
|
||||
rotation_policy = excluded.rotation_policy,
|
||||
auto_schedule = excluded.auto_schedule,
|
||||
access_mode = excluded.access_mode,
|
||||
logo = excluded.logo,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
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::{
|
||||
ports::library::{LibraryCommand, LibraryQuery},
|
||||
ContentType, DomainError, DomainResult, LibraryCollection,
|
||||
@@ -40,10 +40,15 @@ struct LibraryItemRow {
|
||||
thumbnail_url: Option<String>,
|
||||
synced_at: String,
|
||||
chapters: Option<String>,
|
||||
role: Option<String>,
|
||||
}
|
||||
|
||||
impl LibraryItemRow {
|
||||
fn into_media_item(self) -> MediaItem {
|
||||
let role: MediaRole = self
|
||||
.role
|
||||
.map(parse_enum_or_default)
|
||||
.unwrap_or_default();
|
||||
MediaItem::from_persistence(DomainMediaItemRow {
|
||||
id: domain::MediaItemId::new(&self.id),
|
||||
provider_id: self.provider_id,
|
||||
@@ -63,7 +68,7 @@ impl LibraryItemRow {
|
||||
collection_type: self.collection_type,
|
||||
thumbnail_url: self.thumbnail_url,
|
||||
synced_at: Some(self.synced_at),
|
||||
role: MediaRole::default(),
|
||||
role,
|
||||
chapters: self
|
||||
.chapters
|
||||
.as_deref()
|
||||
@@ -116,12 +121,14 @@ impl LibraryCommand for SqliteLibraryRepository {
|
||||
Some(serde_json::to_string(item.chapters()).unwrap_or_default())
|
||||
};
|
||||
|
||||
let role_str = serialize_enum_as_string(item.role(), "program");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO library_items
|
||||
(id, provider_id, external_id, title, content_type, duration_secs,
|
||||
series_name, season_number, episode_number, year, genres, tags,
|
||||
collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters, role)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(item.id().value())
|
||||
.bind(item.provider_id())
|
||||
@@ -141,6 +148,7 @@ impl LibraryCommand for SqliteLibraryRepository {
|
||||
.bind(item.thumbnail_url())
|
||||
.bind(item.synced_at().unwrap_or(""))
|
||||
.bind(&chapters_json)
|
||||
.bind(&role_str)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
@@ -151,6 +159,22 @@ impl LibraryCommand for SqliteLibraryRepository {
|
||||
.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<()> {
|
||||
sqlx::query("DELETE FROM library_items WHERE provider_id = ?")
|
||||
.bind(provider_id)
|
||||
@@ -249,6 +273,19 @@ impl LibraryQuery for SqliteLibraryRepository {
|
||||
.collect();
|
||||
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() {
|
||||
conditions.push(format!("season_number = {}", sn));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -17,7 +17,7 @@ pub struct ActivityEventResponse {
|
||||
pub channel_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ActivityLogParams {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ pub struct UpdateChannelRequest {
|
||||
pub webhook_poll_interval_secs: Option<u32>,
|
||||
pub webhook_body_template: 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)]
|
||||
@@ -55,6 +57,8 @@ pub struct ChannelResponse {
|
||||
pub webhook_poll_interval_secs: u32,
|
||||
pub webhook_body_template: 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 updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -78,6 +82,7 @@ impl From<domain::Channel> for ChannelResponse {
|
||||
webhook_poll_interval_secs: c.webhook_poll_interval_secs(),
|
||||
webhook_body_template: c.webhook_body_template().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(),
|
||||
updated_at: c.updated_at(),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct IptvParams {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
|
||||
pub use iptv::IptvParams;
|
||||
pub use library::{
|
||||
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam,
|
||||
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
|
||||
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry, UpdateRoleRequest,
|
||||
};
|
||||
pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
|
||||
pub use schedule::{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::common::enum_to_string;
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct LibraryItemResponse {
|
||||
pub collection_type: Option<String>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub synced_at: Option<String>,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
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()),
|
||||
thumbnail_url: i.thumbnail_url().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 provider: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
@@ -142,12 +144,12 @@ pub struct LibrarySearchParams {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ProviderParam {
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ShowsParams {
|
||||
pub provider: Option<String>,
|
||||
pub search_term: Option<String>,
|
||||
@@ -155,14 +157,19 @@ pub struct ShowsParams {
|
||||
pub genres: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct SeasonsParams {
|
||||
pub series_name: String,
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct GenresParams {
|
||||
pub content_type: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateRoleRequest {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use domain::models::ScheduleConfig;
|
||||
use domain::value_objects::{ChannelId, RotationPolicy, UserId};
|
||||
use domain::value_objects::{ChannelId, MediaFilter, RotationPolicy, UserId};
|
||||
|
||||
pub struct CreateChannelCommand {
|
||||
pub owner_id: UserId,
|
||||
@@ -16,6 +16,7 @@ pub struct UpdateChannelCommand {
|
||||
pub schedule_config: Option<ScheduleConfig>,
|
||||
pub rotation_policy: Option<RotationPolicy>,
|
||||
pub auto_schedule: Option<bool>,
|
||||
pub gap_filler: Option<Option<MediaFilter>>,
|
||||
}
|
||||
|
||||
pub struct DeleteChannelCommand {
|
||||
|
||||
@@ -45,6 +45,7 @@ async fn updates_channel_name() {
|
||||
schedule_config: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
gap_filler: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -82,6 +83,7 @@ async fn update_fails_if_not_owner() {
|
||||
schedule_config: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
gap_filler: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -108,6 +110,7 @@ async fn update_nonexistent_channel_returns_not_found() {
|
||||
schedule_config: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
gap_filler: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -148,6 +151,7 @@ async fn update_config_creates_snapshot() {
|
||||
schedule_config: Some(new_config),
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
gap_filler: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -187,6 +191,7 @@ async fn update_without_config_skips_snapshot() {
|
||||
schedule_config: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
gap_filler: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -225,6 +230,7 @@ async fn update_description_clear() {
|
||||
schedule_config: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
gap_filler: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -243,6 +249,7 @@ async fn update_description_clear() {
|
||||
schedule_config: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
gap_filler: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -35,6 +35,9 @@ pub async fn execute(deps: &ChannelCommandDeps, cmd: UpdateChannelCommand) -> Do
|
||||
if let Some(auto) = cmd.auto_schedule {
|
||||
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?;
|
||||
|
||||
|
||||
@@ -10,5 +10,5 @@ pub mod value_objects;
|
||||
pub use errors::{DomainError, DomainResult};
|
||||
pub use events::DomainEvent;
|
||||
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::*;
|
||||
|
||||
@@ -282,6 +282,13 @@ impl ScheduleConfig {
|
||||
&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>) {
|
||||
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(
|
||||
name: impl Into<String>,
|
||||
start_time: NaiveTime,
|
||||
@@ -426,6 +455,24 @@ impl ProgrammingBlock {
|
||||
pub fn mid_roll_rule(&self) -> Option<&MidRollRule> {
|
||||
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)]
|
||||
|
||||
@@ -226,6 +226,14 @@ impl MediaItem {
|
||||
pub fn chapters(&self) -> &[Chapter] {
|
||||
&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)]
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::models::{
|
||||
LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult, MediaItem,
|
||||
SeasonSummary, ShowSummary,
|
||||
};
|
||||
use crate::value_objects::{ContentType, LibrarySearchFilter};
|
||||
use crate::value_objects::{ContentType, LibrarySearchFilter, MediaRole};
|
||||
|
||||
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_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>;
|
||||
|
||||
async fn update_role(&self, item_id: &str, role: MediaRole) -> DomainResult<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
342
crates/domain/src/services/ical.rs
Normal file
342
crates/domain/src/services/ical.rs
Normal 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("ed)
|
||||
.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;
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod ical;
|
||||
pub mod iptv;
|
||||
pub mod schedule;
|
||||
|
||||
pub use ical::{generate_ical, parse_ical};
|
||||
pub use iptv::{generate_m3u, generate_xmltv};
|
||||
pub use schedule::ScheduleEngineService;
|
||||
|
||||
@@ -4,7 +4,9 @@ use rand::rngs::StdRng;
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::SeedableRng;
|
||||
|
||||
use crate::models::MediaItem;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::models::{MediaItem, PlaybackRecord};
|
||||
use crate::value_objects::{FillStrategy, MediaItemId};
|
||||
|
||||
pub(super) fn fill_block<'a>(
|
||||
@@ -14,6 +16,7 @@ pub(super) fn fill_block<'a>(
|
||||
strategy: &FillStrategy,
|
||||
last_item_id: Option<&MediaItemId>,
|
||||
loop_on_finish: bool,
|
||||
history: &[PlaybackRecord],
|
||||
) -> Vec<&'a MediaItem> {
|
||||
match strategy {
|
||||
FillStrategy::BestFit => fill_best_fit(pool, target_secs),
|
||||
@@ -38,7 +41,7 @@ pub(super) fn fill_block<'a>(
|
||||
fill_alternating(candidates, pool, target_secs)
|
||||
}
|
||||
FillStrategy::Weighted => {
|
||||
fill_weighted(candidates, pool, target_secs)
|
||||
fill_weighted(pool, target_secs, history)
|
||||
}
|
||||
FillStrategy::Marathon => {
|
||||
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>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
target_secs: u32,
|
||||
history: &[PlaybackRecord],
|
||||
) -> Vec<&'a MediaItem> {
|
||||
if pool.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let pool_ids: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
|
||||
let candidate_ids: HashSet<&MediaItemId> = candidates.iter().map(|i| i.id()).collect();
|
||||
|
||||
let mut fresh: Vec<&MediaItem> = pool
|
||||
let last_played: HashMap<&MediaItemId, i64> = history
|
||||
.iter()
|
||||
.filter(|i| !candidate_ids.contains(i.id()) || pool_ids.contains(i.id()))
|
||||
.collect();
|
||||
|
||||
let all_in_pool: Vec<&MediaItem> = pool.iter().collect();
|
||||
.fold(HashMap::new(), |mut acc, r| {
|
||||
let ts = r.played_at().timestamp();
|
||||
acc.entry(r.item_id())
|
||||
.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();
|
||||
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 result = Vec::new();
|
||||
let mut used: HashSet<&MediaItemId> = HashSet::new();
|
||||
|
||||
for item in &fresh {
|
||||
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 {
|
||||
for item in items {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -256,7 +239,6 @@ pub(super) fn fill_weighted<'a>(
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
@@ -311,11 +293,11 @@ pub(super) fn fill_marathon<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
if let Some(&first) = ordered.first() {
|
||||
if result.is_empty()
|
||||
&& let Some(&first) = ordered.first()
|
||||
{
|
||||
result.push(first);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ use crate::models::{
|
||||
ScheduledSlot,
|
||||
};
|
||||
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 rotation;
|
||||
@@ -63,12 +64,83 @@ impl ScheduleEngineService {
|
||||
channel_id: ChannelId,
|
||||
from: DateTime<Utc>,
|
||||
) -> DomainResult<GeneratedSchedule> {
|
||||
let channel = self
|
||||
.channel_query
|
||||
let channel = self.load_channel(channel_id).await?;
|
||||
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)
|
||||
.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
|
||||
.timezone()
|
||||
.parse()
|
||||
@@ -80,7 +152,6 @@ impl ScheduleEngineService {
|
||||
.await?;
|
||||
|
||||
let latest_schedule = self.schedule_query.find_latest(channel_id).await?;
|
||||
|
||||
let generation = latest_schedule
|
||||
.as_ref()
|
||||
.map(|s| s.generation() + 1)
|
||||
@@ -91,10 +162,7 @@ impl ScheduleEngineService {
|
||||
.find_last_slot_per_block(channel_id)
|
||||
.await?;
|
||||
|
||||
let valid_from = from;
|
||||
let valid_until = from + Duration::days(SCHEDULE_DURATION_DAYS);
|
||||
|
||||
let start_date = from.with_timezone(&tz).date_naive();
|
||||
let start_date = valid_from.with_timezone(&tz).date_naive();
|
||||
let end_date = valid_until.with_timezone(&tz).date_naive();
|
||||
|
||||
let mut slots: Vec<ScheduledSlot> = Vec::new();
|
||||
@@ -102,10 +170,9 @@ impl ScheduleEngineService {
|
||||
|
||||
while current_date <= end_date {
|
||||
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());
|
||||
|
||||
// earliest() picks first valid mapping, skipping DST gaps
|
||||
let block_start_utc = match tz.from_local_datetime(&naive_start).earliest() {
|
||||
Some(dt) => dt.with_timezone(&Utc),
|
||||
None => continue,
|
||||
@@ -153,23 +220,13 @@ impl ScheduleEngineService {
|
||||
|
||||
slots.sort_by_key(|s| s.start_at());
|
||||
|
||||
let schedule = GeneratedSchedule::new(
|
||||
Ok(GeneratedSchedule::new(
|
||||
channel_id,
|
||||
valid_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(), generation);
|
||||
self.schedule_command.save_playback_record(&record).await?;
|
||||
}
|
||||
|
||||
Ok(schedule)
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_current_broadcast(
|
||||
@@ -248,10 +305,10 @@ impl ScheduleEngineService {
|
||||
window: BlockTimeWindow,
|
||||
rotation: RotationContext<'_>,
|
||||
) -> DomainResult<Vec<ScheduledSlot>> {
|
||||
match block.content() {
|
||||
let program_slots = match block.content() {
|
||||
BlockContent::Manual { items } => {
|
||||
self.resolve_manual(items, window.start, window.end, block.id())
|
||||
.await
|
||||
.await?
|
||||
}
|
||||
BlockContent::Algorithmic {
|
||||
filter,
|
||||
@@ -265,11 +322,217 @@ impl ScheduleEngineService {
|
||||
loop_on_finish: block.loop_on_finish(),
|
||||
ignore_rotation_policy: block.ignore_rotation_policy(),
|
||||
},
|
||||
window,
|
||||
BlockTimeWindow {
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
},
|
||||
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,
|
||||
rotation: RotationContext<'_>,
|
||||
) -> 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?;
|
||||
|
||||
if candidates.is_empty() {
|
||||
@@ -324,6 +588,7 @@ impl ScheduleEngineService {
|
||||
params.strategy,
|
||||
rotation.last_item_id,
|
||||
params.loop_on_finish,
|
||||
rotation.history,
|
||||
);
|
||||
|
||||
let mut slots = Vec::new();
|
||||
@@ -341,6 +606,71 @@ impl ScheduleEngineService {
|
||||
|
||||
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 {
|
||||
@@ -362,11 +692,9 @@ fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) ->
|
||||
if let Some(max) = filter.max_duration_secs {
|
||||
lsf = lsf.with_max_duration_secs(max);
|
||||
}
|
||||
if !filter.collections.is_empty() {
|
||||
if let Some(first) = filter.collections.first() {
|
||||
lsf = lsf.with_collection_id(first.clone());
|
||||
}
|
||||
}
|
||||
if !filter.series_names.is_empty() {
|
||||
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());
|
||||
}
|
||||
if !filter.tags.is_empty() {
|
||||
// tags map to the same concept in the library
|
||||
lsf = lsf.with_tags(filter.tags.clone());
|
||||
}
|
||||
lsf
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "test-helpers"))]
|
||||
#[path = "tests/integration.rs"]
|
||||
mod integration_tests;
|
||||
|
||||
@@ -64,7 +64,7 @@ fn sequential_includes_oversize_first_episode() {
|
||||
fn random_fill_respects_budget() {
|
||||
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
|
||||
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();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ fn alternating_empty_pool() {
|
||||
fn weighted_respects_budget() {
|
||||
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
|
||||
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();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
@@ -127,7 +127,7 @@ fn weighted_respects_budget() {
|
||||
fn weighted_no_duplicates() {
|
||||
let pool = vec![item("a", 50), item("b", 50), item("c", 50)];
|
||||
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 unique: HashSet<&str> = ids.iter().copied().collect();
|
||||
assert_eq!(ids.len(), unique.len());
|
||||
@@ -137,7 +137,7 @@ fn weighted_no_duplicates() {
|
||||
fn weighted_empty_pool() {
|
||||
let candidates: 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());
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ fn marathon_oversize_single_item() {
|
||||
fn fill_block_dispatches_alternating() {
|
||||
let candidates = vec![item("a", 100), item("b", 100)];
|
||||
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();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
@@ -201,7 +201,7 @@ fn fill_block_dispatches_alternating() {
|
||||
fn fill_block_dispatches_weighted() {
|
||||
let candidates = vec![item("a", 100), item("b", 100)];
|
||||
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();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
@@ -210,6 +210,6 @@ fn fill_block_dispatches_weighted() {
|
||||
fn fill_block_dispatches_marathon() {
|
||||
let candidates = vec![item("a", 100), item("b", 100)];
|
||||
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");
|
||||
}
|
||||
|
||||
716
crates/domain/src/services/schedule/tests/integration.rs
Normal file
716
crates/domain/src/services/schedule/tests/integration.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
336
crates/domain/src/services/tests/ical.rs
Normal file
336
crates/domain/src/services/tests/ical.rs
Normal 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);
|
||||
}
|
||||
@@ -418,6 +418,18 @@ impl LibraryCommand for InMemoryLibraryRepository {
|
||||
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<()> {
|
||||
let mut logs = self.sync_logs.lock().unwrap();
|
||||
if let Some(entry) = logs.iter_mut().find(|e| e.id() == log_id) {
|
||||
@@ -467,10 +479,21 @@ impl LibraryQuery for InMemoryLibraryRepository {
|
||||
{
|
||||
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
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
items.sort_by(|a, b| a.id().value().cmp(b.id().value()));
|
||||
let total = items.len() as u32;
|
||||
let offset = filter.offset() as usize;
|
||||
let limit = filter.limit() as usize;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::value_objects::ContentType;
|
||||
use crate::value_objects::{ContentType, MediaRole};
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT: u32 = 50;
|
||||
|
||||
@@ -13,7 +13,9 @@ pub struct LibrarySearchFilter {
|
||||
min_duration_secs: Option<u32>,
|
||||
max_duration_secs: Option<u32>,
|
||||
search_term: Option<String>,
|
||||
tags: Vec<String>,
|
||||
season_number: Option<u32>,
|
||||
role: Option<MediaRole>,
|
||||
offset: u32,
|
||||
limit: u32,
|
||||
}
|
||||
@@ -59,10 +61,18 @@ impl LibrarySearchFilter {
|
||||
self.search_term = Some(term.into());
|
||||
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 {
|
||||
self.season_number = Some(n);
|
||||
self
|
||||
}
|
||||
pub fn with_role(mut self, role: MediaRole) -> Self {
|
||||
self.role = Some(role);
|
||||
self
|
||||
}
|
||||
pub fn with_offset(mut self, offset: u32) -> Self {
|
||||
self.offset = offset;
|
||||
self
|
||||
@@ -99,9 +109,15 @@ impl LibrarySearchFilter {
|
||||
pub fn search_term(&self) -> Option<&str> {
|
||||
self.search_term.as_deref()
|
||||
}
|
||||
pub fn tags(&self) -> &[String] {
|
||||
&self.tags
|
||||
}
|
||||
pub fn season_number(&self) -> Option<u32> {
|
||||
self.season_number
|
||||
}
|
||||
pub fn role(&self) -> Option<&MediaRole> {
|
||||
self.role.as_ref()
|
||||
}
|
||||
pub fn offset(&self) -> u32 {
|
||||
self.offset
|
||||
}
|
||||
@@ -122,7 +138,9 @@ impl Default for LibrarySearchFilter {
|
||||
min_duration_secs: None,
|
||||
max_duration_secs: None,
|
||||
search_term: None,
|
||||
tags: vec![],
|
||||
season_number: None,
|
||||
role: None,
|
||||
offset: 0,
|
||||
limit: DEFAULT_SEARCH_LIMIT,
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::tools::{channels, library, schedule};
|
||||
use crate::tools::{channels, ical, library, schedule};
|
||||
|
||||
const SERVER_NAME: &str = "k-tv-mcp";
|
||||
|
||||
@@ -47,6 +47,7 @@ pub struct UpdateChannelParams {
|
||||
pub timezone: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub schedule_config_json: Option<String>,
|
||||
pub gap_filler_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -73,6 +74,89 @@ pub struct ListGenresParams {
|
||||
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> {
|
||||
s.parse::<Uuid>()
|
||||
.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
|
||||
}
|
||||
|
||||
#[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 {
|
||||
let id = match parse_uuid(&p.id) {
|
||||
Ok(id) => id,
|
||||
@@ -114,14 +198,28 @@ impl KTvMcpServer {
|
||||
},
|
||||
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(
|
||||
&self.channel_cmd_deps,
|
||||
id,
|
||||
self.owner_id,
|
||||
p.name,
|
||||
p.timezone,
|
||||
p.description,
|
||||
channels::UpdateChannelArgs {
|
||||
channel_id: id,
|
||||
owner_id: self.owner_id,
|
||||
name: p.name,
|
||||
timezone: p.timezone,
|
||||
description: p.description,
|
||||
schedule_config,
|
||||
gap_filler,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -186,6 +284,174 @@ impl KTvMcpServer {
|
||||
)
|
||||
.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)]
|
||||
@@ -199,7 +465,8 @@ impl ServerHandler for KTvMcpServer {
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
},
|
||||
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."
|
||||
.into(),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
cmd_deps: &Arc<ChannelCommandDeps>,
|
||||
channel_id: Uuid,
|
||||
owner_id: Uuid,
|
||||
name: Option<String>,
|
||||
timezone: Option<String>,
|
||||
description: Option<String>,
|
||||
schedule_config: Option<domain::ScheduleConfig>,
|
||||
args: UpdateChannelArgs,
|
||||
) -> String {
|
||||
let cmd = UpdateChannelCommand {
|
||||
channel_id: channel_id.into(),
|
||||
owner_id: owner_id.into(),
|
||||
name,
|
||||
description: description.map(Some),
|
||||
timezone,
|
||||
schedule_config,
|
||||
channel_id: args.channel_id.into(),
|
||||
owner_id: args.owner_id.into(),
|
||||
name: args.name,
|
||||
description: args.description.map(Some),
|
||||
timezone: args.timezone,
|
||||
schedule_config: args.schedule_config,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
gap_filler: args.gap_filler,
|
||||
};
|
||||
match application::channels::update::execute(cmd_deps, cmd).await {
|
||||
Ok(channel) => ok_json(&channel),
|
||||
|
||||
48
crates/mcp/src/tools/ical.rs
Normal file
48
crates/mcp/src/tools/ical.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::library::SearchItemsQuery;
|
||||
@@ -6,6 +7,7 @@ use serde::Serialize;
|
||||
use crate::error::{domain_err, ok_json};
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT: u32 = 50;
|
||||
const STATS_SEARCH_LIMIT: u32 = 10_000;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CollectionDto {
|
||||
@@ -30,6 +32,7 @@ struct LibraryItemDto {
|
||||
tags: Vec<String>,
|
||||
collection_id: Option<String>,
|
||||
thumbnail_url: Option<String>,
|
||||
role: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -38,11 +41,78 @@ struct SearchResult {
|
||||
total: u32,
|
||||
}
|
||||
|
||||
fn content_type_to_str(ct: &domain::ContentType) -> &'static str {
|
||||
match ct {
|
||||
domain::ContentType::Movie => "movie",
|
||||
domain::ContentType::Episode => "episode",
|
||||
domain::ContentType::Short => "short",
|
||||
#[derive(Serialize)]
|
||||
struct BrowseResult {
|
||||
items: Vec<BrowseItemDto>,
|
||||
total: u32,
|
||||
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 {
|
||||
Ok((items, total)) => {
|
||||
let dtos: Vec<LibraryItemDto> = items
|
||||
.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();
|
||||
let dtos: Vec<LibraryItemDto> = items.iter().map(item_to_dto).collect();
|
||||
ok_json(&SearchResult { items: dtos, total })
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
pub mod channels;
|
||||
pub mod ical;
|
||||
pub mod library;
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::schedule::{
|
||||
GenerateScheduleCommand, GetCurrentBroadcastQuery, ScheduleDeps,
|
||||
};
|
||||
use application::channels::ChannelCommandDeps;
|
||||
use application::schedule::{GenerateScheduleCommand, GetCurrentBroadcastQuery, ScheduleDeps};
|
||||
use chrono::Utc;
|
||||
use domain::ScheduledSlot;
|
||||
use domain::value_objects::ChannelId;
|
||||
use domain::value_objects::{ChannelId, MediaFilter};
|
||||
use domain::{InterstitialRule, MidRollRule, ScheduleConfig, ScheduledSlot};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -51,3 +51,522 @@ pub async fn get_current_broadcast(deps: &Arc<ScheduleDeps>, channel_id: Uuid) -
|
||||
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
33
crates/playout/Cargo.toml
Normal 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"] }
|
||||
50
crates/playout/src/config.rs
Normal file
50
crates/playout/src/config.rs
Normal 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
|
||||
}
|
||||
}
|
||||
263
crates/playout/src/engine.rs
Normal file
263
crates/playout/src/engine.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
161
crates/playout/src/ffmpeg.rs
Normal file
161
crates/playout/src/ffmpeg.rs
Normal 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
120
crates/playout/src/http.rs
Normal 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()
|
||||
}
|
||||
8
crates/playout/src/lib.rs
Normal file
8
crates/playout/src/lib.rs
Normal 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
132
crates/playout/src/main.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
47
crates/playout/src/metadata.rs
Normal file
47
crates/playout/src/metadata.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
67
crates/playout/src/playlist.rs
Normal file
67
crates/playout/src/playlist.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
230
crates/playout/src/scte35.rs
Normal file
230
crates/playout/src/scte35.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
78
crates/playout/src/segment_store/filesystem.rs
Normal file
78
crates/playout/src/segment_store/filesystem.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
125
crates/playout/src/segment_store/memory.rs
Normal file
125
crates/playout/src/segment_store/memory.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
28
crates/playout/src/segment_store/mod.rs
Normal file
28
crates/playout/src/segment_store/mod.rs
Normal 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<()>;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { workspace = true }
|
||||
infra-wiring = { workspace = true }
|
||||
adapter-common = { workspace = true }
|
||||
adapter-auth = { workspace = true }
|
||||
adapter-event-publisher = { workspace = true }
|
||||
|
||||
@@ -27,6 +28,10 @@ adapter-sqlite = { workspace = true, optional = true }
|
||||
adapter-jellyfin = { workspace = true, optional = true }
|
||||
adapter-local-files = { workspace = true, optional = true }
|
||||
|
||||
# OpenAPI
|
||||
utoipa = { workspace = true }
|
||||
utoipa-scalar = { workspace = true }
|
||||
|
||||
# Framework
|
||||
axum = { workspace = true }
|
||||
axum-extra = { workspace = true, features = ["typed-header"] }
|
||||
|
||||
@@ -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 now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let role = adapter_common::role_detector::detect_role(&item, role_config);
|
||||
|
||||
domain::MediaItem::from_persistence(domain::MediaItemRow {
|
||||
id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)),
|
||||
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(),
|
||||
tags: item.tags().to_vec(),
|
||||
collection_id: item.collection_id().map(|s| s.to_string()),
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
collection_name: item.collection_name().map(|s| s.to_string()),
|
||||
collection_type: item.collection_type().map(|s| s.to_string()),
|
||||
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
|
||||
synced_at: Some(now),
|
||||
role: domain::MediaRole::default(),
|
||||
role,
|
||||
chapters: item.chapters().to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
struct SimpleSyncAdapter {
|
||||
library_command: Arc<dyn domain::ports::LibraryCommand>,
|
||||
role_config: adapter_common::role_detector::RoleDetectionConfig,
|
||||
}
|
||||
|
||||
impl SimpleSyncAdapter {
|
||||
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;
|
||||
}
|
||||
|
||||
let library_items: Vec<domain::MediaItem> = items
|
||||
let mut library_items: Vec<domain::MediaItem> = items
|
||||
.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();
|
||||
|
||||
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
|
||||
.library_command
|
||||
.upsert_items(provider_id, library_items)
|
||||
|
||||
@@ -11,6 +11,17 @@ use crate::state::AppState;
|
||||
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
@@ -20,6 +31,18 @@ pub async fn get_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(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
@@ -34,6 +57,18 @@ pub async fn update_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(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
|
||||
@@ -10,6 +10,16 @@ use crate::state::AppState;
|
||||
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
@@ -22,6 +32,16 @@ pub async fn register(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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> {
|
||||
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> {
|
||||
Ok(Json(UserResponse::from(user)))
|
||||
}
|
||||
|
||||
#[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(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
|
||||
@@ -13,6 +13,16 @@ use crate::errors::AppError;
|
||||
use crate::extractors::CurrentUser;
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -21,6 +31,16 @@ pub async fn list_channels(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
@@ -29,6 +49,18 @@ pub async fn list_my_channels(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
@@ -43,6 +75,20 @@ pub async fn create_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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -56,6 +102,22 @@ pub async fn get_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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
@@ -71,11 +133,27 @@ pub async fn update_channel(
|
||||
schedule_config: req.schedule_config.map(Into::into),
|
||||
rotation_policy: req.rotation_policy,
|
||||
auto_schedule: req.auto_schedule,
|
||||
gap_filler: req.gap_filler,
|
||||
};
|
||||
let channel = application::channels::update::execute(&state.channel_command_deps, cmd).await?;
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
@@ -89,6 +167,20 @@ pub async fn delete_channel(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -102,6 +194,20 @@ pub async fn save_snapshot(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -111,6 +217,21 @@ pub async fn list_snapshots(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -124,6 +245,22 @@ pub async fn get_snapshot(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -139,6 +276,21 @@ pub async fn patch_snapshot(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
|
||||
@@ -7,6 +7,14 @@ use application::config::GetConfigQuery;
|
||||
use crate::errors::AppError;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/config",
|
||||
tag = "config",
|
||||
responses(
|
||||
(status = 200, body = ConfigResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn get_config(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<ConfigResponse>, AppError> {
|
||||
|
||||
@@ -12,6 +12,15 @@ use crate::state::AppState;
|
||||
const M3U_CONTENT_TYPE: &str = "audio/x-mpegurl; 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(
|
||||
State(state): State<AppState>,
|
||||
OptionalCurrentUser(_user): OptionalCurrentUser,
|
||||
@@ -25,6 +34,14 @@ pub async fn m3u_playlist(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
OptionalCurrentUser(_user): OptionalCurrentUser,
|
||||
|
||||
@@ -4,9 +4,10 @@ use axum::extract::{Path, Query, State};
|
||||
use api_types::{
|
||||
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, PaginatedResponse,
|
||||
ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
|
||||
UpdateRoleRequest,
|
||||
};
|
||||
use application::library::{SearchItemsQuery, TriggerSyncCommand};
|
||||
use domain::DomainError;
|
||||
use domain::{DomainError, MediaRole};
|
||||
|
||||
use crate::errors::AppError;
|
||||
use crate::extractors::{AdminUser, CurrentUser};
|
||||
@@ -14,6 +15,17 @@ use crate::state::AppState;
|
||||
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -51,6 +77,17 @@ pub async fn get_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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -84,6 +132,17 @@ pub async fn list_shows(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -115,6 +185,16 @@ pub async fn list_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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -123,6 +203,17 @@ pub async fn sync_status(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
@@ -131,3 +222,32 @@ pub async fn trigger_sync(
|
||||
application::library::sync::execute(&state.library_command_deps, cmd).await?;
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,17 @@ use crate::errors::AppError;
|
||||
use crate::extractors::AdminUser;
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
@@ -35,6 +61,21 @@ pub async fn get_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(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
@@ -51,6 +92,21 @@ pub async fn upsert_provider(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
|
||||
@@ -16,6 +16,20 @@ use crate::errors::AppError;
|
||||
use crate::extractors::CurrentUser;
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -26,6 +40,20 @@ pub async fn generate_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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
@@ -66,6 +119,19 @@ pub async fn get_epg(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
@@ -91,3 +170,87 @@ pub async fn list_schedule_history(
|
||||
.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)))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ mod extractors;
|
||||
mod factory;
|
||||
mod handlers;
|
||||
mod mappers;
|
||||
mod openapi;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
@@ -52,6 +53,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let app = axum::Router::new()
|
||||
.nest("/api/v1", routes::api_v1_router())
|
||||
.nest("/api", routes::docs_router())
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(app_state);
|
||||
|
||||
126
crates/presentation/src/openapi.rs
Normal file
126
crates/presentation/src/openapi.rs
Normal 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;
|
||||
@@ -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::openapi::ApiDoc;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn api_v1_router() -> Router<AppState> {
|
||||
@@ -15,6 +18,12 @@ pub fn api_v1_router() -> Router<AppState> {
|
||||
.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> {
|
||||
let r = Router::new()
|
||||
.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}", axum::routing::patch(handlers::channels::patch_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> {
|
||||
@@ -80,6 +91,7 @@ fn library_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/items", get(handlers::library::search_items))
|
||||
.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("/shows", get(handlers::library::list_shows))
|
||||
.route("/seasons", get(handlers::library::list_seasons))
|
||||
|
||||
@@ -17,6 +17,7 @@ local-files = ["dep:adapter-local-files"]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
infra-wiring = { workspace = true }
|
||||
adapter-common = { workspace = true }
|
||||
adapter-sqlite = { workspace = true, optional = true }
|
||||
adapter-auth = { workspace = true }
|
||||
adapter-jellyfin = { workspace = true, optional = true }
|
||||
|
||||
@@ -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 now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let role = adapter_common::role_detector::detect_role(&item, role_config);
|
||||
|
||||
domain::MediaItem::from_persistence(domain::MediaItemRow {
|
||||
id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)),
|
||||
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(),
|
||||
tags: item.tags().to_vec(),
|
||||
collection_id: item.collection_id().map(|s| s.to_string()),
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
collection_name: item.collection_name().map(|s| s.to_string()),
|
||||
collection_type: item.collection_type().map(|s| s.to_string()),
|
||||
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
|
||||
synced_at: Some(now),
|
||||
role: domain::MediaRole::default(),
|
||||
role,
|
||||
chapters: item.chapters().to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
struct SimpleSyncAdapter {
|
||||
library_command: Arc<dyn domain::ports::LibraryCommand>,
|
||||
role_config: adapter_common::role_detector::RoleDetectionConfig,
|
||||
}
|
||||
|
||||
impl SimpleSyncAdapter {
|
||||
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_id: &str,
|
||||
) -> domain::LibrarySyncResult {
|
||||
use adapter_common::ffprobe;
|
||||
use std::time::Instant;
|
||||
|
||||
let start = Instant::now();
|
||||
@@ -426,11 +437,20 @@ impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter {
|
||||
return result;
|
||||
}
|
||||
|
||||
let library_items: Vec<domain::MediaItem> = items
|
||||
let mut library_items: Vec<domain::MediaItem> = items
|
||||
.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();
|
||||
|
||||
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
|
||||
.library_command
|
||||
.upsert_items(provider_id, library_items)
|
||||
|
||||
69
docs/superpowers/plans/2026-07-12-parallel-execution-plan.md
Normal file
69
docs/superpowers/plans/2026-07-12-parallel-execution-plan.md
Normal 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.
|
||||
@@ -1,31 +1,30 @@
|
||||
FROM rust:1.92 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release -p presentation -p worker -p playout
|
||||
|
||||
# Build the release binary
|
||||
RUN cargo build --release -p api
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Presentation image
|
||||
FROM debian:bookworm-slim AS presentation
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL, CA certs, and ffmpeg (provides ffprobe for local-files duration scanning)
|
||||
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 apt-get update && apt-get install -y --no-install-recommends libssl3 ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/k-tv .
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
ENV DATABASE_URL=sqlite:///app/data/template.db
|
||||
ENV SESSION_SECRET=supersecretchangeinproduction
|
||||
|
||||
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"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE library_items ADD COLUMN role TEXT NOT NULL DEFAULT 'program';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE channels RENAME COLUMN recycle_policy TO rotation_policy;
|
||||
@@ -12,12 +12,8 @@ interface AccessSettingsEditorProps {
|
||||
|
||||
export function AccessSettingsEditor({
|
||||
accessMode,
|
||||
accessPassword,
|
||||
onAccessModeChange,
|
||||
onAccessPasswordChange,
|
||||
label = "Access",
|
||||
passwordLabel = "Password",
|
||||
passwordHint = "Leave blank to keep existing password",
|
||||
}: AccessSettingsEditorProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -25,33 +21,13 @@ export function AccessSettingsEditor({
|
||||
<label className="block text-xs font-medium text-zinc-400">{label}</label>
|
||||
<select
|
||||
value={accessMode}
|
||||
onChange={(e) => {
|
||||
onAccessModeChange(e.target.value as AccessMode);
|
||||
onAccessPasswordChange("");
|
||||
}}
|
||||
onChange={(e) => onAccessModeChange(e.target.value as AccessMode)}
|
||||
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="password_protected">Password protected</option>
|
||||
<option value="account_required">Account required</option>
|
||||
<option value="owner_only">Owner only</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,9 @@ export function AlgorithmicFilterEditor({
|
||||
<option value="random">Random</option>
|
||||
<option value="best_fit">Best fit</option>
|
||||
<option value="sequential">Sequential</option>
|
||||
<option value="alternating">Alternating</option>
|
||||
<option value="weighted">Weighted</option>
|
||||
<option value="marathon">Marathon</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,6 @@ interface CreateChannelDialogProps {
|
||||
timezone: string;
|
||||
description: string;
|
||||
access_mode?: AccessMode;
|
||||
access_password?: string;
|
||||
}) => void;
|
||||
isPending: boolean;
|
||||
error?: string | null;
|
||||
@@ -36,7 +35,6 @@ export function CreateChannelDialog({
|
||||
const [timezone, setTimezone] = useState("UTC");
|
||||
const [description, setDescription] = useState("");
|
||||
const [accessMode, setAccessMode] = useState<AccessMode>("public");
|
||||
const [accessPassword, setAccessPassword] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -45,7 +43,6 @@ export function CreateChannelDialog({
|
||||
timezone,
|
||||
description,
|
||||
access_mode: accessMode !== "public" ? accessMode : undefined,
|
||||
access_password: accessMode === "password_protected" && accessPassword ? accessPassword : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -57,7 +54,6 @@ export function CreateChannelDialog({
|
||||
setTimezone("UTC");
|
||||
setDescription("");
|
||||
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"
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="password_protected">Password protected</option>
|
||||
<option value="account_required">Account required</option>
|
||||
<option value="owner_only">Owner only</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</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>}
|
||||
|
||||
<DialogFooter>
|
||||
@@ -151,7 +132,7 @@ export function CreateChannelDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Creating…" : "Create channel"}
|
||||
{isPending ? "Creating..." : "Create channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BlockTimeline, BLOCK_COLORS } from "./block-timeline";
|
||||
import { AlgorithmicFilterEditor } from "./algorithmic-filter-editor";
|
||||
import { RecyclePolicyEditor } from "./recycle-policy-editor";
|
||||
import { RotationPolicyEditor } from "./rotation-policy-editor";
|
||||
import { WebhookEditor } from "./webhook-editor";
|
||||
import { AccessSettingsEditor } from "./access-settings-editor";
|
||||
import { LogoEditor } from "./logo-editor";
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
FillStrategy,
|
||||
MediaFilter,
|
||||
ProviderInfo,
|
||||
RecyclePolicy,
|
||||
RotationPolicy,
|
||||
Weekday,
|
||||
} 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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.ignore_recycle_policy ?? false}
|
||||
checked={block.ignore_rotation_policy ?? false}
|
||||
onChange={(e) =>
|
||||
onChange({ ...block, ignore_recycle_policy: e.target.checked })
|
||||
onChange({ ...block, ignore_rotation_policy: e.target.checked })
|
||||
}
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -339,12 +339,11 @@ interface EditChannelSheetProps {
|
||||
description: string;
|
||||
timezone: string;
|
||||
schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> };
|
||||
recycle_policy: RecyclePolicy;
|
||||
rotation_policy: RotationPolicy;
|
||||
auto_schedule: boolean;
|
||||
access_mode?: AccessMode;
|
||||
access_password?: string;
|
||||
access_mode?: string;
|
||||
logo?: string | null;
|
||||
logo_position?: LogoPosition;
|
||||
logo_position?: string;
|
||||
logo_opacity?: number;
|
||||
webhook_url?: string | null;
|
||||
webhook_poll_interval_secs?: number;
|
||||
@@ -401,7 +400,7 @@ export function EditChannelSheet({
|
||||
description: form.description,
|
||||
timezone: form.timezone,
|
||||
day_blocks: form.dayBlocks,
|
||||
recycle_policy: form.recyclePolicy,
|
||||
rotation_policy: form.rotationPolicy,
|
||||
auto_schedule: form.autoSchedule,
|
||||
access_mode: form.accessMode,
|
||||
access_password: form.accessPassword,
|
||||
@@ -418,10 +417,9 @@ export function EditChannelSheet({
|
||||
description: form.description,
|
||||
timezone: form.timezone,
|
||||
schedule_config: { day_blocks: form.dayBlocks },
|
||||
recycle_policy: form.recyclePolicy,
|
||||
rotation_policy: form.rotationPolicy,
|
||||
auto_schedule: form.autoSchedule,
|
||||
access_mode: form.accessMode !== "public" ? form.accessMode : "public",
|
||||
access_password: form.accessPassword || "",
|
||||
access_mode: form.accessMode,
|
||||
logo: form.logo,
|
||||
logo_position: form.logoPosition,
|
||||
logo_opacity: form.logoOpacity / 100,
|
||||
@@ -540,12 +538,12 @@ export function EditChannelSheet({
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Recycle policy
|
||||
Rotation policy
|
||||
</h3>
|
||||
<RecyclePolicyEditor
|
||||
policy={form.recyclePolicy}
|
||||
<RotationPolicyEditor
|
||||
policy={form.rotationPolicy}
|
||||
errors={fieldErrors}
|
||||
onChange={form.setRecyclePolicy}
|
||||
onChange={form.setRotationPolicy}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
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
|
||||
@@ -37,7 +37,7 @@ const importBlockSchema = z.object({
|
||||
max_duration_secs: z.number().nullable().optional(),
|
||||
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({
|
||||
type: z.literal("manual"),
|
||||
@@ -46,7 +46,7 @@ const importBlockSchema = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
const recyclePolicySchema = z
|
||||
const rotationPolicySchema = z
|
||||
.object({
|
||||
cooldown_days: 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"),
|
||||
blocks: z.array(importBlockSchema).optional(),
|
||||
schedule_config: z.object({ blocks: z.array(importBlockSchema).optional() }).optional(),
|
||||
recycle_policy: recyclePolicySchema,
|
||||
rotation_policy: rotationPolicySchema,
|
||||
})
|
||||
.transform((d) => ({
|
||||
name: d.name,
|
||||
@@ -72,7 +72,7 @@ const importSchema = z
|
||||
...b,
|
||||
id: b.id ?? crypto.randomUUID(),
|
||||
})) as ProgrammingBlock[],
|
||||
recycle_policy: d.recycle_policy as RecyclePolicy,
|
||||
rotation_policy: d.rotation_policy as RotationPolicy,
|
||||
}));
|
||||
|
||||
export type ChannelImportData = z.output<typeof importSchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RecyclePolicy } from "@/lib/types";
|
||||
import type { RotationPolicy } from "@/lib/types";
|
||||
import type { FieldErrors } from "@/lib/schemas";
|
||||
|
||||
function NumberInput({
|
||||
@@ -58,17 +58,17 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
interface RecyclePolicyEditorProps {
|
||||
policy: RecyclePolicy;
|
||||
interface RotationPolicyEditorProps {
|
||||
policy: RotationPolicy;
|
||||
errors: FieldErrors;
|
||||
onChange: (policy: RecyclePolicy) => void;
|
||||
onChange: (policy: RotationPolicy) => void;
|
||||
}
|
||||
|
||||
export function RecyclePolicyEditor({
|
||||
export function RotationPolicyEditor({
|
||||
policy,
|
||||
errors,
|
||||
onChange,
|
||||
}: RecyclePolicyEditorProps) {
|
||||
}: RotationPolicyEditorProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -96,7 +96,7 @@ export function RecyclePolicyEditor({
|
||||
<Field
|
||||
label="Min available ratio"
|
||||
hint="0.0–1.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
|
||||
value={policy.min_available_ratio}
|
||||
@@ -107,7 +107,7 @@ export function RecyclePolicyEditor({
|
||||
max={1}
|
||||
step={0.01}
|
||||
placeholder="0.1"
|
||||
error={!!errors["recycle_policy.min_available_ratio"]}
|
||||
error={!!errors["rotation_policy.min_available_ratio"]}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
@@ -6,12 +6,12 @@ import { useActiveSchedule } from "@/hooks/use-channels";
|
||||
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
|
||||
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> {
|
||||
const seen = new Map<string, string>();
|
||||
slots.forEach((slot) => {
|
||||
if (!seen.has(slot.block_id)) {
|
||||
seen.set(slot.block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]);
|
||||
if (!seen.has(slot.source_block_id)) {
|
||||
seen.set(slot.source_block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]);
|
||||
}
|
||||
});
|
||||
return seen;
|
||||
@@ -66,7 +66,7 @@ function DayRow({ label, dayStart, slots, colorMap, now }: DayRowProps) {
|
||||
const clampedEnd = Math.min(slotEnd.getTime(), dayEnd.getTime());
|
||||
const leftPct = ((clampedStart - dayStart.getTime()) / 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 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>
|
||||
<div className="rounded-md border border-zinc-800 divide-y divide-zinc-800">
|
||||
{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, {
|
||||
weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false,
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ import { ScheduleHistoryDialog } from "./components/schedule-history-dialog";
|
||||
import type {
|
||||
ChannelResponse,
|
||||
ProgrammingBlock,
|
||||
RecyclePolicy,
|
||||
RotationPolicy,
|
||||
Weekday,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -67,7 +67,6 @@ export default function DashboardPage() {
|
||||
timezone: string;
|
||||
description: string;
|
||||
access_mode?: import("@/lib/types").AccessMode;
|
||||
access_password?: string;
|
||||
}) => {
|
||||
createChannel.mutate(
|
||||
{
|
||||
@@ -75,7 +74,6 @@ export default function DashboardPage() {
|
||||
timezone: data.timezone,
|
||||
description: data.description || undefined,
|
||||
access_mode: data.access_mode,
|
||||
access_password: data.access_password,
|
||||
},
|
||||
{ onSuccess: () => setCreateOpen(false) },
|
||||
);
|
||||
@@ -88,12 +86,11 @@ export default function DashboardPage() {
|
||||
description: string;
|
||||
timezone: string;
|
||||
schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> };
|
||||
recycle_policy: RecyclePolicy;
|
||||
rotation_policy: RotationPolicy;
|
||||
auto_schedule: boolean;
|
||||
access_mode?: import("@/lib/types").AccessMode;
|
||||
access_password?: string;
|
||||
access_mode?: string;
|
||||
logo?: string | null;
|
||||
logo_position?: import("@/lib/types").LogoPosition;
|
||||
logo_position?: string;
|
||||
logo_opacity?: number;
|
||||
webhook_url?: string | null;
|
||||
webhook_poll_interval_secs?: number;
|
||||
|
||||
@@ -128,7 +128,7 @@ const TOC = [
|
||||
{ id: "blocks", label: "Programming blocks" },
|
||||
{ id: "filters", label: "Filters reference" },
|
||||
{ id: "strategies", label: "Fill strategies" },
|
||||
{ id: "recycle-policy", label: "Recycle policy" },
|
||||
{ id: "rotation-policy", label: "Rotation policy" },
|
||||
{ id: "import-export", label: "Import & export" },
|
||||
{ id: "iptv", label: "IPTV export" },
|
||||
{ id: "access-control", label: "Access control" },
|
||||
@@ -356,6 +356,11 @@ npm run dev`}</Pre>
|
||||
"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.",
|
||||
],
|
||||
[
|
||||
<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 id="recycle-policy">
|
||||
<H2>Recycle policy</H2>
|
||||
<Section id="rotation-policy">
|
||||
<H2>Rotation policy</H2>
|
||||
<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
|
||||
the same content every day.
|
||||
</P>
|
||||
@@ -807,7 +812,7 @@ Authorization: Bearer <token>
|
||||
<P>
|
||||
Click the download icon on any channel card in the Dashboard. A{" "}
|
||||
<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>
|
||||
|
||||
<H3>Importing</H3>
|
||||
@@ -848,7 +853,7 @@ Authorization: Bearer <token>
|
||||
}
|
||||
}
|
||||
],
|
||||
"recycle_policy": {
|
||||
"rotation_policy": {
|
||||
"cooldown_days": 7,
|
||||
"cooldown_generations": null,
|
||||
"min_available_ratio": 0.15
|
||||
@@ -886,11 +891,11 @@ Output only valid JSON matching this structure:
|
||||
"max_duration_secs": number | null,
|
||||
"collections": []
|
||||
},
|
||||
"strategy": "random" | "sequential" | "best_fit"
|
||||
"strategy": "random" | "sequential" | "best_fit" | "alternating" | "weighted" | "marathon"
|
||||
}
|
||||
}
|
||||
],
|
||||
"recycle_policy": {
|
||||
"rotation_policy": {
|
||||
"cooldown_days": number | null,
|
||||
"cooldown_generations": number | null,
|
||||
"min_available_ratio": number
|
||||
@@ -963,34 +968,11 @@ Output only valid JSON matching this structure:
|
||||
"Anyone can watch. This is the default.",
|
||||
],
|
||||
[
|
||||
<Code key="pp">password_protected</Code>,
|
||||
"Viewers must enter a password before the stream plays.",
|
||||
],
|
||||
[
|
||||
<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.",
|
||||
<Code key="priv">private</Code>,
|
||||
"Only authenticated users 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>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
@@ -1210,7 +1192,7 @@ Output only valid JSON matching this structure:
|
||||
Clearing <Code>collections</Code> to search all libraries.
|
||||
</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.
|
||||
</Li>
|
||||
</Ul>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { LogoPosition } from "@/lib/types";
|
||||
|
||||
function logoPositionClass(pos?: LogoPosition) {
|
||||
function logoPositionClass(pos?: string) {
|
||||
switch (pos) {
|
||||
case "top_left":
|
||||
return "top-0 left-0";
|
||||
@@ -15,7 +13,7 @@ function logoPositionClass(pos?: LogoPosition) {
|
||||
|
||||
interface LogoWatermarkProps {
|
||||
logo: string;
|
||||
position?: LogoPosition;
|
||||
position?: string;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -89,8 +89,6 @@ function TvPageContent() {
|
||||
error: broadcastError,
|
||||
} = useCurrentBroadcast(channel?.id ?? "", passwords.channelPassword);
|
||||
|
||||
const blockPassword = passwords.getBlockPassword(broadcast?.slot.id);
|
||||
|
||||
const { data: epgSlots } = useEpg(
|
||||
channel?.id ?? "",
|
||||
undefined,
|
||||
@@ -102,14 +100,7 @@ function TvPageContent() {
|
||||
const volume = useVolume(videoRef, isCasting);
|
||||
const subtitles = useSubtitlePicker(channelIdx, broadcast?.slot.id);
|
||||
|
||||
const { data: streamUrl, error: streamUrlError } = useStreamUrl(
|
||||
channel?.id,
|
||||
token,
|
||||
broadcast?.slot.id,
|
||||
passwords.channelPassword,
|
||||
blockPassword,
|
||||
quality.quality,
|
||||
);
|
||||
const { data: streamUrl, error: streamUrlError } = useStreamUrl(channel?.id);
|
||||
|
||||
const channelCount = channels?.length ?? 0;
|
||||
|
||||
@@ -176,13 +167,6 @@ function TvPageContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
setStreamError(false);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export default function LandingPage() {
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-zinc-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
LogoPosition,
|
||||
ProgrammingBlock,
|
||||
MediaFilter,
|
||||
RecyclePolicy,
|
||||
RotationPolicy,
|
||||
Weekday,
|
||||
} from "@/lib/types";
|
||||
import { WEEKDAYS } from "@/lib/types";
|
||||
@@ -51,7 +51,7 @@ export function defaultBlock(startMins = 20 * 60, durationMins = 60): Programmin
|
||||
duration_mins: durationMins,
|
||||
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
|
||||
loop_on_finish: true,
|
||||
ignore_recycle_policy: false,
|
||||
ignore_rotation_policy: false,
|
||||
access_mode: "public",
|
||||
};
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function useChannelForm(channel: ChannelResponse | null) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [timezone, setTimezone] = useState("UTC");
|
||||
const [dayBlocks, setDayBlocks] = useState<Record<Weekday, ProgrammingBlock[]>>(emptyDayBlocks);
|
||||
const [recyclePolicy, setRecyclePolicy] = useState<RecyclePolicy>({
|
||||
const [rotationPolicy, setRotationPolicy] = useState<RotationPolicy>({
|
||||
cooldown_days: null,
|
||||
cooldown_generations: null,
|
||||
min_available_ratio: 0.1,
|
||||
@@ -96,12 +96,12 @@ export function useChannelForm(channel: ChannelResponse | null) {
|
||||
...emptyDayBlocks(),
|
||||
...channel.schedule_config.day_blocks,
|
||||
});
|
||||
setRecyclePolicy(channel.recycle_policy);
|
||||
setRotationPolicy(channel.rotation_policy);
|
||||
setAutoSchedule(channel.auto_schedule);
|
||||
setAccessMode(channel.access_mode ?? "public");
|
||||
setAccessMode((channel.access_mode as AccessMode) ?? "public");
|
||||
setAccessPassword("");
|
||||
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));
|
||||
setWebhookUrl(channel.webhook_url ?? "");
|
||||
setWebhookPollInterval(channel.webhook_poll_interval_secs ?? 5);
|
||||
@@ -164,7 +164,7 @@ export function useChannelForm(channel: ChannelResponse | null) {
|
||||
// Blocks (day-keyed)
|
||||
dayBlocks, setDayBlocks,
|
||||
selectedBlockId, setSelectedBlockId,
|
||||
recyclePolicy, setRecyclePolicy,
|
||||
rotationPolicy, setRotationPolicy,
|
||||
addBlock,
|
||||
updateBlock,
|
||||
removeBlock,
|
||||
|
||||
@@ -33,7 +33,7 @@ export function useImportChannel(token: string | null) {
|
||||
WEEKDAYS.map(d => [d, d === 'monday' ? data.blocks : []])
|
||||
) as Record<Weekday, typeof data.blocks>,
|
||||
},
|
||||
recycle_policy: data.recycle_policy,
|
||||
rotation_policy: data.rotation_policy,
|
||||
},
|
||||
token,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ScheduleSlot } from "@/app/(main)/tv/components";
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves the live stream URL for a channel, starting at the correct
|
||||
* broadcast offset so refresh doesn't replay from the beginning.
|
||||
*
|
||||
* The backend's GET /channels/:id/stream endpoint returns a 307 redirect to
|
||||
* the Jellyfin stream URL. Since browsers can't read redirect Location headers
|
||||
* from fetch(), we proxy through /api/stream/[channelId] (a Next.js route that
|
||||
* runs server-side) and return the final URL as JSON.
|
||||
*
|
||||
* 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,
|
||||
});
|
||||
const PLAYOUT_URL =
|
||||
process.env.NEXT_PUBLIC_PLAYOUT_URL ?? "http://localhost:9090";
|
||||
|
||||
export function useStreamUrl(channelId: string | undefined) {
|
||||
if (!channelId) return { data: null, isLoading: false, error: null };
|
||||
return {
|
||||
data: `${PLAYOUT_URL}/playout/${channelId}/playlist.m3u8`,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export function exportChannel(channel: ChannelResponse): void {
|
||||
description: channel.description ?? undefined,
|
||||
timezone: channel.timezone,
|
||||
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)], {
|
||||
type: "application/json",
|
||||
|
||||
@@ -26,9 +26,7 @@ export const mediaFilterSchema = z.object({
|
||||
|
||||
export const accessModeSchema = z.enum([
|
||||
"public",
|
||||
"password_protected",
|
||||
"account_required",
|
||||
"owner_only",
|
||||
"private",
|
||||
]);
|
||||
|
||||
export const blockSchema = z.object({
|
||||
@@ -40,7 +38,7 @@ export const blockSchema = z.object({
|
||||
z.object({
|
||||
type: z.literal("algorithmic"),
|
||||
filter: mediaFilterSchema,
|
||||
strategy: z.enum(["best_fit", "sequential", "random"]),
|
||||
strategy: z.enum(["best_fit", "sequential", "random", "alternating", "weighted", "marathon"]),
|
||||
provider_id: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
@@ -50,7 +48,7 @@ export const blockSchema = z.object({
|
||||
}),
|
||||
]),
|
||||
loop_on_finish: z.boolean().optional(),
|
||||
ignore_recycle_policy: z.boolean().optional(),
|
||||
ignore_rotation_policy: z.boolean().optional(),
|
||||
access_mode: accessModeSchema.optional(),
|
||||
access_password: z.string().optional(),
|
||||
});
|
||||
@@ -63,7 +61,7 @@ export const channelFormSchema = z.object({
|
||||
.default(() =>
|
||||
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_generations: z.number().int().min(0).nullable().optional(),
|
||||
min_available_ratio: z
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// API response and request types matching the backend DTOs
|
||||
|
||||
export interface ActivityEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
@@ -17,11 +15,11 @@ export interface LogLine {
|
||||
|
||||
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 FillStrategy = "best_fit" | "sequential" | "random";
|
||||
export type FillStrategy = "best_fit" | "sequential" | "random" | "alternating" | "weighted" | "marathon";
|
||||
|
||||
export interface MediaFilter {
|
||||
content_type?: ContentType | null;
|
||||
@@ -31,9 +29,7 @@ export interface MediaFilter {
|
||||
min_duration_secs?: number | null;
|
||||
max_duration_secs?: number | null;
|
||||
collections: string[];
|
||||
/** Filter to one or more TV series by name. OR-combined: any listed show is eligible. */
|
||||
series_names?: string[];
|
||||
/** Free-text search, used for library browsing only. */
|
||||
search_term?: string | null;
|
||||
}
|
||||
|
||||
@@ -55,22 +51,43 @@ export interface SeriesResponse {
|
||||
|
||||
export interface LibraryItemResponse {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
external_id: string;
|
||||
title: string;
|
||||
content_type: ContentType;
|
||||
content_type: string;
|
||||
duration_secs: number;
|
||||
series_name?: string | null;
|
||||
season_number?: number | null;
|
||||
episode_number?: number | null;
|
||||
year?: number | null;
|
||||
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_generations?: number | null;
|
||||
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 =
|
||||
| { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy; provider_id?: string }
|
||||
| { type: "manual"; items: string[]; provider_id?: string };
|
||||
@@ -78,16 +95,14 @@ export type BlockContent =
|
||||
export interface ProgrammingBlock {
|
||||
id: string;
|
||||
name: string;
|
||||
/** "HH:MM:SS" */
|
||||
start_time: string;
|
||||
duration_mins: number;
|
||||
content: BlockContent;
|
||||
/** Sequential only: loop back to episode 1 after the last episode. Default true on backend. */
|
||||
loop_on_finish?: boolean;
|
||||
/** When true, skip the channel-level recycle policy for this block. Default false on backend. */
|
||||
ignore_recycle_policy?: boolean;
|
||||
ignore_rotation_policy?: boolean;
|
||||
interstitial_rule?: InterstitialRule | null;
|
||||
mid_roll_rule?: MidRollRule | null;
|
||||
access_mode?: AccessMode;
|
||||
/** Plain-text password sent to API; hashed server-side. Only set on write operations. */
|
||||
access_password?: string;
|
||||
}
|
||||
|
||||
@@ -154,9 +169,7 @@ export interface ProviderInfo {
|
||||
|
||||
export interface ConfigResponse {
|
||||
allow_registration: boolean;
|
||||
/** All registered providers. Added in multi-provider update. */
|
||||
providers: ProviderInfo[];
|
||||
/** Primary provider capabilities — kept for backward compat. */
|
||||
provider_capabilities: ProviderCapabilities;
|
||||
available_provider_types: string[];
|
||||
}
|
||||
@@ -198,16 +211,17 @@ export interface ChannelResponse {
|
||||
description?: string | null;
|
||||
timezone: string;
|
||||
schedule_config: ScheduleConfig;
|
||||
recycle_policy: RecyclePolicy;
|
||||
rotation_policy: RotationPolicy;
|
||||
auto_schedule: boolean;
|
||||
access_mode: AccessMode;
|
||||
access_mode: string;
|
||||
logo?: string | null;
|
||||
logo_position: LogoPosition;
|
||||
logo_position: string;
|
||||
logo_opacity: number;
|
||||
webhook_url?: string | null;
|
||||
webhook_poll_interval_secs?: number;
|
||||
webhook_poll_interval_secs: number;
|
||||
webhook_body_template?: string | null;
|
||||
webhook_headers?: string | null;
|
||||
gap_filler?: MediaFilter | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -229,22 +243,17 @@ export interface UpdateChannelRequest {
|
||||
description?: string;
|
||||
timezone?: string;
|
||||
schedule_config?: ScheduleConfig;
|
||||
recycle_policy?: RecyclePolicy;
|
||||
rotation_policy?: RotationPolicy;
|
||||
auto_schedule?: boolean;
|
||||
access_mode?: AccessMode;
|
||||
/** Empty string clears the password. */
|
||||
access_password?: string;
|
||||
/** null = clear logo */
|
||||
access_mode?: string;
|
||||
logo?: string | null;
|
||||
logo_position?: LogoPosition;
|
||||
logo_position?: string;
|
||||
logo_opacity?: number;
|
||||
/** null = clear webhook */
|
||||
webhook_url?: string | null;
|
||||
webhook_poll_interval_secs?: number;
|
||||
/** null = clear template */
|
||||
webhook_body_template?: string | null;
|
||||
/** null = clear headers */
|
||||
webhook_headers?: string | null;
|
||||
gap_filler?: MediaFilter | null;
|
||||
}
|
||||
|
||||
// Media & Schedule
|
||||
@@ -252,56 +261,40 @@ export interface UpdateChannelRequest {
|
||||
export interface MediaItemResponse {
|
||||
id: string;
|
||||
title: string;
|
||||
content_type: ContentType;
|
||||
content_type: string;
|
||||
duration_secs: number;
|
||||
description?: string | null;
|
||||
genres: string[];
|
||||
tags: string[];
|
||||
year?: number | null;
|
||||
/** Episodes only: the parent TV show name. */
|
||||
series_name?: string | null;
|
||||
/** Episodes only: season number (1-based). */
|
||||
season_number?: number | null;
|
||||
/** Episodes only: episode number within the season (1-based). */
|
||||
episode_number?: number | null;
|
||||
}
|
||||
|
||||
export interface ScheduledSlotResponse {
|
||||
id: string;
|
||||
block_id: string;
|
||||
item: MediaItemResponse;
|
||||
/** RFC3339 */
|
||||
start_at: string;
|
||||
/** RFC3339 */
|
||||
end_at: string;
|
||||
block_access_mode: AccessMode;
|
||||
item: MediaItemResponse;
|
||||
source_block_id: string;
|
||||
}
|
||||
|
||||
export interface ScheduleResponse {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
generation: number;
|
||||
generated_at: string;
|
||||
valid_from: string;
|
||||
valid_until: string;
|
||||
generation: number;
|
||||
slots: ScheduledSlotResponse[];
|
||||
}
|
||||
|
||||
export interface CurrentBroadcastResponse {
|
||||
slot: ScheduledSlotResponse;
|
||||
offset_secs: number;
|
||||
block_access_mode: AccessMode;
|
||||
}
|
||||
|
||||
// Library management
|
||||
// Note: LibraryItemResponse is already defined in this file (search for it above).
|
||||
// LibraryItemFull extends it with the extra fields returned by the DB-backed endpoint.
|
||||
|
||||
export interface LibraryItemFull extends LibraryItemResponse {
|
||||
thumbnail_url?: string | null;
|
||||
collection_id?: string | null;
|
||||
collection_name?: string | null;
|
||||
}
|
||||
export type LibraryItemFull = LibraryItemResponse;
|
||||
|
||||
export interface ShowSummary {
|
||||
series_name: string;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE library_items ADD COLUMN role TEXT NOT NULL DEFAULT 'program';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE channels RENAME COLUMN recycle_policy TO rotation_policy;
|
||||
Reference in New Issue
Block a user