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:
@@ -342,7 +342,6 @@ impl ProgrammingBlock {
|
||||
content: BlockContent::Algorithmic {
|
||||
filter,
|
||||
strategy,
|
||||
provider_id: String::new(),
|
||||
},
|
||||
loop_on_finish: true,
|
||||
ignore_rotation_policy: false,
|
||||
@@ -362,7 +361,6 @@ impl ProgrammingBlock {
|
||||
duration_mins,
|
||||
content: BlockContent::Manual {
|
||||
items,
|
||||
provider_id: String::new(),
|
||||
},
|
||||
loop_on_finish: true,
|
||||
ignore_rotation_policy: false,
|
||||
@@ -403,14 +401,10 @@ impl ProgrammingBlock {
|
||||
pub enum BlockContent {
|
||||
Manual {
|
||||
items: Vec<MediaItemId>,
|
||||
#[serde(default)]
|
||||
provider_id: String,
|
||||
},
|
||||
Algorithmic {
|
||||
filter: MediaFilter,
|
||||
strategy: FillStrategy,
|
||||
#[serde(default)]
|
||||
provider_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,172 +1,5 @@
|
||||
use crate::value_objects::ContentType;
|
||||
|
||||
const SYNC_STATUS_RUNNING: &str = "running";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LibraryItem {
|
||||
id: String,
|
||||
provider_id: String,
|
||||
external_id: String,
|
||||
title: String,
|
||||
content_type: ContentType,
|
||||
duration_secs: u32,
|
||||
series_name: Option<String>,
|
||||
season_number: Option<u32>,
|
||||
episode_number: Option<u32>,
|
||||
year: Option<u16>,
|
||||
genres: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
collection_id: Option<String>,
|
||||
collection_name: Option<String>,
|
||||
collection_type: Option<String>,
|
||||
thumbnail_url: Option<String>,
|
||||
synced_at: String,
|
||||
}
|
||||
|
||||
pub struct LibraryItemRow {
|
||||
pub id: String,
|
||||
pub provider_id: String,
|
||||
pub external_id: String,
|
||||
pub title: String,
|
||||
pub content_type: ContentType,
|
||||
pub duration_secs: u32,
|
||||
pub series_name: Option<String>,
|
||||
pub season_number: Option<u32>,
|
||||
pub episode_number: Option<u32>,
|
||||
pub year: Option<u16>,
|
||||
pub genres: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub collection_id: Option<String>,
|
||||
pub collection_name: Option<String>,
|
||||
pub collection_type: Option<String>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub synced_at: String,
|
||||
}
|
||||
|
||||
impl LibraryItem {
|
||||
pub fn new(
|
||||
provider_id: impl Into<String>,
|
||||
external_id: impl Into<String>,
|
||||
title: impl Into<String>,
|
||||
content_type: ContentType,
|
||||
duration_secs: u32,
|
||||
synced_at: impl Into<String>,
|
||||
) -> Self {
|
||||
let provider_id = provider_id.into();
|
||||
let external_id = external_id.into();
|
||||
let id = format!("{}::{}", provider_id, external_id);
|
||||
Self {
|
||||
id,
|
||||
provider_id,
|
||||
external_id,
|
||||
title: title.into(),
|
||||
content_type,
|
||||
duration_secs,
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
year: None,
|
||||
genres: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
collection_id: None,
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
thumbnail_url: None,
|
||||
synced_at: synced_at.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(row: LibraryItemRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
provider_id: row.provider_id,
|
||||
external_id: row.external_id,
|
||||
title: row.title,
|
||||
content_type: row.content_type,
|
||||
duration_secs: row.duration_secs,
|
||||
series_name: row.series_name,
|
||||
season_number: row.season_number,
|
||||
episode_number: row.episode_number,
|
||||
year: row.year,
|
||||
genres: row.genres,
|
||||
tags: row.tags,
|
||||
collection_id: row.collection_id,
|
||||
collection_name: row.collection_name,
|
||||
collection_type: row.collection_type,
|
||||
thumbnail_url: row.thumbnail_url,
|
||||
synced_at: row.synced_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn provider_id(&self) -> &str {
|
||||
&self.provider_id
|
||||
}
|
||||
|
||||
pub fn external_id(&self) -> &str {
|
||||
&self.external_id
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn content_type(&self) -> &ContentType {
|
||||
&self.content_type
|
||||
}
|
||||
|
||||
pub fn duration_secs(&self) -> u32 {
|
||||
self.duration_secs
|
||||
}
|
||||
|
||||
pub fn series_name(&self) -> Option<&str> {
|
||||
self.series_name.as_deref()
|
||||
}
|
||||
|
||||
pub fn season_number(&self) -> Option<u32> {
|
||||
self.season_number
|
||||
}
|
||||
|
||||
pub fn episode_number(&self) -> Option<u32> {
|
||||
self.episode_number
|
||||
}
|
||||
|
||||
pub fn year(&self) -> Option<u16> {
|
||||
self.year
|
||||
}
|
||||
|
||||
pub fn genres(&self) -> &[String] {
|
||||
&self.genres
|
||||
}
|
||||
|
||||
pub fn tags(&self) -> &[String] {
|
||||
&self.tags
|
||||
}
|
||||
|
||||
pub fn collection_id(&self) -> Option<&str> {
|
||||
self.collection_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn collection_name(&self) -> Option<&str> {
|
||||
self.collection_name.as_deref()
|
||||
}
|
||||
|
||||
pub fn collection_type(&self) -> Option<&str> {
|
||||
self.collection_type.as_deref()
|
||||
}
|
||||
|
||||
pub fn thumbnail_url(&self) -> Option<&str> {
|
||||
self.thumbnail_url.as_deref()
|
||||
}
|
||||
|
||||
pub fn synced_at(&self) -> &str {
|
||||
&self.synced_at
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LibraryCollection {
|
||||
id: String,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::value_objects::{ChannelId, ContentType, MediaItemId, PlaybackRecordId};
|
||||
use crate::value_objects::{ChannelId, ContentType, MediaItemId, MediaRole, PlaybackRecordId};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaItem {
|
||||
@@ -10,14 +10,25 @@ pub struct MediaItem {
|
||||
content_type: ContentType,
|
||||
duration_secs: u32,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
genres: Vec<String>,
|
||||
year: Option<u16>,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
series_name: Option<String>,
|
||||
season_number: Option<u32>,
|
||||
episode_number: Option<u32>,
|
||||
thumbnail_url: Option<String>,
|
||||
collection_id: Option<String>,
|
||||
#[serde(default)]
|
||||
provider_id: String,
|
||||
#[serde(default)]
|
||||
external_id: String,
|
||||
collection_name: Option<String>,
|
||||
collection_type: Option<String>,
|
||||
synced_at: Option<String>,
|
||||
#[serde(default)]
|
||||
role: MediaRole,
|
||||
}
|
||||
|
||||
pub struct MediaItemRow {
|
||||
@@ -34,6 +45,12 @@ pub struct MediaItemRow {
|
||||
pub episode_number: Option<u32>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub collection_id: Option<String>,
|
||||
pub provider_id: String,
|
||||
pub external_id: String,
|
||||
pub collection_name: Option<String>,
|
||||
pub collection_type: Option<String>,
|
||||
pub synced_at: Option<String>,
|
||||
pub role: MediaRole,
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
@@ -57,6 +74,46 @@ impl MediaItem {
|
||||
episode_number: None,
|
||||
thumbnail_url: None,
|
||||
collection_id: None,
|
||||
provider_id: String::new(),
|
||||
external_id: String::new(),
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
synced_at: None,
|
||||
role: MediaRole::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_library(
|
||||
provider_id: impl Into<String>,
|
||||
external_id: impl Into<String>,
|
||||
title: impl Into<String>,
|
||||
content_type: ContentType,
|
||||
duration_secs: u32,
|
||||
synced_at: impl Into<String>,
|
||||
) -> Self {
|
||||
let provider_id = provider_id.into();
|
||||
let external_id = external_id.into();
|
||||
let id = MediaItemId::new(format!("{}::{}", provider_id, external_id));
|
||||
Self {
|
||||
id,
|
||||
title: title.into(),
|
||||
content_type,
|
||||
duration_secs,
|
||||
description: None,
|
||||
genres: Vec::new(),
|
||||
year: None,
|
||||
tags: Vec::new(),
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
thumbnail_url: None,
|
||||
collection_id: None,
|
||||
provider_id,
|
||||
external_id,
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
synced_at: Some(synced_at.into()),
|
||||
role: MediaRole::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +132,12 @@ impl MediaItem {
|
||||
episode_number: row.episode_number,
|
||||
thumbnail_url: row.thumbnail_url,
|
||||
collection_id: row.collection_id,
|
||||
provider_id: row.provider_id,
|
||||
external_id: row.external_id,
|
||||
collection_name: row.collection_name,
|
||||
collection_type: row.collection_type,
|
||||
synced_at: row.synced_at,
|
||||
role: row.role,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +192,30 @@ impl MediaItem {
|
||||
pub fn collection_id(&self) -> Option<&str> {
|
||||
self.collection_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn provider_id(&self) -> &str {
|
||||
&self.provider_id
|
||||
}
|
||||
|
||||
pub fn external_id(&self) -> &str {
|
||||
&self.external_id
|
||||
}
|
||||
|
||||
pub fn collection_name(&self) -> Option<&str> {
|
||||
self.collection_name.as_deref()
|
||||
}
|
||||
|
||||
pub fn collection_type(&self) -> Option<&str> {
|
||||
self.collection_type.as_deref()
|
||||
}
|
||||
|
||||
pub fn synced_at(&self) -> Option<&str> {
|
||||
self.synced_at.as_deref()
|
||||
}
|
||||
|
||||
pub fn role(&self) -> &MediaRole {
|
||||
&self.role
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -16,7 +16,7 @@ pub use channel::{
|
||||
pub use collections::{PageParams, Paginated};
|
||||
pub use config_snapshot::ChannelConfigSnapshot;
|
||||
pub use library::{
|
||||
LibraryCollection, LibraryItem, LibraryItemRow, LibrarySyncLogEntry, LibrarySyncResult,
|
||||
LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult,
|
||||
SeasonSummary, ShowSummary,
|
||||
};
|
||||
pub use media::{MediaItem, MediaItemRow, PlaybackRecord};
|
||||
|
||||
@@ -102,9 +102,8 @@ fn manual_block_creation() {
|
||||
let items = vec![MediaItemId::new("item1"), MediaItemId::new("item2")];
|
||||
let block = ProgrammingBlock::new_manual("Manual Block", t(20, 0), 60, items);
|
||||
match block.content() {
|
||||
BlockContent::Manual { items, provider_id } => {
|
||||
BlockContent::Manual { items } => {
|
||||
assert_eq!(items.len(), 2);
|
||||
assert!(provider_id.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Manual content"),
|
||||
}
|
||||
|
||||
@@ -1,51 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn library_item_new_generates_composite_id() {
|
||||
let item = LibraryItem::new("jellyfin", "abc123", "Test Movie", ContentType::Movie, 7200, "2026-03-19T00:00:00Z");
|
||||
assert_eq!(item.id(), "jellyfin::abc123");
|
||||
assert_eq!(item.provider_id(), "jellyfin");
|
||||
assert_eq!(item.external_id(), "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_item_new_defaults_optional_fields() {
|
||||
let item = LibraryItem::new("jf", "1", "Movie", ContentType::Movie, 3600, "2026-01-01");
|
||||
assert!(item.series_name().is_none());
|
||||
assert!(item.season_number().is_none());
|
||||
assert!(item.genres().is_empty());
|
||||
assert!(item.tags().is_empty());
|
||||
assert!(item.collection_id().is_none());
|
||||
assert!(item.thumbnail_url().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_item_from_persistence_all_fields() {
|
||||
let item = LibraryItem::from_persistence(LibraryItemRow {
|
||||
id: "jf::abc".into(),
|
||||
provider_id: "jf".into(),
|
||||
external_id: "abc".into(),
|
||||
title: "Breaking Bad S01E01".into(),
|
||||
content_type: ContentType::Episode,
|
||||
duration_secs: 2700,
|
||||
series_name: Some("Breaking Bad".into()),
|
||||
season_number: Some(1),
|
||||
episode_number: Some(1),
|
||||
year: Some(2008),
|
||||
genres: vec!["Drama".into()],
|
||||
tags: vec!["tv".into()],
|
||||
collection_id: Some("col-1".into()),
|
||||
collection_name: Some("TV Shows".into()),
|
||||
collection_type: Some("tvshows".into()),
|
||||
thumbnail_url: Some("http://thumb.jpg".into()),
|
||||
synced_at: "2026-03-19T00:00:00Z".into(),
|
||||
});
|
||||
assert_eq!(item.series_name(), Some("Breaking Bad"));
|
||||
assert_eq!(item.season_number(), Some(1));
|
||||
assert_eq!(item.year(), Some(2008));
|
||||
assert_eq!(item.collection_name(), Some("TV Shows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_collection_new_and_getters() {
|
||||
let col = LibraryCollection::new("col-1", "Movies");
|
||||
|
||||
@@ -15,6 +15,32 @@ fn media_item_new_defaults() {
|
||||
assert!(item.genres().is_empty());
|
||||
assert!(item.year().is_none());
|
||||
assert!(item.series_name().is_none());
|
||||
assert_eq!(item.provider_id(), "");
|
||||
assert_eq!(item.external_id(), "");
|
||||
assert!(item.synced_at().is_none());
|
||||
assert_eq!(item.role(), &MediaRole::Program);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_item_new_library_generates_composite_id() {
|
||||
let item = MediaItem::new_library("jellyfin", "abc123", "Test Movie", ContentType::Movie, 7200, "2026-03-19T00:00:00Z");
|
||||
assert_eq!(item.id().value(), "jellyfin::abc123");
|
||||
assert_eq!(item.provider_id(), "jellyfin");
|
||||
assert_eq!(item.external_id(), "abc123");
|
||||
assert_eq!(item.synced_at(), Some("2026-03-19T00:00:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_item_new_library_defaults_optional_fields() {
|
||||
let item = MediaItem::new_library("jf", "1", "Movie", ContentType::Movie, 3600, "2026-01-01");
|
||||
assert!(item.series_name().is_none());
|
||||
assert!(item.season_number().is_none());
|
||||
assert!(item.genres().is_empty());
|
||||
assert!(item.tags().is_empty());
|
||||
assert!(item.collection_id().is_none());
|
||||
assert!(item.thumbnail_url().is_none());
|
||||
assert!(item.collection_name().is_none());
|
||||
assert!(item.collection_type().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -33,6 +59,12 @@ fn media_item_from_persistence_round_trip() {
|
||||
episode_number: Some(1),
|
||||
thumbnail_url: Some("http://thumb.jpg".into()),
|
||||
collection_id: Some("col-1".into()),
|
||||
provider_id: "jf".into(),
|
||||
external_id: "abc".into(),
|
||||
collection_name: Some("TV Shows".into()),
|
||||
collection_type: Some("tvshows".into()),
|
||||
synced_at: Some("2026-03-19T00:00:00Z".into()),
|
||||
role: MediaRole::Program,
|
||||
});
|
||||
assert_eq!(item.title(), "Breaking Bad S01E01");
|
||||
assert_eq!(item.series_name(), Some("Breaking Bad"));
|
||||
@@ -40,6 +72,11 @@ fn media_item_from_persistence_round_trip() {
|
||||
assert_eq!(item.episode_number(), Some(1));
|
||||
assert_eq!(item.year(), Some(2008));
|
||||
assert_eq!(item.collection_id(), Some("col-1"));
|
||||
assert_eq!(item.provider_id(), "jf");
|
||||
assert_eq!(item.external_id(), "abc");
|
||||
assert_eq!(item.collection_name(), Some("TV Shows"));
|
||||
assert_eq!(item.collection_type(), Some("tvshows"));
|
||||
assert_eq!(item.synced_at(), Some("2026-03-19T00:00:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,7 @@ use async_trait::async_trait;
|
||||
|
||||
use crate::errors::DomainResult;
|
||||
use crate::models::{
|
||||
LibraryCollection, LibraryItem, LibrarySyncLogEntry, LibrarySyncResult,
|
||||
LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult, MediaItem,
|
||||
SeasonSummary, ShowSummary,
|
||||
};
|
||||
use crate::value_objects::{ContentType, LibrarySearchFilter};
|
||||
@@ -11,7 +11,7 @@ use super::media::IMediaProvider;
|
||||
|
||||
#[async_trait]
|
||||
pub trait LibraryCommand: Send + Sync {
|
||||
async fn upsert_items(&self, provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()>;
|
||||
async fn upsert_items(&self, provider_id: &str, items: Vec<MediaItem>) -> DomainResult<()>;
|
||||
|
||||
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>;
|
||||
|
||||
@@ -25,9 +25,9 @@ pub trait LibraryQuery: Send + Sync {
|
||||
async fn search(
|
||||
&self,
|
||||
filter: &LibrarySearchFilter,
|
||||
) -> DomainResult<(Vec<LibraryItem>, u32)>;
|
||||
) -> DomainResult<(Vec<MediaItem>, u32)>;
|
||||
|
||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>>;
|
||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<MediaItem>>;
|
||||
|
||||
async fn list_collections(
|
||||
&self,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use chrono::{DateTime, Utc};
|
||||
use crate::errors::DomainResult;
|
||||
use crate::models::{
|
||||
ActivityEvent, Channel, ChannelConfigSnapshot, GeneratedSchedule, LibraryCollection,
|
||||
LibraryItem, LibrarySyncLogEntry, LibrarySyncResult, PlaybackRecord, ProviderConfigRow,
|
||||
LibrarySyncLogEntry, LibrarySyncResult, MediaItem, PlaybackRecord, ProviderConfigRow,
|
||||
ScheduleConfig, SeasonSummary, ShowSummary,
|
||||
};
|
||||
use crate::ports::{
|
||||
@@ -367,7 +367,7 @@ impl ScheduleQuery for InMemoryScheduleRepository {
|
||||
}
|
||||
|
||||
pub struct InMemoryLibraryRepository {
|
||||
pub items: Mutex<HashMap<String, LibraryItem>>,
|
||||
pub items: Mutex<HashMap<String, MediaItem>>,
|
||||
pub sync_logs: Mutex<Vec<LibrarySyncLogEntry>>,
|
||||
next_log_id: Mutex<i64>,
|
||||
}
|
||||
@@ -390,10 +390,10 @@ impl Default for InMemoryLibraryRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl LibraryCommand for InMemoryLibraryRepository {
|
||||
async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> {
|
||||
async fn upsert_items(&self, _provider_id: &str, items: Vec<MediaItem>) -> DomainResult<()> {
|
||||
let mut store = self.items.lock().unwrap();
|
||||
for item in items {
|
||||
store.insert(item.id().to_string(), item);
|
||||
store.insert(item.id().value().to_string(), item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -443,7 +443,7 @@ impl LibraryQuery for InMemoryLibraryRepository {
|
||||
async fn search(
|
||||
&self,
|
||||
filter: &LibrarySearchFilter,
|
||||
) -> DomainResult<(Vec<LibraryItem>, u32)> {
|
||||
) -> DomainResult<(Vec<MediaItem>, u32)> {
|
||||
let store = self.items.lock().unwrap();
|
||||
let mut items: Vec<_> = store
|
||||
.values()
|
||||
@@ -476,7 +476,7 @@ impl LibraryQuery for InMemoryLibraryRepository {
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>> {
|
||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<MediaItem>> {
|
||||
Ok(self.items.lock().unwrap().get(id).cloned())
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,14 @@ impl Weekday {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MediaRole {
|
||||
#[default]
|
||||
Program,
|
||||
Interstitial,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/scheduling.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user