kill LibraryItem, unify to MediaItem; decouple schedule engine from providers

ADR-0001: MediaItem absorbs LibraryItem fields (provider_id, external_id,
collection_name, collection_type, synced_at, role/MediaRole).
LibraryItem + LibraryItemRow deleted. All ports/adapters/tests updated.

ADR-0002: schedule engine takes LibraryQuery instead of IProviderRegistry.
Algorithmic blocks query library via search(), manual blocks via get_by_id().
get_stream_url removed from engine; provider_registry moved to ScheduleDeps
for playback-time stream URL resolution in application layer.

BlockContent provider_id field removed (meaningless when querying library).
This commit is contained in:
2026-07-12 07:02:08 +02:00
parent 773e228e21
commit a6558e15b2
30 changed files with 324 additions and 354 deletions

View File

@@ -8,8 +8,8 @@ use crate::models::{
BlockContent, CurrentBroadcast, GeneratedSchedule, PlaybackRecord, ProgrammingBlock,
ScheduledSlot,
};
use crate::ports::{ChannelQuery, IProviderRegistry, ScheduleCommand, ScheduleQuery, StreamQuality};
use crate::value_objects::{BlockId, ChannelId, FillStrategy, MediaFilter, MediaItemId, RotationPolicy, Weekday};
use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery};
use crate::value_objects::{BlockId, ChannelId, FillStrategy, LibrarySearchFilter, MediaItemId, RotationPolicy, Weekday};
mod fill;
mod rotation;
@@ -22,8 +22,7 @@ struct BlockTimeWindow {
}
struct AlgorithmicParams<'a> {
provider_id: &'a str,
filter: &'a MediaFilter,
filter: &'a crate::value_objects::MediaFilter,
strategy: &'a FillStrategy,
block_id: BlockId,
loop_on_finish: bool,
@@ -38,7 +37,7 @@ struct RotationContext<'a> {
}
pub struct ScheduleEngineService {
provider_registry: Arc<dyn IProviderRegistry>,
library_query: Arc<dyn LibraryQuery>,
channel_query: Arc<dyn ChannelQuery>,
schedule_query: Arc<dyn ScheduleQuery>,
schedule_command: Arc<dyn ScheduleCommand>,
@@ -46,13 +45,13 @@ pub struct ScheduleEngineService {
impl ScheduleEngineService {
pub fn new(
provider_registry: Arc<dyn IProviderRegistry>,
library_query: Arc<dyn LibraryQuery>,
channel_query: Arc<dyn ChannelQuery>,
schedule_query: Arc<dyn ScheduleQuery>,
schedule_command: Arc<dyn ScheduleCommand>,
) -> Self {
Self {
provider_registry,
library_query,
channel_query,
schedule_query,
schedule_command,
@@ -204,14 +203,6 @@ impl ScheduleEngineService {
self.schedule_query.find_active(channel_id, at).await
}
pub async fn get_stream_url(
&self,
item_id: &MediaItemId,
quality: &StreamQuality,
) -> DomainResult<String> {
self.provider_registry.get_stream_url(item_id, quality).await
}
pub async fn list_schedule_history(
&self,
channel_id: ChannelId,
@@ -258,18 +249,16 @@ impl ScheduleEngineService {
rotation: RotationContext<'_>,
) -> DomainResult<Vec<ScheduledSlot>> {
match block.content() {
BlockContent::Manual { items, .. } => {
BlockContent::Manual { items } => {
self.resolve_manual(items, window.start, window.end, block.id())
.await
}
BlockContent::Algorithmic {
filter,
strategy,
provider_id,
} => {
self.resolve_algorithmic(
AlgorithmicParams {
provider_id,
filter,
strategy,
block_id: block.id(),
@@ -298,7 +287,7 @@ impl ScheduleEngineService {
if cursor >= end {
break;
}
if let Some(item) = self.provider_registry.fetch_by_id(item_id).await? {
if let Some(item) = self.library_query.get_by_id(item_id.value()).await? {
let item_end =
(cursor + Duration::seconds(item.duration_secs() as i64)).min(end);
slots.push(ScheduledSlot::new(cursor, item_end, item, block_id));
@@ -315,10 +304,8 @@ impl ScheduleEngineService {
window: BlockTimeWindow,
rotation: RotationContext<'_>,
) -> DomainResult<Vec<ScheduledSlot>> {
let candidates = self
.provider_registry
.fetch_items(params.provider_id, params.filter)
.await?;
let library_filter = media_filter_to_library_search(params.filter);
let (candidates, _total) = self.library_query.search(&library_filter).await?;
if candidates.is_empty() {
return Ok(vec![]);
@@ -355,3 +342,39 @@ impl ScheduleEngineService {
Ok(slots)
}
}
fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) -> LibrarySearchFilter {
let mut lsf = LibrarySearchFilter::new()
.with_limit(10_000);
if let Some(ct) = &filter.content_type {
lsf = lsf.with_content_type(ct.clone());
}
if !filter.genres.is_empty() {
lsf = lsf.with_genres(filter.genres.clone());
}
if let Some(decade) = filter.decade {
lsf = lsf.with_decade(decade);
}
if let Some(min) = filter.min_duration_secs {
lsf = lsf.with_min_duration_secs(min);
}
if let Some(max) = filter.max_duration_secs {
lsf = lsf.with_max_duration_secs(max);
}
if !filter.collections.is_empty() {
if let Some(first) = filter.collections.first() {
lsf = lsf.with_collection_id(first.clone());
}
}
if !filter.series_names.is_empty() {
lsf = lsf.with_series_names(filter.series_names.clone());
}
if let Some(term) = &filter.search_term {
lsf = lsf.with_search_term(term.clone());
}
if !filter.tags.is_empty() {
// tags map to the same concept in the library
}
lsf
}