Compare commits

..

29 Commits

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

40
Cargo.lock generated
View File

@@ -25,6 +25,7 @@ dependencies = [
"serde",
"serde_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",

View File

@@ -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

View File

@@ -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: []

View File

@@ -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:

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ impl SqliteChannelRepository {
}
}
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy AS rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, gap_filler, created_at, updated_at";
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, gap_filler, created_at, updated_at";
#[derive(Debug, sqlx::FromRow)]
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,

View File

@@ -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));
}

View File

@@ -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>,
}

View File

@@ -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(),
}

View File

@@ -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>,
}

View File

@@ -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::{

View File

@@ -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,
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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?;

View File

@@ -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::*;

View File

@@ -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)]

View File

@@ -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)]

View File

@@ -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]

View File

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

View File

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

View File

@@ -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,60 +204,39 @@ 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 {
for item in items {
if remaining == 0 {
break;
}
if used.contains(item.id()) {
continue;
}
if item.duration_secs() <= remaining {
remaining -= item.duration_secs();
used.insert(item.id());
result.push(*item);
}
}
if remaining > 0 {
let mut rest: Vec<&MediaItem> = all_in_pool
.iter()
.filter(|i| !used.contains(i.id()))
.copied()
.collect();
rest.shuffle(&mut rng);
for item in rest {
if remaining == 0 {
break;
}
if item.duration_secs() <= remaining {
remaining -= item.duration_secs();
result.push(item);
}
result.push(item);
}
}
@@ -311,10 +293,10 @@ pub(super) fn fill_marathon<'a>(
}
}
if result.is_empty() {
if let Some(&first) = ordered.first() {
result.push(first);
}
if result.is_empty()
&& let Some(&first) = ordered.first()
{
result.push(first);
}
result

View File

@@ -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,10 +692,8 @@ 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 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;

View File

@@ -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");
}

View File

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

View File

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

View File

@@ -418,6 +418,18 @@ impl LibraryCommand for InMemoryLibraryRepository {
Ok(id)
}
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;

View File

@@ -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,
}

View File

@@ -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,
schedule_config,
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(),
),

View File

@@ -45,24 +45,30 @@ pub async fn create_channel(
}
}
pub struct UpdateChannelArgs {
pub channel_id: Uuid,
pub owner_id: Uuid,
pub name: Option<String>,
pub timezone: Option<String>,
pub description: Option<String>,
pub schedule_config: Option<domain::ScheduleConfig>,
pub gap_filler: Option<Option<domain::MediaFilter>>,
}
pub async fn update_channel(
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),

View File

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

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::sync::Arc;
use 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,
})
}

View File

@@ -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",
}
}

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ domain = { workspace = true }
application = { workspace = true }
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"] }

View File

@@ -435,10 +435,16 @@ impl IProviderRegistry for SimpleProviderRegistry {
}
}
fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> domain::MediaItem {
fn provider_item_to_library_item(
item: domain::MediaItem,
provider_id: &str,
role_config: &adapter_common::role_detector::RoleDetectionConfig,
) -> domain::MediaItem {
let external_id = item.id().value().to_string();
let 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)

View File

@@ -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,

View File

@@ -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>,

View File

@@ -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,

View File

@@ -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> {

View File

@@ -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,

View File

@@ -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)))
}

View File

@@ -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,

View File

@@ -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)))
}

View File

@@ -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);

View File

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

View File

@@ -1,6 +1,9 @@
use axum::{Router, routing::{delete, get, post, put}};
use axum::{Json, Router, routing::{delete, get, post, put}};
use utoipa::OpenApi;
use utoipa_scalar::{Scalar, Servable};
use crate::handlers;
use crate::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))

View File

@@ -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 }

View File

@@ -341,10 +341,16 @@ impl IProviderRegistry for SimpleProviderRegistry {
}
}
fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> domain::MediaItem {
fn provider_item_to_library_item(
item: domain::MediaItem,
provider_id: &str,
role_config: &adapter_common::role_detector::RoleDetectionConfig,
) -> domain::MediaItem {
let external_id = item.id().value().to_string();
let 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)

View File

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

View File

@@ -1,31 +1,30 @@
FROM rust:1.92 AS builder
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"]

View File

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

View File

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

View File

@@ -12,12 +12,8 @@ interface AccessSettingsEditorProps {
export function AccessSettingsEditor({
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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>;

View File

@@ -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.01.0 · Fraction of the pool kept selectable even if cooldown is active"
error={errors["recycle_policy.min_available_ratio"]}
error={errors["rotation_policy.min_available_ratio"]}
>
<NumberInput
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>

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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);

View File

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

View File

@@ -61,7 +61,7 @@ export default function LandingPage() {
</h3>
<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>

View File

@@ -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,

View File

@@ -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,
);

View File

@@ -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,
};
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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;

View File

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

View File

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