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)] #[derive(Debug, sqlx::FromRow)]
struct ChannelRow { struct ChannelRow {
@@ -126,7 +126,7 @@ impl ChannelCommand for SqliteChannelRepository {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO channels INSERT INTO channels
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, (id, owner_id, name, description, timezone, schedule_config, rotation_policy,
auto_schedule, access_mode, logo, logo_position, auto_schedule, access_mode, logo, logo_position,
logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template,
webhook_headers, gap_filler, created_at, updated_at) webhook_headers, gap_filler, created_at, updated_at)
@@ -136,7 +136,7 @@ impl ChannelCommand for SqliteChannelRepository {
description = excluded.description, description = excluded.description,
timezone = excluded.timezone, timezone = excluded.timezone,
schedule_config = excluded.schedule_config, schedule_config = excluded.schedule_config,
recycle_policy = excluded.recycle_policy, rotation_policy = excluded.rotation_policy,
auto_schedule = excluded.auto_schedule, auto_schedule = excluded.auto_schedule,
access_mode = excluded.access_mode, access_mode = excluded.access_mode,
logo = excluded.logo, logo = excluded.logo,

View File

@@ -273,6 +273,19 @@ impl LibraryQuery for SqliteLibraryRepository {
.collect(); .collect();
conditions.push(format!("({})", genre_conditions.join(" OR "))); conditions.push(format!("({})", genre_conditions.join(" OR ")));
} }
if !filter.tags().is_empty() {
let tag_conditions: Vec<String> = filter
.tags()
.iter()
.map(|t| {
format!(
"EXISTS (SELECT 1 FROM json_each(library_items.tags) WHERE LOWER(value) = LOWER('{}'))",
t.replace('\'', "''")
)
})
.collect();
conditions.push(format!("({})", tag_conditions.join(" OR ")));
}
if let Some(sn) = filter.season_number() { if let Some(sn) = filter.season_number() {
conditions.push(format!("season_number = {}", sn)); conditions.push(format!("season_number = {}", sn));
} }

View File

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

View File

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

View File

@@ -64,116 +64,34 @@ impl ScheduleEngineService {
channel_id: ChannelId, channel_id: ChannelId,
from: DateTime<Utc>, from: DateTime<Utc>,
) -> DomainResult<GeneratedSchedule> { ) -> DomainResult<GeneratedSchedule> {
let channel = self let channel = self.load_channel(channel_id).await?;
.channel_query
.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 valid_until = from + Duration::days(SCHEDULE_DURATION_DAYS); let valid_until = from + Duration::days(SCHEDULE_DURATION_DAYS);
let start_date = from.with_timezone(&tz).date_naive(); let mut schedule = self
let end_date = valid_until.with_timezone(&tz).date_naive(); .build_schedule(&channel, channel.schedule_config(), from, valid_until)
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?; .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());
if let Some(gap_filter) = channel.gap_filler() { if let Some(gap_filter) = channel.gap_filler() {
let filler_items = self.query_gap_fillers(gap_filter).await?; let filler_items = self.query_gap_fillers(gap_filter).await?;
if !filler_items.is_empty() { 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(
let schedule = GeneratedSchedule::new(
channel_id, channel_id,
valid_from, from,
valid_until, valid_until,
generation, generation,
slots, slots,
); );
}
}
self.schedule_command.save(&schedule).await?; self.schedule_command.save(&schedule).await?;
for slot in schedule.slots() { for slot in schedule.slots() {
let record = 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?; self.schedule_command.save_playback_record(&record).await?;
} }
@@ -186,100 +104,10 @@ impl ScheduleEngineService {
from: DateTime<Utc>, from: DateTime<Utc>,
duration_hours: u32, duration_hours: u32,
) -> DomainResult<GeneratedSchedule> { ) -> DomainResult<GeneratedSchedule> {
let channel = self let channel = self.load_channel(channel_id).await?;
.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 valid_until = from + Duration::hours(duration_hours as i64); let valid_until = from + Duration::hours(duration_hours as i64);
let start_date = from.with_timezone(&tz).date_naive(); self.build_schedule(&channel, channel.schedule_config(), from, valid_until)
let end_date = valid_until.with_timezone(&tz).date_naive(); .await
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,
))
} }
pub async fn preview_config( pub async fn preview_config(
@@ -289,12 +117,30 @@ impl ScheduleEngineService {
from: DateTime<Utc>, from: DateTime<Utc>,
duration_hours: u32, duration_hours: u32,
) -> DomainResult<GeneratedSchedule> { ) -> DomainResult<GeneratedSchedule> {
let channel = self let channel = self.load_channel(channel_id).await?;
.channel_query let valid_until = from + Duration::hours(duration_hours as i64);
self.build_schedule(&channel, config, from, valid_until)
.await
}
async fn load_channel(
&self,
channel_id: ChannelId,
) -> DomainResult<crate::models::Channel> {
self.channel_query
.find_by_id(channel_id) .find_by_id(channel_id)
.await? .await?
.ok_or(DomainError::ChannelNotFound(channel_id))?; .ok_or(DomainError::ChannelNotFound(channel_id))
}
async fn build_schedule(
&self,
channel: &crate::models::Channel,
config: &crate::models::ScheduleConfig,
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
) -> DomainResult<GeneratedSchedule> {
let channel_id = channel.id();
let tz: Tz = channel let tz: Tz = channel
.timezone() .timezone()
.parse() .parse()
@@ -304,6 +150,7 @@ impl ScheduleEngineService {
.schedule_query .schedule_query
.find_playback_history(channel_id) .find_playback_history(channel_id)
.await?; .await?;
let latest_schedule = self.schedule_query.find_latest(channel_id).await?; let latest_schedule = self.schedule_query.find_latest(channel_id).await?;
let generation = latest_schedule let generation = latest_schedule
.as_ref() .as_ref()
@@ -315,9 +162,7 @@ impl ScheduleEngineService {
.find_last_slot_per_block(channel_id) .find_last_slot_per_block(channel_id)
.await?; .await?;
let valid_from = from; let start_date = valid_from.with_timezone(&tz).date_naive();
let valid_until = from + Duration::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 end_date = valid_until.with_timezone(&tz).date_naive();
let mut slots: Vec<ScheduledSlot> = Vec::new(); let mut slots: Vec<ScheduledSlot> = Vec::new();
@@ -325,7 +170,6 @@ impl ScheduleEngineService {
while current_date <= end_date { while current_date <= end_date {
let weekday = Weekday::from(current_date.weekday()); let weekday = Weekday::from(current_date.weekday());
for block in config.blocks_for(weekday) { for block in config.blocks_for(weekday) {
let naive_start = current_date.and_time(block.start_time()); let naive_start = current_date.and_time(block.start_time());
@@ -744,6 +588,7 @@ impl ScheduleEngineService {
params.strategy, params.strategy,
rotation.last_item_id, rotation.last_item_id,
params.loop_on_finish, params.loop_on_finish,
rotation.history,
); );
let mut slots = Vec::new(); let mut slots = Vec::new();
@@ -857,7 +702,7 @@ fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) ->
lsf = lsf.with_search_term(term.clone()); lsf = lsf.with_search_term(term.clone());
} }
if !filter.tags.is_empty() { if !filter.tags.is_empty() {
// tags map to the same concept in the library lsf = lsf.with_tags(filter.tags.clone());
} }
lsf lsf
} }

View File

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

View File

@@ -13,6 +13,7 @@ pub struct LibrarySearchFilter {
min_duration_secs: Option<u32>, min_duration_secs: Option<u32>,
max_duration_secs: Option<u32>, max_duration_secs: Option<u32>,
search_term: Option<String>, search_term: Option<String>,
tags: Vec<String>,
season_number: Option<u32>, season_number: Option<u32>,
role: Option<MediaRole>, role: Option<MediaRole>,
offset: u32, offset: u32,
@@ -60,6 +61,10 @@ impl LibrarySearchFilter {
self.search_term = Some(term.into()); self.search_term = Some(term.into());
self self
} }
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn with_season_number(mut self, n: u32) -> Self { pub fn with_season_number(mut self, n: u32) -> Self {
self.season_number = Some(n); self.season_number = Some(n);
self self
@@ -104,6 +109,9 @@ impl LibrarySearchFilter {
pub fn search_term(&self) -> Option<&str> { pub fn search_term(&self) -> Option<&str> {
self.search_term.as_deref() self.search_term.as_deref()
} }
pub fn tags(&self) -> &[String] {
&self.tags
}
pub fn season_number(&self) -> Option<u32> { pub fn season_number(&self) -> Option<u32> {
self.season_number self.season_number
} }
@@ -130,6 +138,7 @@ impl Default for LibrarySearchFilter {
min_duration_secs: None, min_duration_secs: None,
max_duration_secs: None, max_duration_secs: None,
search_term: None, search_term: None,
tags: vec![],
season_number: None, season_number: None,
role: None, role: None,
offset: 0, offset: 0,

View File

@@ -94,20 +94,7 @@ struct RecentSyncDto {
items_found: u32, items_found: u32,
} }
fn content_type_to_str(ct: &domain::ContentType) -> &'static str { use super::{content_type_str, role_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",
}
}
fn item_to_dto(i: &domain::MediaItem) -> LibraryItemDto { fn item_to_dto(i: &domain::MediaItem) -> LibraryItemDto {
LibraryItemDto { LibraryItemDto {
@@ -115,7 +102,7 @@ fn item_to_dto(i: &domain::MediaItem) -> LibraryItemDto {
provider_id: i.provider_id().to_string(), provider_id: i.provider_id().to_string(),
external_id: i.external_id().to_string(), external_id: i.external_id().to_string(),
title: i.title().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(), duration_secs: i.duration_secs(),
series_name: i.series_name().map(|s| s.to_string()), series_name: i.series_name().map(|s| s.to_string()),
season_number: i.season_number(), season_number: i.season_number(),
@@ -125,7 +112,7 @@ fn item_to_dto(i: &domain::MediaItem) -> LibraryItemDto {
tags: i.tags().to_vec(), tags: i.tags().to_vec(),
collection_id: i.collection_id().map(|s| s.to_string()), collection_id: i.collection_id().map(|s| s.to_string()),
thumbnail_url: i.thumbnail_url().map(|s| s.to_string()), thumbnail_url: i.thumbnail_url().map(|s| s.to_string()),
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() .iter()
.filter(|i| { .filter(|i| {
if let Some(ref r) = role { 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() { if item_role != r.as_str() {
return false; return false;
} }
@@ -270,13 +257,13 @@ pub async fn browse_library(
BrowseItemDto { BrowseItemDto {
id: i.id().value().to_string(), id: i.id().value().to_string(),
title: i.title().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, duration_mins: i.duration_secs() / 60,
series_name: i.series_name().map(|s| s.to_string()), series_name: i.series_name().map(|s| s.to_string()),
season_episode: se, season_episode: se,
year: i.year(), year: i.year(),
genres: i.genres().to_vec(), genres: i.genres().to_vec(),
role: role_to_str(i.role()).to_string(), role: role_str(i.role()).to_string(),
} }
}) })
.collect(); .collect();
@@ -337,10 +324,10 @@ pub async fn library_stats(
for item in &items { for item in &items {
*by_content_type *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; .or_default() += 1;
*by_role *by_role
.entry(role_to_str(item.role()).to_string()) .entry(role_str(item.role()).to_string())
.or_default() += 1; .or_default() += 1;
for genre in item.genres() { for genre in item.genres() {
*genre_counts.entry(genre.clone()).or_default() += 1; *genre_counts.entry(genre.clone()).or_default() += 1;

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,8 @@ impl Modify for BearerAuth {
crate::handlers::schedule::get_epg, crate::handlers::schedule::get_epg,
crate::handlers::schedule::get_stream, crate::handlers::schedule::get_stream,
crate::handlers::schedule::list_schedule_history, crate::handlers::schedule::list_schedule_history,
crate::handlers::schedule::export_ical,
crate::handlers::schedule::import_ical,
crate::handlers::admin::get_settings, crate::handlers::admin::get_settings,
crate::handlers::admin::update_settings, crate::handlers::admin::update_settings,
crate::handlers::admin::get_activity_log, crate::handlers::admin::get_activity_log,

View File

@@ -6,7 +6,7 @@ RUN cargo build --release -p presentation -p worker -p playout
# Presentation image # Presentation image
FROM debian:bookworm-slim AS presentation FROM debian:bookworm-slim AS presentation
WORKDIR /app 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 . COPY --from=builder /app/target/release/k-tv .
RUN mkdir -p /app/data RUN mkdir -p /app/data
EXPOSE 3000 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_poll_interval_secs: number;
webhook_body_template?: string | null; webhook_body_template?: string | null;
webhook_headers?: string | null; webhook_headers?: string | null;
gap_filler?: MediaFilter | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -252,6 +253,7 @@ export interface UpdateChannelRequest {
webhook_poll_interval_secs?: number; webhook_poll_interval_secs?: number;
webhook_body_template?: string | null; webhook_body_template?: string | null;
webhook_headers?: string | null; webhook_headers?: string | null;
gap_filler?: MediaFilter | null;
} }
// Media & Schedule // Media & Schedule

View File

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