Compare commits

..

10 Commits

18 changed files with 166 additions and 296 deletions

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

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

@@ -305,7 +305,7 @@ fn parse_duration(s: &str) -> DomainResult<u32> {
)));
}
let body = &s[2..];
let mut total_mins: u32 = 0;
let mut total_secs: u32 = 0;
let mut num_buf = String::new();
for c in body.chars() {
@@ -317,9 +317,9 @@ fn parse_duration(s: &str) -> DomainResult<u32> {
})?;
num_buf.clear();
match c {
'H' => total_mins += n * 60,
'M' => total_mins += n,
'S' => total_mins += n / 60,
'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}"
@@ -328,6 +328,7 @@ fn parse_duration(s: &str) -> DomainResult<u32> {
}
}
}
let total_mins = (total_secs + 59) / 60;
if total_mins == 0 {
return Err(crate::DomainError::validation(format!(
"zero DURATION: {s}"

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

View File

@@ -64,116 +64,34 @@ impl ScheduleEngineService {
channel_id: ChannelId,
from: DateTime<Utc>,
) -> DomainResult<GeneratedSchedule> {
let channel = self
.channel_query
.find_by_id(channel_id)
.await?
.ok_or(DomainError::ChannelNotFound(channel_id))?;
let tz: Tz = channel
.timezone()
.parse()
.map_err(|_| DomainError::TimezoneError(channel.timezone().to_owned()))?;
let history = self
.schedule_query
.find_playback_history(channel_id)
.await?;
let latest_schedule = self.schedule_query.find_latest(channel_id).await?;
let generation = latest_schedule
.as_ref()
.map(|s| s.generation() + 1)
.unwrap_or(1);
let mut block_continuity = self
.schedule_query
.find_last_slot_per_block(channel_id)
.await?;
let valid_from = from;
let channel = self.load_channel(channel_id).await?;
let valid_until = from + Duration::days(SCHEDULE_DURATION_DAYS);
let start_date = from.with_timezone(&tz).date_naive();
let end_date = valid_until.with_timezone(&tz).date_naive();
let mut slots: Vec<ScheduledSlot> = Vec::new();
let mut current_date = start_date;
while current_date <= end_date {
let weekday = Weekday::from(current_date.weekday());
for block in channel.schedule_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,
};
let block_end_utc =
block_start_utc + Duration::minutes(block.duration_mins() as i64);
let slot_start = block_start_utc.max(valid_from);
let slot_end = block_end_utc.min(valid_until);
if slot_end <= slot_start {
continue;
}
let last_item_id = block_continuity.get(&block.id());
let mut block_slots = self
.resolve_block(
block,
BlockTimeWindow {
start: slot_start,
end: slot_end,
},
RotationContext {
history: &history,
policy: channel.rotation_policy(),
generation,
last_item_id,
},
)
.await?;
if let Some(last_slot) = block_slots.last() {
block_continuity.insert(block.id(), last_slot.item().id().clone());
}
slots.append(&mut block_slots);
}
current_date = current_date.succ_opt().ok_or_else(|| {
DomainError::validation("Date overflow during schedule generation")
})?;
}
slots.sort_by_key(|s| s.start_at());
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() {
Self::fill_gaps(&mut slots, &filler_items, valid_from, valid_until);
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,
);
}
}
let schedule = 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);
PlaybackRecord::new(channel_id, slot.item().id().clone(), schedule.generation());
self.schedule_command.save_playback_record(&record).await?;
}
@@ -186,100 +104,10 @@ impl ScheduleEngineService {
from: DateTime<Utc>,
duration_hours: u32,
) -> DomainResult<GeneratedSchedule> {
let channel = self
.channel_query
.find_by_id(channel_id)
.await?
.ok_or(DomainError::ChannelNotFound(channel_id))?;
let tz: Tz = channel
.timezone()
.parse()
.map_err(|_| DomainError::TimezoneError(channel.timezone().to_owned()))?;
let history = self
.schedule_query
.find_playback_history(channel_id)
.await?;
let latest_schedule = self.schedule_query.find_latest(channel_id).await?;
let generation = latest_schedule
.as_ref()
.map(|s| s.generation() + 1)
.unwrap_or(1);
let mut block_continuity = self
.schedule_query
.find_last_slot_per_block(channel_id)
.await?;
let valid_from = from;
let channel = self.load_channel(channel_id).await?;
let valid_until = from + Duration::hours(duration_hours as i64);
let start_date = from.with_timezone(&tz).date_naive();
let end_date = valid_until.with_timezone(&tz).date_naive();
let mut slots: Vec<ScheduledSlot> = Vec::new();
let mut current_date = start_date;
while current_date <= end_date {
let weekday = Weekday::from(current_date.weekday());
for block in channel.schedule_config().blocks_for(weekday) {
let naive_start = current_date.and_time(block.start_time());
let block_start_utc = match tz.from_local_datetime(&naive_start).earliest() {
Some(dt) => dt.with_timezone(&Utc),
None => continue,
};
let block_end_utc =
block_start_utc + Duration::minutes(block.duration_mins() as i64);
let slot_start = block_start_utc.max(valid_from);
let slot_end = block_end_utc.min(valid_until);
if slot_end <= slot_start {
continue;
}
let last_item_id = block_continuity.get(&block.id());
let mut block_slots = self
.resolve_block(
block,
BlockTimeWindow {
start: slot_start,
end: slot_end,
},
RotationContext {
history: &history,
policy: channel.rotation_policy(),
generation,
last_item_id,
},
)
.await?;
if let Some(last_slot) = block_slots.last() {
block_continuity.insert(block.id(), last_slot.item().id().clone());
}
slots.append(&mut block_slots);
}
current_date = current_date.succ_opt().ok_or_else(|| {
DomainError::validation("Date overflow during schedule generation")
})?;
}
slots.sort_by_key(|s| s.start_at());
Ok(GeneratedSchedule::new(
channel_id,
valid_from,
valid_until,
generation,
slots,
))
self.build_schedule(&channel, channel.schedule_config(), from, valid_until)
.await
}
pub async fn preview_config(
@@ -289,12 +117,30 @@ impl ScheduleEngineService {
from: DateTime<Utc>,
duration_hours: u32,
) -> DomainResult<GeneratedSchedule> {
let channel = self
.channel_query
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()
@@ -304,6 +150,7 @@ impl ScheduleEngineService {
.schedule_query
.find_playback_history(channel_id)
.await?;
let latest_schedule = self.schedule_query.find_latest(channel_id).await?;
let generation = latest_schedule
.as_ref()
@@ -315,9 +162,7 @@ impl ScheduleEngineService {
.find_last_slot_per_block(channel_id)
.await?;
let valid_from = from;
let valid_until = from + Duration::hours(duration_hours as i64);
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();
@@ -325,7 +170,6 @@ impl ScheduleEngineService {
while current_date <= end_date {
let weekday = Weekday::from(current_date.weekday());
for block in config.blocks_for(weekday) {
let naive_start = current_date.and_time(block.start_time());
@@ -744,6 +588,7 @@ impl ScheduleEngineService {
params.strategy,
rotation.last_item_id,
params.loop_on_finish,
rotation.history,
);
let mut slots = Vec::new();
@@ -857,7 +702,7 @@ 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
}

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

@@ -13,6 +13,7 @@ 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,
@@ -60,6 +61,10 @@ 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
@@ -104,6 +109,9 @@ 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
}
@@ -130,6 +138,7 @@ impl Default for LibrarySearchFilter {
min_duration_secs: None,
max_duration_secs: None,
search_term: None,
tags: vec![],
season_number: None,
role: None,
offset: 0,

View File

@@ -94,20 +94,7 @@ struct RecentSyncDto {
items_found: 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",
}
}
fn role_to_str(r: &domain::MediaRole) -> &'static str {
match r {
domain::MediaRole::Program => "program",
domain::MediaRole::Interstitial => "interstitial",
}
}
use super::{content_type_str, role_str};
fn item_to_dto(i: &domain::MediaItem) -> LibraryItemDto {
LibraryItemDto {
@@ -115,7 +102,7 @@ fn item_to_dto(i: &domain::MediaItem) -> LibraryItemDto {
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(),
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(),
@@ -125,7 +112,7 @@ fn item_to_dto(i: &domain::MediaItem) -> LibraryItemDto {
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_to_str(i.role()).to_string(),
role: role_str(i.role()).to_string(),
}
}
@@ -241,7 +228,7 @@ pub async fn browse_library(
.iter()
.filter(|i| {
if let Some(ref r) = role {
let item_role = role_to_str(i.role());
let item_role = role_str(i.role());
if item_role != r.as_str() {
return false;
}
@@ -270,13 +257,13 @@ pub async fn browse_library(
BrowseItemDto {
id: i.id().value().to_string(),
title: i.title().to_string(),
content_type: content_type_to_str(i.content_type()).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_to_str(i.role()).to_string(),
role: role_str(i.role()).to_string(),
}
})
.collect();
@@ -337,10 +324,10 @@ pub async fn library_stats(
for item in &items {
*by_content_type
.entry(content_type_to_str(item.content_type()).to_string())
.entry(content_type_str(item.content_type()).to_string())
.or_default() += 1;
*by_role
.entry(role_to_str(item.role()).to_string())
.entry(role_str(item.role()).to_string())
.or_default() += 1;
for genre in item.genres() {
*genre_counts.entry(genre.clone()).or_default() += 1;

View File

@@ -2,3 +2,18 @@ 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

@@ -132,12 +132,7 @@ pub async fn analyze_schedule(
let entry = title_counts
.entry(title_key)
.or_insert_with(|| {
let ct = match slot.item().content_type() {
domain::ContentType::Movie => "movie",
domain::ContentType::Episode => "episode",
domain::ContentType::Short => "short",
};
(ct.to_string(), 0)
(super::content_type_str(slot.item().content_type()).to_string(), 0)
});
entry.1 += 1;
@@ -261,12 +256,7 @@ pub async fn preview_schedule(
start_at: s.start_at().to_rfc3339(),
end_at: s.end_at().to_rfc3339(),
title: s.item().title().to_string(),
content_type: match s.item().content_type() {
domain::ContentType::Movie => "movie",
domain::ContentType::Episode => "episode",
domain::ContentType::Short => "short",
}
.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(),
})
@@ -318,12 +308,7 @@ pub async fn preview_config(
start_at: s.start_at().to_rfc3339(),
end_at: s.end_at().to_rfc3339(),
title: s.item().title().to_string(),
content_type: match s.item().content_type() {
domain::ContentType::Movie => "movie",
domain::ContentType::Episode => "episode",
domain::ContentType::Short => "short",
}
.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(),
})

View File

@@ -5,7 +5,6 @@ use std::time::Duration;
pub struct PlayoutConfig {
pub listen_addr: String,
pub segment_duration_secs: u32,
pub target_duration_secs: u32,
pub window_size: usize,
pub storage_path: PathBuf,
pub tick_interval_ms: u64,
@@ -17,7 +16,6 @@ impl Default for PlayoutConfig {
Self {
listen_addr: "0.0.0.0:9090".into(),
segment_duration_secs: 6,
target_duration_secs: 6,
window_size: 10,
storage_path: PathBuf::from("/tmp/k-tv-playout"),
tick_interval_ms: 1000,
@@ -34,7 +32,6 @@ impl PlayoutConfig {
}
if let Some(v) = std::env::var("PLAYOUT_SEGMENT_DURATION").ok().and_then(|v| v.parse().ok()) {
config.segment_duration_secs = v;
config.target_duration_secs = v;
}
if let Some(v) = std::env::var("PLAYOUT_WINDOW_SIZE").ok().and_then(|v| v.parse().ok()) {
config.window_size = v;

View File

@@ -5,7 +5,7 @@ use crate::config::PlayoutConfig;
use crate::metadata::{OverlayPayload, TimedMetadata};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scte35Event {
pub(crate) enum Scte35Event {
SpliceOut {
id: String,
start: DateTime<Utc>,
@@ -18,13 +18,13 @@ pub enum Scte35Event {
}
#[derive(Debug, Clone)]
pub struct PlayoutPlan {
pub(crate) struct PlayoutPlan {
pub slots: Vec<ScheduledSlot>,
pub scte35_events: Vec<Scte35Event>,
pub timed_metadata: Vec<TimedMetadata>,
}
pub fn build_playout_plan(slots: &[ScheduledSlot], config: &PlayoutConfig) -> PlayoutPlan {
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);
@@ -103,7 +103,7 @@ fn generate_overlay_triggers(
triggers
}
pub fn render_scte35_daterange(event: &Scte35Event) -> String {
pub(crate) fn render_scte35_daterange(event: &Scte35Event) -> String {
match event {
Scte35Event::SpliceOut {
id,

View File

@@ -171,6 +171,20 @@ pub async fn list_schedule_history(
))
}
#[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,
@@ -202,6 +216,22 @@ pub async fn export_ical(
}
}
#[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,

View File

@@ -50,6 +50,8 @@ impl Modify for BearerAuth {
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,

View File

@@ -6,7 +6,7 @@ RUN cargo build --release -p presentation -p worker -p playout
# Presentation image
FROM debian:bookworm-slim AS presentation
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
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
EXPOSE 3000

View File

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

View File

@@ -221,6 +221,7 @@ export interface ChannelResponse {
webhook_poll_interval_secs: number;
webhook_body_template?: string | null;
webhook_headers?: string | null;
gap_filler?: MediaFilter | null;
created_at: string;
updated_at: string;
}
@@ -252,6 +253,7 @@ export interface UpdateChannelRequest {
webhook_poll_interval_secs?: number;
webhook_body_template?: string | null;
webhook_headers?: string | null;
gap_filler?: MediaFilter | null;
}
// Media & Schedule

View File

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