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:
@@ -1,4 +1,4 @@
|
|||||||
use domain::{ContentType, MediaItem, MediaItemId, MediaItemRow};
|
use domain::{ContentType, MediaItem, MediaItemId, MediaItemRow, MediaRole};
|
||||||
|
|
||||||
use crate::models::JellyfinItem;
|
use crate::models::JellyfinItem;
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
Some(MediaItem::from_persistence(MediaItemRow {
|
Some(MediaItem::from_persistence(MediaItemRow {
|
||||||
id: MediaItemId::new(item.id),
|
id: MediaItemId::new(&item.id),
|
||||||
title: item.name,
|
title: item.name,
|
||||||
content_type,
|
content_type,
|
||||||
duration_secs,
|
duration_secs,
|
||||||
@@ -30,5 +30,11 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
|
|||||||
episode_number: item.index_number,
|
episode_number: item.index_number,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
collection_id: None,
|
collection_id: None,
|
||||||
|
provider_id: String::new(),
|
||||||
|
external_id: item.id,
|
||||||
|
collection_name: None,
|
||||||
|
collection_type: None,
|
||||||
|
synced_at: None,
|
||||||
|
role: MediaRole::default(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
|||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
Collection, IMediaProvider, ProviderCapabilities, StreamQuality, StreamingProtocol,
|
Collection, IMediaProvider, ProviderCapabilities, StreamQuality, StreamingProtocol,
|
||||||
};
|
};
|
||||||
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, MediaItemRow};
|
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, MediaItemRow, MediaRole};
|
||||||
|
|
||||||
use crate::config::LocalFilesConfig;
|
use crate::config::LocalFilesConfig;
|
||||||
use crate::index::{decode_id, LocalIndex};
|
use crate::index::{decode_id, LocalIndex};
|
||||||
@@ -54,6 +54,12 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
|
|||||||
episode_number: None,
|
episode_number: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
collection_id: None,
|
collection_id: None,
|
||||||
|
provider_id: String::new(),
|
||||||
|
external_id: String::new(),
|
||||||
|
collection_name: None,
|
||||||
|
collection_type: None,
|
||||||
|
synced_at: None,
|
||||||
|
role: MediaRole::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ use sqlx::SqlitePool;
|
|||||||
use adapter_common::{content_type_str, parse_content_type, parse_genres_blob};
|
use adapter_common::{content_type_str, parse_content_type, parse_genres_blob};
|
||||||
use domain::{
|
use domain::{
|
||||||
ports::library::{LibraryCommand, LibraryQuery},
|
ports::library::{LibraryCommand, LibraryQuery},
|
||||||
ContentType, DomainError, DomainResult, LibraryCollection, LibraryItem,
|
ContentType, DomainError, DomainResult, LibraryCollection,
|
||||||
LibraryItemRow as DomainLibraryItemRow, LibrarySearchFilter, LibrarySyncLogEntry,
|
LibrarySearchFilter, LibrarySyncLogEntry,
|
||||||
LibrarySyncResult, SeasonSummary, ShowSummary,
|
LibrarySyncResult, MediaItem, MediaItemRow as DomainMediaItemRow,
|
||||||
|
MediaRole, SeasonSummary, ShowSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct SqliteLibraryRepository {
|
pub struct SqliteLibraryRepository {
|
||||||
@@ -41,14 +42,15 @@ struct LibraryItemRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LibraryItemRow {
|
impl LibraryItemRow {
|
||||||
fn into_library_item(self) -> LibraryItem {
|
fn into_media_item(self) -> MediaItem {
|
||||||
LibraryItem::from_persistence(DomainLibraryItemRow {
|
MediaItem::from_persistence(DomainMediaItemRow {
|
||||||
id: self.id,
|
id: domain::MediaItemId::new(&self.id),
|
||||||
provider_id: self.provider_id,
|
provider_id: self.provider_id,
|
||||||
external_id: self.external_id,
|
external_id: self.external_id,
|
||||||
title: self.title,
|
title: self.title,
|
||||||
content_type: parse_content_type(&self.content_type),
|
content_type: parse_content_type(&self.content_type),
|
||||||
duration_secs: self.duration_secs as u32,
|
duration_secs: self.duration_secs as u32,
|
||||||
|
description: None,
|
||||||
series_name: self.series_name,
|
series_name: self.series_name,
|
||||||
season_number: self.season_number.map(|n| n as u32),
|
season_number: self.season_number.map(|n| n as u32),
|
||||||
episode_number: self.episode_number.map(|n| n as u32),
|
episode_number: self.episode_number.map(|n| n as u32),
|
||||||
@@ -59,7 +61,8 @@ impl LibraryItemRow {
|
|||||||
collection_name: self.collection_name,
|
collection_name: self.collection_name,
|
||||||
collection_type: self.collection_type,
|
collection_type: self.collection_type,
|
||||||
thumbnail_url: self.thumbnail_url,
|
thumbnail_url: self.thumbnail_url,
|
||||||
synced_at: self.synced_at,
|
synced_at: Some(self.synced_at),
|
||||||
|
role: MediaRole::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +96,7 @@ struct SeasonSummaryRow {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LibraryCommand for SqliteLibraryRepository {
|
impl LibraryCommand for SqliteLibraryRepository {
|
||||||
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 tx = self
|
let mut tx = self
|
||||||
.pool
|
.pool
|
||||||
.begin()
|
.begin()
|
||||||
@@ -108,7 +111,7 @@ impl LibraryCommand for SqliteLibraryRepository {
|
|||||||
collection_id, collection_name, collection_type, thumbnail_url, synced_at)
|
collection_id, collection_name, collection_type, thumbnail_url, synced_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
)
|
)
|
||||||
.bind(item.id())
|
.bind(item.id().value())
|
||||||
.bind(item.provider_id())
|
.bind(item.provider_id())
|
||||||
.bind(item.external_id())
|
.bind(item.external_id())
|
||||||
.bind(item.title())
|
.bind(item.title())
|
||||||
@@ -124,7 +127,7 @@ impl LibraryCommand for SqliteLibraryRepository {
|
|||||||
.bind(item.collection_name())
|
.bind(item.collection_name())
|
||||||
.bind(item.collection_type())
|
.bind(item.collection_type())
|
||||||
.bind(item.thumbnail_url())
|
.bind(item.thumbnail_url())
|
||||||
.bind(item.synced_at())
|
.bind(item.synced_at().unwrap_or(""))
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
@@ -187,7 +190,7 @@ impl LibraryQuery for SqliteLibraryRepository {
|
|||||||
async fn search(
|
async fn search(
|
||||||
&self,
|
&self,
|
||||||
filter: &LibrarySearchFilter,
|
filter: &LibrarySearchFilter,
|
||||||
) -> DomainResult<(Vec<LibraryItem>, u32)> {
|
) -> DomainResult<(Vec<MediaItem>, u32)> {
|
||||||
let mut conditions: Vec<String> = vec![];
|
let mut conditions: Vec<String> = vec![];
|
||||||
|
|
||||||
if let Some(p) = filter.provider_id() {
|
if let Some(p) = filter.provider_id() {
|
||||||
@@ -263,19 +266,19 @@ impl LibraryQuery for SqliteLibraryRepository {
|
|||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(LibraryItemRow::into_library_item)
|
.map(LibraryItemRow::into_media_item)
|
||||||
.collect(),
|
.collect(),
|
||||||
total as u32,
|
total as u32,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>> {
|
async fn get_by_id(&self, id: &str) -> DomainResult<Option<MediaItem>> {
|
||||||
let row = sqlx::query_as::<_, LibraryItemRow>("SELECT * FROM library_items WHERE id = ?")
|
let row = sqlx::query_as::<_, LibraryItemRow>("SELECT * FROM library_items WHERE id = ?")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
Ok(row.map(LibraryItemRow::into_library_item))
|
Ok(row.map(LibraryItemRow::into_media_item))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_collections(
|
async fn list_collections(
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ pub struct LibraryItemResponse {
|
|||||||
pub collection_name: Option<String>,
|
pub collection_name: Option<String>,
|
||||||
pub collection_type: Option<String>,
|
pub collection_type: Option<String>,
|
||||||
pub thumbnail_url: Option<String>,
|
pub thumbnail_url: Option<String>,
|
||||||
pub synced_at: String,
|
pub synced_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<domain::LibraryItem> for LibraryItemResponse {
|
impl From<domain::MediaItem> for LibraryItemResponse {
|
||||||
fn from(i: domain::LibraryItem) -> Self {
|
fn from(i: domain::MediaItem) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: i.id().to_string(),
|
id: i.id().value().to_string(),
|
||||||
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(),
|
||||||
@@ -43,7 +43,7 @@ impl From<domain::LibraryItem> for LibraryItemResponse {
|
|||||||
collection_name: i.collection_name().map(|s| s.to_string()),
|
collection_name: i.collection_name().map(|s| s.to_string()),
|
||||||
collection_type: i.collection_type().map(|s| s.to_string()),
|
collection_type: i.collection_type().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()),
|
||||||
synced_at: i.synced_at().to_string(),
|
synced_at: i.synced_at().map(|s| s.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use domain::models::LibraryItem;
|
use domain::models::MediaItem;
|
||||||
use domain::DomainResult;
|
use domain::DomainResult;
|
||||||
|
|
||||||
use super::deps::LibraryQueryDeps;
|
use super::deps::LibraryQueryDeps;
|
||||||
@@ -7,7 +7,7 @@ use super::queries::GetItemQuery;
|
|||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
deps: &LibraryQueryDeps,
|
deps: &LibraryQueryDeps,
|
||||||
query: GetItemQuery,
|
query: GetItemQuery,
|
||||||
) -> DomainResult<Option<LibraryItem>> {
|
) -> DomainResult<Option<MediaItem>> {
|
||||||
deps.library_query.get_by_id(&query.item_id).await
|
deps.library_query.get_by_id(&query.item_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use domain::DomainResult;
|
use domain::DomainResult;
|
||||||
use domain::models::LibraryItem;
|
use domain::models::MediaItem;
|
||||||
use domain::value_objects::LibrarySearchFilter;
|
use domain::value_objects::LibrarySearchFilter;
|
||||||
|
|
||||||
use super::deps::LibraryQueryDeps;
|
use super::deps::LibraryQueryDeps;
|
||||||
@@ -9,7 +9,7 @@ use super::queries::SearchItemsQuery;
|
|||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
deps: &LibraryQueryDeps,
|
deps: &LibraryQueryDeps,
|
||||||
query: SearchItemsQuery,
|
query: SearchItemsQuery,
|
||||||
) -> DomainResult<(Vec<LibraryItem>, u32)> {
|
) -> DomainResult<(Vec<MediaItem>, u32)> {
|
||||||
let content_type = query
|
let content_type = query
|
||||||
.content_type
|
.content_type
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use domain::models::LibraryItem;
|
use domain::models::MediaItem;
|
||||||
use domain::value_objects::ContentType;
|
use domain::value_objects::ContentType;
|
||||||
|
|
||||||
use crate::library::get_item;
|
use crate::library::get_item;
|
||||||
@@ -8,11 +8,11 @@ use crate::library::queries::GetItemQuery;
|
|||||||
mod helpers;
|
mod helpers;
|
||||||
|
|
||||||
fn seed_item(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
fn seed_item(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||||
let item = LibraryItem::new("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01");
|
let item = MediaItem::new_library("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01");
|
||||||
repo.items
|
repo.items
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(item.id().to_string(), item);
|
.insert(item.id().value().to_string(), item);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use domain::models::{LibraryItem, LibraryItemRow};
|
use domain::models::{MediaItem, MediaItemRow};
|
||||||
use domain::value_objects::ContentType;
|
use domain::value_objects::{ContentType, MediaItemId, MediaRole};
|
||||||
|
|
||||||
use crate::library::list_collections;
|
use crate::library::list_collections;
|
||||||
use crate::library::queries::ListCollectionsQuery;
|
use crate::library::queries::ListCollectionsQuery;
|
||||||
@@ -10,13 +10,14 @@ mod helpers;
|
|||||||
fn seed_with_collections(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
fn seed_with_collections(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||||
let mut store = repo.items.lock().unwrap();
|
let mut store = repo.items.lock().unwrap();
|
||||||
|
|
||||||
let item = LibraryItem::from_persistence(LibraryItemRow {
|
let item = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: "test::m1".into(),
|
id: MediaItemId::new("test::m1"),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: "m1".into(),
|
external_id: "m1".into(),
|
||||||
title: "Die Hard".into(),
|
title: "Die Hard".into(),
|
||||||
content_type: ContentType::Movie,
|
content_type: ContentType::Movie,
|
||||||
duration_secs: 7800,
|
duration_secs: 7800,
|
||||||
|
description: None,
|
||||||
series_name: None,
|
series_name: None,
|
||||||
season_number: None,
|
season_number: None,
|
||||||
episode_number: None,
|
episode_number: None,
|
||||||
@@ -27,17 +28,19 @@ fn seed_with_collections(repo: &std::sync::Arc<domain::testing::InMemoryLibraryR
|
|||||||
collection_name: Some("Movies".into()),
|
collection_name: Some("Movies".into()),
|
||||||
collection_type: Some("movies".into()),
|
collection_type: Some("movies".into()),
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
});
|
});
|
||||||
store.insert(item.id().to_string(), item);
|
store.insert(item.id().value().to_string(), item);
|
||||||
|
|
||||||
let item2 = LibraryItem::from_persistence(LibraryItemRow {
|
let item2 = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: "test::e1".into(),
|
id: MediaItemId::new("test::e1"),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: "e1".into(),
|
external_id: "e1".into(),
|
||||||
title: "BB S01E01".into(),
|
title: "BB S01E01".into(),
|
||||||
content_type: ContentType::Episode,
|
content_type: ContentType::Episode,
|
||||||
duration_secs: 2700,
|
duration_secs: 2700,
|
||||||
|
description: None,
|
||||||
series_name: Some("Breaking Bad".into()),
|
series_name: Some("Breaking Bad".into()),
|
||||||
season_number: Some(1),
|
season_number: Some(1),
|
||||||
episode_number: Some(1),
|
episode_number: Some(1),
|
||||||
@@ -48,9 +51,10 @@ fn seed_with_collections(repo: &std::sync::Arc<domain::testing::InMemoryLibraryR
|
|||||||
collection_name: Some("TV Shows".into()),
|
collection_name: Some("TV Shows".into()),
|
||||||
collection_type: Some("tvshows".into()),
|
collection_type: Some("tvshows".into()),
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
});
|
});
|
||||||
store.insert(item2.id().to_string(), item2);
|
store.insert(item2.id().value().to_string(), item2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use domain::models::{LibraryItem, LibraryItemRow};
|
use domain::models::{MediaItem, MediaItemRow};
|
||||||
use domain::value_objects::ContentType;
|
use domain::value_objects::{ContentType, MediaItemId, MediaRole};
|
||||||
|
|
||||||
use crate::library::list_genres;
|
use crate::library::list_genres;
|
||||||
use crate::library::queries::ListGenresQuery;
|
use crate::library::queries::ListGenresQuery;
|
||||||
@@ -10,13 +10,14 @@ mod helpers;
|
|||||||
fn seed_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
fn seed_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||||
let mut store = repo.items.lock().unwrap();
|
let mut store = repo.items.lock().unwrap();
|
||||||
|
|
||||||
let item1 = LibraryItem::from_persistence(LibraryItemRow {
|
let item1 = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: "test::m1".into(),
|
id: MediaItemId::new("test::m1"),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: "m1".into(),
|
external_id: "m1".into(),
|
||||||
title: "Die Hard".into(),
|
title: "Die Hard".into(),
|
||||||
content_type: ContentType::Movie,
|
content_type: ContentType::Movie,
|
||||||
duration_secs: 7800,
|
duration_secs: 7800,
|
||||||
|
description: None,
|
||||||
series_name: None,
|
series_name: None,
|
||||||
season_number: None,
|
season_number: None,
|
||||||
episode_number: None,
|
episode_number: None,
|
||||||
@@ -27,15 +28,17 @@ fn seed_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryReposi
|
|||||||
collection_name: None,
|
collection_name: None,
|
||||||
collection_type: None,
|
collection_type: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
});
|
});
|
||||||
let item2 = LibraryItem::from_persistence(LibraryItemRow {
|
let item2 = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: "test::m2".into(),
|
id: MediaItemId::new("test::m2"),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: "m2".into(),
|
external_id: "m2".into(),
|
||||||
title: "Alien".into(),
|
title: "Alien".into(),
|
||||||
content_type: ContentType::Movie,
|
content_type: ContentType::Movie,
|
||||||
duration_secs: 7020,
|
duration_secs: 7020,
|
||||||
|
description: None,
|
||||||
series_name: None,
|
series_name: None,
|
||||||
season_number: None,
|
season_number: None,
|
||||||
episode_number: None,
|
episode_number: None,
|
||||||
@@ -46,11 +49,12 @@ fn seed_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryReposi
|
|||||||
collection_name: None,
|
collection_name: None,
|
||||||
collection_type: None,
|
collection_type: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
store.insert(item1.id().to_string(), item1);
|
store.insert(item1.id().value().to_string(), item1);
|
||||||
store.insert(item2.id().to_string(), item2);
|
store.insert(item2.id().value().to_string(), item2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use domain::models::{LibraryItem, LibraryItemRow};
|
use domain::models::{MediaItem, MediaItemRow};
|
||||||
use domain::value_objects::ContentType;
|
use domain::value_objects::{ContentType, MediaItemId, MediaRole};
|
||||||
|
|
||||||
use crate::library::list_seasons;
|
use crate::library::list_seasons;
|
||||||
use crate::library::queries::ListSeasonsQuery;
|
use crate::library::queries::ListSeasonsQuery;
|
||||||
@@ -11,13 +11,14 @@ fn seed_episodes(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepositor
|
|||||||
let mut store = repo.items.lock().unwrap();
|
let mut store = repo.items.lock().unwrap();
|
||||||
|
|
||||||
for (i, season) in [1u32, 1, 2, 2, 2, 3].iter().enumerate() {
|
for (i, season) in [1u32, 1, 2, 2, 2, 3].iter().enumerate() {
|
||||||
let item = LibraryItem::from_persistence(LibraryItemRow {
|
let item = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: format!("test::e{i}"),
|
id: MediaItemId::new(format!("test::e{i}")),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: format!("e{i}"),
|
external_id: format!("e{i}"),
|
||||||
title: format!("BB S{season:02}E{:02}", i + 1),
|
title: format!("BB S{season:02}E{:02}", i + 1),
|
||||||
content_type: ContentType::Episode,
|
content_type: ContentType::Episode,
|
||||||
duration_secs: 2700,
|
duration_secs: 2700,
|
||||||
|
description: None,
|
||||||
series_name: Some("Breaking Bad".into()),
|
series_name: Some("Breaking Bad".into()),
|
||||||
season_number: Some(*season),
|
season_number: Some(*season),
|
||||||
episode_number: Some(i as u32 + 1),
|
episode_number: Some(i as u32 + 1),
|
||||||
@@ -28,9 +29,10 @@ fn seed_episodes(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepositor
|
|||||||
collection_name: None,
|
collection_name: None,
|
||||||
collection_type: None,
|
collection_type: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
});
|
});
|
||||||
store.insert(item.id().to_string(), item);
|
store.insert(item.id().value().to_string(), item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use domain::models::{LibraryItem, LibraryItemRow};
|
use domain::models::{MediaItem, MediaItemRow};
|
||||||
use domain::value_objects::ContentType;
|
use domain::value_objects::{ContentType, MediaItemId, MediaRole};
|
||||||
|
|
||||||
use crate::library::list_shows;
|
use crate::library::list_shows;
|
||||||
use crate::library::queries::ListShowsQuery;
|
use crate::library::queries::ListShowsQuery;
|
||||||
@@ -20,13 +20,14 @@ fn seed_episodes(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepositor
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
{
|
{
|
||||||
let item = LibraryItem::from_persistence(LibraryItemRow {
|
let item = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: format!("test::e{i}"),
|
id: MediaItemId::new(format!("test::e{i}")),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: format!("e{i}"),
|
external_id: format!("e{i}"),
|
||||||
title: format!("{series} S{season:02}E{i:02}"),
|
title: format!("{series} S{season:02}E{i:02}"),
|
||||||
content_type: ContentType::Episode,
|
content_type: ContentType::Episode,
|
||||||
duration_secs: 2700,
|
duration_secs: 2700,
|
||||||
|
description: None,
|
||||||
series_name: Some(series.to_string()),
|
series_name: Some(series.to_string()),
|
||||||
season_number: Some(*season),
|
season_number: Some(*season),
|
||||||
episode_number: Some(i as u32 + 1),
|
episode_number: Some(i as u32 + 1),
|
||||||
@@ -37,9 +38,10 @@ fn seed_episodes(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepositor
|
|||||||
collection_name: None,
|
collection_name: None,
|
||||||
collection_type: None,
|
collection_type: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
});
|
});
|
||||||
store.insert(item.id().to_string(), item);
|
store.insert(item.id().value().to_string(), item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use domain::models::{LibraryItem, LibraryItemRow};
|
use domain::models::{MediaItem, MediaItemRow};
|
||||||
use domain::value_objects::ContentType;
|
use domain::value_objects::{ContentType, MediaItemId, MediaRole};
|
||||||
|
|
||||||
use crate::library::queries::SearchItemsQuery;
|
use crate::library::queries::SearchItemsQuery;
|
||||||
use crate::library::search;
|
use crate::library::search;
|
||||||
@@ -10,25 +10,26 @@ mod helpers;
|
|||||||
fn seed_items(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
fn seed_items(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||||
let mut store = repo.items.lock().unwrap();
|
let mut store = repo.items.lock().unwrap();
|
||||||
let items = vec![
|
let items = vec![
|
||||||
LibraryItem::new("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01"),
|
MediaItem::new_library("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01"),
|
||||||
LibraryItem::new("test", "m2", "Alien", ContentType::Movie, 7020, "2026-01-01"),
|
MediaItem::new_library("test", "m2", "Alien", ContentType::Movie, 7020, "2026-01-01"),
|
||||||
LibraryItem::new("test", "e1", "BB S01E01", ContentType::Episode, 2700, "2026-01-01"),
|
MediaItem::new_library("test", "e1", "BB S01E01", ContentType::Episode, 2700, "2026-01-01"),
|
||||||
];
|
];
|
||||||
for item in items {
|
for item in items {
|
||||||
store.insert(item.id().to_string(), item);
|
store.insert(item.id().value().to_string(), item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||||
let mut store = repo.items.lock().unwrap();
|
let mut store = repo.items.lock().unwrap();
|
||||||
|
|
||||||
let action = LibraryItem::from_persistence(LibraryItemRow {
|
let action = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: "test::m1".into(),
|
id: MediaItemId::new("test::m1"),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: "m1".into(),
|
external_id: "m1".into(),
|
||||||
title: "Die Hard".into(),
|
title: "Die Hard".into(),
|
||||||
content_type: ContentType::Movie,
|
content_type: ContentType::Movie,
|
||||||
duration_secs: 7800,
|
duration_secs: 7800,
|
||||||
|
description: None,
|
||||||
series_name: None,
|
series_name: None,
|
||||||
season_number: None,
|
season_number: None,
|
||||||
episode_number: None,
|
episode_number: None,
|
||||||
@@ -39,15 +40,17 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
|||||||
collection_name: None,
|
collection_name: None,
|
||||||
collection_type: None,
|
collection_type: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
});
|
});
|
||||||
let scifi = LibraryItem::from_persistence(LibraryItemRow {
|
let scifi = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: "test::m2".into(),
|
id: MediaItemId::new("test::m2"),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: "m2".into(),
|
external_id: "m2".into(),
|
||||||
title: "Alien".into(),
|
title: "Alien".into(),
|
||||||
content_type: ContentType::Movie,
|
content_type: ContentType::Movie,
|
||||||
duration_secs: 7020,
|
duration_secs: 7020,
|
||||||
|
description: None,
|
||||||
series_name: None,
|
series_name: None,
|
||||||
season_number: None,
|
season_number: None,
|
||||||
episode_number: None,
|
episode_number: None,
|
||||||
@@ -58,15 +61,17 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
|||||||
collection_name: None,
|
collection_name: None,
|
||||||
collection_type: None,
|
collection_type: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
});
|
});
|
||||||
let comedy = LibraryItem::from_persistence(LibraryItemRow {
|
let comedy = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: "test::m3".into(),
|
id: MediaItemId::new("test::m3"),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: "m3".into(),
|
external_id: "m3".into(),
|
||||||
title: "Airplane!".into(),
|
title: "Airplane!".into(),
|
||||||
content_type: ContentType::Movie,
|
content_type: ContentType::Movie,
|
||||||
duration_secs: 5280,
|
duration_secs: 5280,
|
||||||
|
description: None,
|
||||||
series_name: None,
|
series_name: None,
|
||||||
season_number: None,
|
season_number: None,
|
||||||
episode_number: None,
|
episode_number: None,
|
||||||
@@ -77,12 +82,13 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
|||||||
collection_name: None,
|
collection_name: None,
|
||||||
collection_type: None,
|
collection_type: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
store.insert(action.id().to_string(), action);
|
store.insert(action.id().value().to_string(), action);
|
||||||
store.insert(scifi.id().to_string(), scifi);
|
store.insert(scifi.id().value().to_string(), scifi);
|
||||||
store.insert(comedy.id().to_string(), comedy);
|
store.insert(comedy.id().value().to_string(), comedy);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{ChannelQuery, EventPublisher, ScheduleCommand, ScheduleQuery};
|
use domain::ports::{ChannelQuery, EventPublisher, IProviderRegistry, ScheduleCommand, ScheduleQuery};
|
||||||
use domain::ScheduleEngineService;
|
use domain::ScheduleEngineService;
|
||||||
|
|
||||||
pub struct ScheduleDeps {
|
pub struct ScheduleDeps {
|
||||||
@@ -9,4 +9,5 @@ pub struct ScheduleDeps {
|
|||||||
pub schedule_query: Arc<dyn ScheduleQuery>,
|
pub schedule_query: Arc<dyn ScheduleQuery>,
|
||||||
pub schedule_command: Arc<dyn ScheduleCommand>,
|
pub schedule_command: Arc<dyn ScheduleCommand>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
pub provider_registry: Arc<dyn IProviderRegistry>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub async fn execute(deps: &ScheduleDeps, query: GetStreamUrlQuery) -> DomainRes
|
|||||||
|
|
||||||
let item_id = broadcast.slot().item().id().clone();
|
let item_id = broadcast.slot().item().id().clone();
|
||||||
let url = deps
|
let url = deps
|
||||||
.schedule_engine
|
.provider_registry
|
||||||
.get_stream_url(&item_id, &StreamQuality::Direct)
|
.get_stream_url(&item_id, &StreamQuality::Direct)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Some(url))
|
Ok(Some(url))
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ use domain::ports::{
|
|||||||
Collection, IProviderRegistry, ProviderCapabilities,
|
Collection, IProviderRegistry, ProviderCapabilities,
|
||||||
SeriesSummary, StreamQuality, StreamingProtocol,
|
SeriesSummary, StreamQuality, StreamingProtocol,
|
||||||
};
|
};
|
||||||
use domain::testing::{InMemoryChannelRepository, InMemoryScheduleRepository, NoopEventPublisher};
|
use domain::testing::{InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository, NoopEventPublisher};
|
||||||
use domain::value_objects::{ContentType, MediaFilter, MediaItemId};
|
use domain::value_objects::{ContentType, MediaFilter, MediaItemId};
|
||||||
use domain::ScheduleEngineService;
|
use domain::ScheduleEngineService;
|
||||||
|
|
||||||
use crate::schedule::deps::ScheduleDeps;
|
use crate::schedule::deps::ScheduleDeps;
|
||||||
|
|
||||||
/// Minimal IProviderRegistry backed by a NoopMediaProvider.
|
|
||||||
pub(crate) struct TestProviderRegistry;
|
pub(crate) struct TestProviderRegistry;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -84,9 +83,6 @@ impl IProviderRegistry for TestProviderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build ScheduleDeps backed by InMemory repos and a test provider registry.
|
|
||||||
///
|
|
||||||
/// Returns the deps plus the underlying repos for test assertions.
|
|
||||||
pub(crate) fn make_schedule_deps() -> (
|
pub(crate) fn make_schedule_deps() -> (
|
||||||
ScheduleDeps,
|
ScheduleDeps,
|
||||||
Arc<InMemoryChannelRepository>,
|
Arc<InMemoryChannelRepository>,
|
||||||
@@ -94,10 +90,11 @@ pub(crate) fn make_schedule_deps() -> (
|
|||||||
) {
|
) {
|
||||||
let channel_repo = Arc::new(InMemoryChannelRepository::new());
|
let channel_repo = Arc::new(InMemoryChannelRepository::new());
|
||||||
let schedule_repo = Arc::new(InMemoryScheduleRepository::new());
|
let schedule_repo = Arc::new(InMemoryScheduleRepository::new());
|
||||||
|
let library_repo = Arc::new(InMemoryLibraryRepository::new());
|
||||||
let provider_registry = Arc::new(TestProviderRegistry);
|
let provider_registry = Arc::new(TestProviderRegistry);
|
||||||
|
|
||||||
let engine = Arc::new(ScheduleEngineService::new(
|
let engine = Arc::new(ScheduleEngineService::new(
|
||||||
provider_registry,
|
library_repo,
|
||||||
channel_repo.clone(),
|
channel_repo.clone(),
|
||||||
schedule_repo.clone(),
|
schedule_repo.clone(),
|
||||||
schedule_repo.clone(),
|
schedule_repo.clone(),
|
||||||
@@ -109,6 +106,7 @@ pub(crate) fn make_schedule_deps() -> (
|
|||||||
schedule_query: schedule_repo.clone(),
|
schedule_query: schedule_repo.clone(),
|
||||||
schedule_command: schedule_repo.clone(),
|
schedule_command: schedule_repo.clone(),
|
||||||
event_publisher: Arc::new(NoopEventPublisher::new()),
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
provider_registry,
|
||||||
};
|
};
|
||||||
|
|
||||||
(deps, channel_repo, schedule_repo)
|
(deps, channel_repo, schedule_repo)
|
||||||
|
|||||||
@@ -342,7 +342,6 @@ impl ProgrammingBlock {
|
|||||||
content: BlockContent::Algorithmic {
|
content: BlockContent::Algorithmic {
|
||||||
filter,
|
filter,
|
||||||
strategy,
|
strategy,
|
||||||
provider_id: String::new(),
|
|
||||||
},
|
},
|
||||||
loop_on_finish: true,
|
loop_on_finish: true,
|
||||||
ignore_rotation_policy: false,
|
ignore_rotation_policy: false,
|
||||||
@@ -362,7 +361,6 @@ impl ProgrammingBlock {
|
|||||||
duration_mins,
|
duration_mins,
|
||||||
content: BlockContent::Manual {
|
content: BlockContent::Manual {
|
||||||
items,
|
items,
|
||||||
provider_id: String::new(),
|
|
||||||
},
|
},
|
||||||
loop_on_finish: true,
|
loop_on_finish: true,
|
||||||
ignore_rotation_policy: false,
|
ignore_rotation_policy: false,
|
||||||
@@ -403,14 +401,10 @@ impl ProgrammingBlock {
|
|||||||
pub enum BlockContent {
|
pub enum BlockContent {
|
||||||
Manual {
|
Manual {
|
||||||
items: Vec<MediaItemId>,
|
items: Vec<MediaItemId>,
|
||||||
#[serde(default)]
|
|
||||||
provider_id: String,
|
|
||||||
},
|
},
|
||||||
Algorithmic {
|
Algorithmic {
|
||||||
filter: MediaFilter,
|
filter: MediaFilter,
|
||||||
strategy: FillStrategy,
|
strategy: FillStrategy,
|
||||||
#[serde(default)]
|
|
||||||
provider_id: String,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,172 +1,5 @@
|
|||||||
use crate::value_objects::ContentType;
|
|
||||||
|
|
||||||
const SYNC_STATUS_RUNNING: &str = "running";
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LibraryCollection {
|
pub struct LibraryCollection {
|
||||||
id: String,
|
id: String,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MediaItem {
|
pub struct MediaItem {
|
||||||
@@ -10,14 +10,25 @@ pub struct MediaItem {
|
|||||||
content_type: ContentType,
|
content_type: ContentType,
|
||||||
duration_secs: u32,
|
duration_secs: u32,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
genres: Vec<String>,
|
genres: Vec<String>,
|
||||||
year: Option<u16>,
|
year: Option<u16>,
|
||||||
|
#[serde(default)]
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
series_name: Option<String>,
|
series_name: Option<String>,
|
||||||
season_number: Option<u32>,
|
season_number: Option<u32>,
|
||||||
episode_number: Option<u32>,
|
episode_number: Option<u32>,
|
||||||
thumbnail_url: Option<String>,
|
thumbnail_url: Option<String>,
|
||||||
collection_id: 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 {
|
pub struct MediaItemRow {
|
||||||
@@ -34,6 +45,12 @@ pub struct MediaItemRow {
|
|||||||
pub episode_number: Option<u32>,
|
pub episode_number: Option<u32>,
|
||||||
pub thumbnail_url: Option<String>,
|
pub thumbnail_url: Option<String>,
|
||||||
pub collection_id: 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 {
|
impl MediaItem {
|
||||||
@@ -57,6 +74,46 @@ impl MediaItem {
|
|||||||
episode_number: None,
|
episode_number: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
collection_id: 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,
|
episode_number: row.episode_number,
|
||||||
thumbnail_url: row.thumbnail_url,
|
thumbnail_url: row.thumbnail_url,
|
||||||
collection_id: row.collection_id,
|
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> {
|
pub fn collection_id(&self) -> Option<&str> {
|
||||||
self.collection_id.as_deref()
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub use channel::{
|
|||||||
pub use collections::{PageParams, Paginated};
|
pub use collections::{PageParams, Paginated};
|
||||||
pub use config_snapshot::ChannelConfigSnapshot;
|
pub use config_snapshot::ChannelConfigSnapshot;
|
||||||
pub use library::{
|
pub use library::{
|
||||||
LibraryCollection, LibraryItem, LibraryItemRow, LibrarySyncLogEntry, LibrarySyncResult,
|
LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult,
|
||||||
SeasonSummary, ShowSummary,
|
SeasonSummary, ShowSummary,
|
||||||
};
|
};
|
||||||
pub use media::{MediaItem, MediaItemRow, PlaybackRecord};
|
pub use media::{MediaItem, MediaItemRow, PlaybackRecord};
|
||||||
|
|||||||
@@ -102,9 +102,8 @@ fn manual_block_creation() {
|
|||||||
let items = vec![MediaItemId::new("item1"), MediaItemId::new("item2")];
|
let items = vec![MediaItemId::new("item1"), MediaItemId::new("item2")];
|
||||||
let block = ProgrammingBlock::new_manual("Manual Block", t(20, 0), 60, items);
|
let block = ProgrammingBlock::new_manual("Manual Block", t(20, 0), 60, items);
|
||||||
match block.content() {
|
match block.content() {
|
||||||
BlockContent::Manual { items, provider_id } => {
|
BlockContent::Manual { items } => {
|
||||||
assert_eq!(items.len(), 2);
|
assert_eq!(items.len(), 2);
|
||||||
assert!(provider_id.is_empty());
|
|
||||||
}
|
}
|
||||||
_ => panic!("Expected Manual content"),
|
_ => panic!("Expected Manual content"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,5 @@
|
|||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn library_collection_new_and_getters() {
|
fn library_collection_new_and_getters() {
|
||||||
let col = LibraryCollection::new("col-1", "Movies");
|
let col = LibraryCollection::new("col-1", "Movies");
|
||||||
|
|||||||
@@ -15,6 +15,32 @@ fn media_item_new_defaults() {
|
|||||||
assert!(item.genres().is_empty());
|
assert!(item.genres().is_empty());
|
||||||
assert!(item.year().is_none());
|
assert!(item.year().is_none());
|
||||||
assert!(item.series_name().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]
|
#[test]
|
||||||
@@ -33,6 +59,12 @@ fn media_item_from_persistence_round_trip() {
|
|||||||
episode_number: Some(1),
|
episode_number: Some(1),
|
||||||
thumbnail_url: Some("http://thumb.jpg".into()),
|
thumbnail_url: Some("http://thumb.jpg".into()),
|
||||||
collection_id: Some("col-1".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.title(), "Breaking Bad S01E01");
|
||||||
assert_eq!(item.series_name(), Some("Breaking Bad"));
|
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.episode_number(), Some(1));
|
||||||
assert_eq!(item.year(), Some(2008));
|
assert_eq!(item.year(), Some(2008));
|
||||||
assert_eq!(item.collection_id(), Some("col-1"));
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use crate::errors::DomainResult;
|
use crate::errors::DomainResult;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
LibraryCollection, LibraryItem, LibrarySyncLogEntry, LibrarySyncResult,
|
LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult, MediaItem,
|
||||||
SeasonSummary, ShowSummary,
|
SeasonSummary, ShowSummary,
|
||||||
};
|
};
|
||||||
use crate::value_objects::{ContentType, LibrarySearchFilter};
|
use crate::value_objects::{ContentType, LibrarySearchFilter};
|
||||||
@@ -11,7 +11,7 @@ use super::media::IMediaProvider;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait LibraryCommand: Send + Sync {
|
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<()>;
|
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>;
|
||||||
|
|
||||||
@@ -25,9 +25,9 @@ pub trait LibraryQuery: Send + Sync {
|
|||||||
async fn search(
|
async fn search(
|
||||||
&self,
|
&self,
|
||||||
filter: &LibrarySearchFilter,
|
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(
|
async fn list_collections(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use crate::models::{
|
|||||||
BlockContent, CurrentBroadcast, GeneratedSchedule, PlaybackRecord, ProgrammingBlock,
|
BlockContent, CurrentBroadcast, GeneratedSchedule, PlaybackRecord, ProgrammingBlock,
|
||||||
ScheduledSlot,
|
ScheduledSlot,
|
||||||
};
|
};
|
||||||
use crate::ports::{ChannelQuery, IProviderRegistry, ScheduleCommand, ScheduleQuery, StreamQuality};
|
use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery};
|
||||||
use crate::value_objects::{BlockId, ChannelId, FillStrategy, MediaFilter, MediaItemId, RotationPolicy, Weekday};
|
use crate::value_objects::{BlockId, ChannelId, FillStrategy, LibrarySearchFilter, MediaItemId, RotationPolicy, Weekday};
|
||||||
|
|
||||||
mod fill;
|
mod fill;
|
||||||
mod rotation;
|
mod rotation;
|
||||||
@@ -22,8 +22,7 @@ struct BlockTimeWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AlgorithmicParams<'a> {
|
struct AlgorithmicParams<'a> {
|
||||||
provider_id: &'a str,
|
filter: &'a crate::value_objects::MediaFilter,
|
||||||
filter: &'a MediaFilter,
|
|
||||||
strategy: &'a FillStrategy,
|
strategy: &'a FillStrategy,
|
||||||
block_id: BlockId,
|
block_id: BlockId,
|
||||||
loop_on_finish: bool,
|
loop_on_finish: bool,
|
||||||
@@ -38,7 +37,7 @@ struct RotationContext<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct ScheduleEngineService {
|
pub struct ScheduleEngineService {
|
||||||
provider_registry: Arc<dyn IProviderRegistry>,
|
library_query: Arc<dyn LibraryQuery>,
|
||||||
channel_query: Arc<dyn ChannelQuery>,
|
channel_query: Arc<dyn ChannelQuery>,
|
||||||
schedule_query: Arc<dyn ScheduleQuery>,
|
schedule_query: Arc<dyn ScheduleQuery>,
|
||||||
schedule_command: Arc<dyn ScheduleCommand>,
|
schedule_command: Arc<dyn ScheduleCommand>,
|
||||||
@@ -46,13 +45,13 @@ pub struct ScheduleEngineService {
|
|||||||
|
|
||||||
impl ScheduleEngineService {
|
impl ScheduleEngineService {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
provider_registry: Arc<dyn IProviderRegistry>,
|
library_query: Arc<dyn LibraryQuery>,
|
||||||
channel_query: Arc<dyn ChannelQuery>,
|
channel_query: Arc<dyn ChannelQuery>,
|
||||||
schedule_query: Arc<dyn ScheduleQuery>,
|
schedule_query: Arc<dyn ScheduleQuery>,
|
||||||
schedule_command: Arc<dyn ScheduleCommand>,
|
schedule_command: Arc<dyn ScheduleCommand>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
provider_registry,
|
library_query,
|
||||||
channel_query,
|
channel_query,
|
||||||
schedule_query,
|
schedule_query,
|
||||||
schedule_command,
|
schedule_command,
|
||||||
@@ -204,14 +203,6 @@ impl ScheduleEngineService {
|
|||||||
self.schedule_query.find_active(channel_id, at).await
|
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(
|
pub async fn list_schedule_history(
|
||||||
&self,
|
&self,
|
||||||
channel_id: ChannelId,
|
channel_id: ChannelId,
|
||||||
@@ -258,18 +249,16 @@ impl ScheduleEngineService {
|
|||||||
rotation: RotationContext<'_>,
|
rotation: RotationContext<'_>,
|
||||||
) -> DomainResult<Vec<ScheduledSlot>> {
|
) -> DomainResult<Vec<ScheduledSlot>> {
|
||||||
match block.content() {
|
match block.content() {
|
||||||
BlockContent::Manual { items, .. } => {
|
BlockContent::Manual { items } => {
|
||||||
self.resolve_manual(items, window.start, window.end, block.id())
|
self.resolve_manual(items, window.start, window.end, block.id())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
BlockContent::Algorithmic {
|
BlockContent::Algorithmic {
|
||||||
filter,
|
filter,
|
||||||
strategy,
|
strategy,
|
||||||
provider_id,
|
|
||||||
} => {
|
} => {
|
||||||
self.resolve_algorithmic(
|
self.resolve_algorithmic(
|
||||||
AlgorithmicParams {
|
AlgorithmicParams {
|
||||||
provider_id,
|
|
||||||
filter,
|
filter,
|
||||||
strategy,
|
strategy,
|
||||||
block_id: block.id(),
|
block_id: block.id(),
|
||||||
@@ -298,7 +287,7 @@ impl ScheduleEngineService {
|
|||||||
if cursor >= end {
|
if cursor >= end {
|
||||||
break;
|
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 =
|
let item_end =
|
||||||
(cursor + Duration::seconds(item.duration_secs() as i64)).min(end);
|
(cursor + Duration::seconds(item.duration_secs() as i64)).min(end);
|
||||||
slots.push(ScheduledSlot::new(cursor, item_end, item, block_id));
|
slots.push(ScheduledSlot::new(cursor, item_end, item, block_id));
|
||||||
@@ -315,10 +304,8 @@ impl ScheduleEngineService {
|
|||||||
window: BlockTimeWindow,
|
window: BlockTimeWindow,
|
||||||
rotation: RotationContext<'_>,
|
rotation: RotationContext<'_>,
|
||||||
) -> DomainResult<Vec<ScheduledSlot>> {
|
) -> DomainResult<Vec<ScheduledSlot>> {
|
||||||
let candidates = self
|
let library_filter = media_filter_to_library_search(params.filter);
|
||||||
.provider_registry
|
let (candidates, _total) = self.library_query.search(&library_filter).await?;
|
||||||
.fetch_items(params.provider_id, params.filter)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
@@ -355,3 +342,39 @@ impl ScheduleEngineService {
|
|||||||
Ok(slots)
|
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::errors::DomainResult;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
ActivityEvent, Channel, ChannelConfigSnapshot, GeneratedSchedule, LibraryCollection,
|
ActivityEvent, Channel, ChannelConfigSnapshot, GeneratedSchedule, LibraryCollection,
|
||||||
LibraryItem, LibrarySyncLogEntry, LibrarySyncResult, PlaybackRecord, ProviderConfigRow,
|
LibrarySyncLogEntry, LibrarySyncResult, MediaItem, PlaybackRecord, ProviderConfigRow,
|
||||||
ScheduleConfig, SeasonSummary, ShowSummary,
|
ScheduleConfig, SeasonSummary, ShowSummary,
|
||||||
};
|
};
|
||||||
use crate::ports::{
|
use crate::ports::{
|
||||||
@@ -367,7 +367,7 @@ impl ScheduleQuery for InMemoryScheduleRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct InMemoryLibraryRepository {
|
pub struct InMemoryLibraryRepository {
|
||||||
pub items: Mutex<HashMap<String, LibraryItem>>,
|
pub items: Mutex<HashMap<String, MediaItem>>,
|
||||||
pub sync_logs: Mutex<Vec<LibrarySyncLogEntry>>,
|
pub sync_logs: Mutex<Vec<LibrarySyncLogEntry>>,
|
||||||
next_log_id: Mutex<i64>,
|
next_log_id: Mutex<i64>,
|
||||||
}
|
}
|
||||||
@@ -390,10 +390,10 @@ impl Default for InMemoryLibraryRepository {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LibraryCommand for InMemoryLibraryRepository {
|
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();
|
let mut store = self.items.lock().unwrap();
|
||||||
for item in items {
|
for item in items {
|
||||||
store.insert(item.id().to_string(), item);
|
store.insert(item.id().value().to_string(), item);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -443,7 +443,7 @@ impl LibraryQuery for InMemoryLibraryRepository {
|
|||||||
async fn search(
|
async fn search(
|
||||||
&self,
|
&self,
|
||||||
filter: &LibrarySearchFilter,
|
filter: &LibrarySearchFilter,
|
||||||
) -> DomainResult<(Vec<LibraryItem>, u32)> {
|
) -> DomainResult<(Vec<MediaItem>, u32)> {
|
||||||
let store = self.items.lock().unwrap();
|
let store = self.items.lock().unwrap();
|
||||||
let mut items: Vec<_> = store
|
let mut items: Vec<_> = store
|
||||||
.values()
|
.values()
|
||||||
@@ -476,7 +476,7 @@ impl LibraryQuery for InMemoryLibraryRepository {
|
|||||||
Ok((items, total))
|
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())
|
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)]
|
#[cfg(test)]
|
||||||
#[path = "tests/scheduling.rs"]
|
#[path = "tests/scheduling.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let event_publisher: Arc<dyn domain::ports::EventPublisher> = event_bus.clone();
|
let event_publisher: Arc<dyn domain::ports::EventPublisher> = event_bus.clone();
|
||||||
|
|
||||||
let schedule_engine = Arc::new(ScheduleEngineService::new(
|
let schedule_engine = Arc::new(ScheduleEngineService::new(
|
||||||
provider_registry.clone(),
|
wire.library_query.clone(),
|
||||||
wire.channel_query.clone(),
|
wire.channel_query.clone(),
|
||||||
wire.schedule_query.clone(),
|
wire.schedule_query.clone(),
|
||||||
wire.schedule_command.clone(),
|
wire.schedule_command.clone(),
|
||||||
@@ -70,6 +70,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
schedule_query: wire.schedule_query.clone(),
|
schedule_query: wire.schedule_query.clone(),
|
||||||
schedule_command: wire.schedule_command.clone(),
|
schedule_command: wire.schedule_command.clone(),
|
||||||
event_publisher: event_publisher.clone(),
|
event_publisher: event_publisher.clone(),
|
||||||
|
provider_registry: provider_registry.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let library_query_deps = Arc::new(application::library::LibraryQueryDeps {
|
let library_query_deps = Arc::new(application::library::LibraryQueryDeps {
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ impl KTvMcpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "Search media items. content_type: movie|episode|short. Returns JSON array of LibraryItem."
|
description = "Search media items. content_type: movie|episode|short. Returns JSON array of MediaItem."
|
||||||
)]
|
)]
|
||||||
async fn search_media(&self, #[tool(aggr)] p: SearchMediaParams) -> String {
|
async fn search_media(&self, #[tool(aggr)] p: SearchMediaParams) -> String {
|
||||||
library::search_media(
|
library::search_media(
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ pub async fn search_media(
|
|||||||
let dtos: Vec<LibraryItemDto> = items
|
let dtos: Vec<LibraryItemDto> = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|i| LibraryItemDto {
|
.map(|i| LibraryItemDto {
|
||||||
id: i.id().to_string(),
|
id: i.id().value().to_string(),
|
||||||
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(),
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub async fn build_app_state(config: Config) -> anyhow::Result<AppState> {
|
|||||||
build_library_sync(wire_output.library_command.clone());
|
build_library_sync(wire_output.library_command.clone());
|
||||||
|
|
||||||
let schedule_engine = Arc::new(ScheduleEngineService::new(
|
let schedule_engine = Arc::new(ScheduleEngineService::new(
|
||||||
provider_registry.clone(),
|
wire_output.library_query.clone(),
|
||||||
wire_output.channel_query.clone(),
|
wire_output.channel_query.clone(),
|
||||||
wire_output.schedule_query.clone(),
|
wire_output.schedule_query.clone(),
|
||||||
wire_output.schedule_command.clone(),
|
wire_output.schedule_command.clone(),
|
||||||
@@ -88,6 +88,7 @@ pub async fn build_app_state(config: Config) -> anyhow::Result<AppState> {
|
|||||||
schedule_query: wire_output.schedule_query.clone(),
|
schedule_query: wire_output.schedule_query.clone(),
|
||||||
schedule_command: wire_output.schedule_command.clone(),
|
schedule_command: wire_output.schedule_command.clone(),
|
||||||
event_publisher: event_publisher.clone(),
|
event_publisher: event_publisher.clone(),
|
||||||
|
provider_registry: provider_registry.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let library_command_deps = Arc::new(LibraryCommandDeps {
|
let library_command_deps = Arc::new(LibraryCommandDeps {
|
||||||
@@ -475,18 +476,18 @@ impl IProviderRegistry for SimpleProviderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn media_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> domain::LibraryItem {
|
fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> domain::MediaItem {
|
||||||
let external_id = item.id().value().to_string();
|
let external_id = item.id().value().to_string();
|
||||||
let id = format!("{}::{}", provider_id, external_id);
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
domain::LibraryItem::from_persistence(domain::LibraryItemRow {
|
domain::MediaItem::from_persistence(domain::MediaItemRow {
|
||||||
id,
|
id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)),
|
||||||
provider_id: provider_id.to_string(),
|
provider_id: provider_id.to_string(),
|
||||||
external_id,
|
external_id,
|
||||||
title: item.title().to_string(),
|
title: item.title().to_string(),
|
||||||
content_type: item.content_type().clone(),
|
content_type: item.content_type().clone(),
|
||||||
duration_secs: item.duration_secs(),
|
duration_secs: item.duration_secs(),
|
||||||
|
description: item.description().map(|s| s.to_string()),
|
||||||
series_name: item.series_name().map(|s| s.to_string()),
|
series_name: item.series_name().map(|s| s.to_string()),
|
||||||
season_number: item.season_number(),
|
season_number: item.season_number(),
|
||||||
episode_number: item.episode_number(),
|
episode_number: item.episode_number(),
|
||||||
@@ -497,7 +498,8 @@ fn media_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> dom
|
|||||||
collection_name: None,
|
collection_name: None,
|
||||||
collection_type: None,
|
collection_type: None,
|
||||||
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
|
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
|
||||||
synced_at: now,
|
synced_at: Some(now),
|
||||||
|
role: domain::MediaRole::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,9 +560,9 @@ impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
let library_items: Vec<domain::LibraryItem> = items
|
let library_items: Vec<domain::MediaItem> = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| media_item_to_library_item(item, provider_id))
|
.map(|item| provider_item_to_library_item(item, provider_id))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
|
|||||||
Reference in New Issue
Block a user