Compare commits
10 Commits
5bc1e5e44b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2698cb3ad4 | |||
| abd6bf4bc4 | |||
| 25ff9d2779 | |||
| 2995aea606 | |||
| f52e024e98 | |||
| 5561b70e1b | |||
| 8de7d33007 | |||
| 8dabbdf280 | |||
| 9558f04f73 | |||
| a823a79f6b |
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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,60 +204,39 @@ 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 {
|
if remaining == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if used.contains(item.id()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if item.duration_secs() <= remaining {
|
if item.duration_secs() <= remaining {
|
||||||
remaining -= item.duration_secs();
|
remaining -= item.duration_secs();
|
||||||
used.insert(item.id());
|
result.push(item);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
.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());
|
|
||||||
|
|
||||||
// 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());
|
|
||||||
|
|
||||||
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(
|
||||||
|
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?;
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE channels RENAME COLUMN recycle_policy TO rotation_policy;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE channels RENAME COLUMN recycle_policy TO rotation_policy;
|
||||||
Reference in New Issue
Block a user