Compare commits
7 Commits
efd15c4f53
...
60524c56f7
| Author | SHA1 | Date | |
|---|---|---|---|
| 60524c56f7 | |||
| 39f5f99bfd | |||
| abcf69ce7e | |||
| 826e824b58 | |||
| e2393be635 | |||
| a6558e15b2 | |||
| 773e228e21 |
31
Cargo.lock
generated
31
Cargo.lock
generated
@@ -34,7 +34,11 @@ name = "adapter-event-publisher"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"domain",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -1804,9 +1808,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"domain",
|
||||
"dotenvy",
|
||||
"handlebars",
|
||||
"infra-wiring",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
@@ -3365,6 +3367,31 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "worker"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"adapter-auth",
|
||||
"adapter-event-publisher",
|
||||
"adapter-jellyfin",
|
||||
"adapter-local-files",
|
||||
"adapter-sqlite",
|
||||
"anyhow",
|
||||
"application",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"domain",
|
||||
"dotenvy",
|
||||
"handlebars",
|
||||
"infra-wiring",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common", "crates/adapters/sqlite", "crates/adapters/auth", "crates/adapters/jellyfin", "crates/adapters/local-files", "crates/adapters/event-publisher", "crates/presentation", "crates/mcp"]
|
||||
members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common", "crates/adapters/sqlite", "crates/adapters/auth", "crates/adapters/jellyfin", "crates/adapters/local-files", "crates/adapters/event-publisher", "crates/presentation", "crates/worker", "crates/mcp"]
|
||||
exclude = ["k-tv-backend", "k-tv-frontend"]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{DomainError, RecyclePolicy, ScheduleConfig, ScheduleConfigCompat};
|
||||
use domain::{DomainError, RotationPolicy, ScheduleConfig, ScheduleConfigCompat};
|
||||
use serde::de::DeserializeOwned;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -32,8 +32,8 @@ pub fn parse_schedule_config(json: &str) -> Result<ScheduleConfig, DomainError>
|
||||
Ok(ScheduleConfig::from(compat))
|
||||
}
|
||||
|
||||
pub fn parse_recycle_policy(json: &str) -> Result<RecyclePolicy, DomainError> {
|
||||
parse_json(json, "recycle_policy")
|
||||
pub fn parse_rotation_policy(json: &str) -> Result<RotationPolicy, DomainError> {
|
||||
parse_json(json, "rotation_policy")
|
||||
}
|
||||
|
||||
pub fn parse_enum_or_default<T: DeserializeOwned + Default>(value: String) -> T {
|
||||
@@ -151,9 +151,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recycle_policy_valid() {
|
||||
fn parse_rotation_policy_valid() {
|
||||
let json = r#"{"cooldown_days":7,"cooldown_generations":3,"min_available_ratio":0.3}"#;
|
||||
let policy = parse_recycle_policy(json).unwrap();
|
||||
let policy = parse_rotation_policy(json).unwrap();
|
||||
assert_eq!(policy.cooldown_days, Some(7));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,7 @@ domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
sqlx = { workspace = true, features = ["sqlite"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -1,42 +1,170 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::errors::{DomainError, DomainResult};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::events::{EventConsumer, EventPublisher};
|
||||
use tokio::sync::broadcast;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct ChannelEventBus {
|
||||
tx: broadcast::Sender<DomainEvent>,
|
||||
fn event_type_label(event: &DomainEvent) -> &'static str {
|
||||
match event {
|
||||
DomainEvent::BroadcastTransition { .. } => "broadcast_transition",
|
||||
DomainEvent::NoSignal { .. } => "no_signal",
|
||||
DomainEvent::ScheduleGenerated { .. } => "schedule_generated",
|
||||
DomainEvent::ChannelCreated { .. } => "channel_created",
|
||||
DomainEvent::ChannelUpdated { .. } => "channel_updated",
|
||||
DomainEvent::ChannelDeleted { .. } => "channel_deleted",
|
||||
DomainEvent::UserRegistered { .. } => "user_registered",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelEventBus {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self { tx }
|
||||
}
|
||||
pub struct SqliteEventPublisher {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
pub fn subscriber(&self) -> broadcast::Receiver<DomainEvent> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn sender(&self) -> broadcast::Sender<DomainEvent> {
|
||||
self.tx.clone()
|
||||
impl SqliteEventPublisher {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for ChannelEventBus {
|
||||
impl EventPublisher for SqliteEventPublisher {
|
||||
async fn publish(&self, event: DomainEvent) -> DomainResult<()> {
|
||||
let _ = self.tx.send(event);
|
||||
let event_type = event_type_label(&event);
|
||||
let payload = serde_json::to_string(&event)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("event serialize: {e}")))?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO event_queue (event_type, payload, status) VALUES (?, ?, 'pending')",
|
||||
)
|
||||
.bind(event_type)
|
||||
.bind(&payload)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventConsumer for ChannelEventBus {
|
||||
async fn recv(&self) -> DomainResult<DomainEvent> {
|
||||
let mut rx = self.tx.subscribe();
|
||||
rx.recv()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
pub struct SqliteEventConsumer {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteEventConsumer {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EventRow {
|
||||
id: i64,
|
||||
payload: String,
|
||||
retry_count: i32,
|
||||
created_at: String,
|
||||
max_retries: i32,
|
||||
event_type: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventConsumer for SqliteEventConsumer {
|
||||
async fn poll_next(&self) -> DomainResult<Option<EventEnvelope>> {
|
||||
let row: Option<EventRow> = sqlx::query_as(
|
||||
"SELECT id, event_type, payload, retry_count, created_at, max_retries \
|
||||
FROM event_queue WHERE status = 'pending' ORDER BY id ASC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => r,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
sqlx::query("UPDATE event_queue SET status = 'processing', updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(row.id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
match serde_json::from_str::<DomainEvent>(&row.payload) {
|
||||
Ok(event) => Ok(Some(EventEnvelope::from_persistence(
|
||||
row.id,
|
||||
event,
|
||||
row.retry_count as u32,
|
||||
row.created_at,
|
||||
))),
|
||||
Err(e) => {
|
||||
move_to_dlq(&self.pool, &row, &e.to_string()).await?;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ack(&self, event_id: i64) -> DomainResult<()> {
|
||||
sqlx::query("DELETE FROM event_queue WHERE id = ?")
|
||||
.bind(event_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn nack(&self, event_id: i64, error: &str) -> DomainResult<()> {
|
||||
let row: Option<EventRow> = sqlx::query_as(
|
||||
"SELECT id, event_type, payload, retry_count, created_at, max_retries FROM event_queue WHERE id = ?",
|
||||
)
|
||||
.bind(event_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let new_retry = row.retry_count + 1;
|
||||
if new_retry >= row.max_retries {
|
||||
move_to_dlq(&self.pool, &row, error).await?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
"UPDATE event_queue SET status = 'pending', retry_count = ?, error_message = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
)
|
||||
.bind(new_retry)
|
||||
.bind(error)
|
||||
.bind(event_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn move_to_dlq(pool: &SqlitePool, row: &EventRow, error: &str) -> DomainResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO dead_letter_queue (original_event_id, event_type, payload, error_message, retry_count, original_created_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(row.id)
|
||||
.bind(&row.event_type)
|
||||
.bind(&row.payload)
|
||||
.bind(error)
|
||||
.bind(row.retry_count)
|
||||
.bind(&row.created_at)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
sqlx::query("DELETE FROM event_queue WHERE id = ?")
|
||||
.bind(row.id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use domain::{ContentType, MediaItem, MediaItemId, MediaItemRow};
|
||||
use domain::{ContentType, MediaItem, MediaItemId, MediaItemRow, MediaRole};
|
||||
|
||||
use crate::models::JellyfinItem;
|
||||
|
||||
@@ -17,7 +17,7 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
|
||||
.unwrap_or(0);
|
||||
|
||||
Some(MediaItem::from_persistence(MediaItemRow {
|
||||
id: MediaItemId::new(item.id),
|
||||
id: MediaItemId::new(&item.id),
|
||||
title: item.name,
|
||||
content_type,
|
||||
duration_secs,
|
||||
@@ -30,5 +30,12 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
|
||||
episode_number: item.index_number,
|
||||
thumbnail_url: None,
|
||||
collection_id: None,
|
||||
provider_id: String::new(),
|
||||
external_id: item.id,
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
synced_at: None,
|
||||
role: MediaRole::default(),
|
||||
chapters: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -37,20 +37,6 @@ pub(crate) struct JellyfinItem {
|
||||
pub recursive_item_count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct JellyfinPlaybackInfoResponse {
|
||||
#[serde(rename = "MediaSources")]
|
||||
pub media_sources: Vec<JellyfinMediaSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct JellyfinMediaSource {
|
||||
#[serde(rename = "SupportsDirectStream")]
|
||||
pub supports_direct_stream: bool,
|
||||
#[serde(rename = "DirectStreamUrl")]
|
||||
pub direct_stream_url: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn jellyfin_item_type(ct: &ContentType) -> &'static str {
|
||||
match ct {
|
||||
ContentType::Movie => "Movie",
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ports::{
|
||||
Collection, IMediaProvider, ProviderCapabilities, SeriesSummary, StreamQuality,
|
||||
StreamingProtocol,
|
||||
};
|
||||
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId};
|
||||
use domain::ports::{Collection, IMediaProvider, ProviderCapabilities, SeriesSummary};
|
||||
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, SourceUri};
|
||||
|
||||
use crate::config::JellyfinConfig;
|
||||
use crate::mapping::{map_jellyfin_item, TICKS_PER_SEC};
|
||||
use crate::models::{
|
||||
jellyfin_item_type, JellyfinItemsResponse, JellyfinPlaybackInfoResponse,
|
||||
};
|
||||
|
||||
const FALLBACK_HLS_BITRATE: u32 = 8_000_000;
|
||||
use crate::models::{jellyfin_item_type, JellyfinItemsResponse};
|
||||
|
||||
pub struct JellyfinMediaProvider {
|
||||
client: reqwest::Client,
|
||||
@@ -129,16 +122,6 @@ impl JellyfinMediaProvider {
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn hls_url(&self, item_id: &MediaItemId, bitrate: u32) -> String {
|
||||
format!(
|
||||
"{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&VideoBitRate={}&mediaSourceId={}&SubtitleMethod=Hls&subtitleCodec=vtt&api_key={}",
|
||||
self.config.base_url,
|
||||
item_id.as_ref(),
|
||||
bitrate,
|
||||
item_id.as_ref(),
|
||||
self.config.api_key,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -151,9 +134,7 @@ impl IMediaProvider for JellyfinMediaProvider {
|
||||
tags: true,
|
||||
decade: true,
|
||||
search: true,
|
||||
streaming_protocol: StreamingProtocol::Hls,
|
||||
rescan: false,
|
||||
transcode: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,52 +331,14 @@ impl IMediaProvider for JellyfinMediaProvider {
|
||||
Ok(body.items.into_iter().map(|item| item.name).collect())
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
item_id: &MediaItemId,
|
||||
quality: &StreamQuality,
|
||||
) -> DomainResult<String> {
|
||||
match quality {
|
||||
StreamQuality::Direct => {
|
||||
let url = format!(
|
||||
"{}/Items/{}/PlaybackInfo",
|
||||
self.config.base_url,
|
||||
item_id.as_ref()
|
||||
);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-Emby-Token", &self.config.api_key)
|
||||
.query(&[
|
||||
("userId", &self.config.user_id),
|
||||
("mediaSourceId", &item_id.as_ref().to_string()),
|
||||
])
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("PlaybackInfo failed: {e}"))
|
||||
})?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
let info: JellyfinPlaybackInfoResponse = resp.json().await.map_err(|e| {
|
||||
DomainError::InfrastructureError(format!(
|
||||
"PlaybackInfo parse failed: {e}"
|
||||
))
|
||||
})?;
|
||||
if let Some(src) = info.media_sources.first()
|
||||
&& src.supports_direct_stream
|
||||
&& let Some(rel_url) = &src.direct_stream_url
|
||||
{
|
||||
return Ok(format!(
|
||||
"{}{}&api_key={}",
|
||||
self.config.base_url, rel_url, self.config.api_key
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(self.hls_url(item_id, FALLBACK_HLS_BITRATE))
|
||||
}
|
||||
StreamQuality::Transcode(bps) => Ok(self.hls_url(item_id, *bps)),
|
||||
}
|
||||
async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri> {
|
||||
Ok(SourceUri::NetworkUrl {
|
||||
url: format!(
|
||||
"{}/Videos/{}/stream?static=true&api_key={}",
|
||||
self.config.base_url,
|
||||
item_id.as_ref(),
|
||||
self.config.api_key,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ impl LocalFilesBundle {
|
||||
});
|
||||
|
||||
let provider =
|
||||
LocalFilesProvider::new(Arc::clone(&local_index), &config, transcode_manager.clone());
|
||||
LocalFilesProvider::new(Arc::clone(&local_index), &config);
|
||||
|
||||
Self {
|
||||
provider,
|
||||
|
||||
@@ -1,36 +1,23 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
Collection, IMediaProvider, ProviderCapabilities, StreamQuality, StreamingProtocol,
|
||||
};
|
||||
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, MediaItemRow};
|
||||
use domain::ports::{Collection, IMediaProvider, ProviderCapabilities};
|
||||
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, MediaItemRow, MediaRole, SourceUri};
|
||||
|
||||
use crate::config::LocalFilesConfig;
|
||||
use crate::index::{decode_id, LocalIndex};
|
||||
use crate::scanner::LocalFileItem;
|
||||
use crate::transcoder::TranscodeManager;
|
||||
|
||||
pub struct LocalFilesProvider {
|
||||
pub index: Arc<LocalIndex>,
|
||||
base_url: String,
|
||||
transcode_manager: Option<Arc<TranscodeManager>>,
|
||||
}
|
||||
|
||||
const SHORT_DURATION_SECS: u32 = 1200;
|
||||
const DECADE_SPAN: u16 = 9;
|
||||
|
||||
impl LocalFilesProvider {
|
||||
pub fn new(
|
||||
index: Arc<LocalIndex>,
|
||||
config: &LocalFilesConfig,
|
||||
transcode_manager: Option<Arc<TranscodeManager>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
index,
|
||||
base_url: config.base_url.trim_end_matches('/').to_string(),
|
||||
transcode_manager,
|
||||
}
|
||||
pub fn new(index: Arc<LocalIndex>, _config: &LocalFilesConfig) -> Self {
|
||||
Self { index }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +41,13 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> 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(),
|
||||
chapters: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,13 +61,7 @@ impl IMediaProvider for LocalFilesProvider {
|
||||
tags: true,
|
||||
decade: true,
|
||||
search: true,
|
||||
streaming_protocol: if self.transcode_manager.is_some() {
|
||||
StreamingProtocol::Hls
|
||||
} else {
|
||||
StreamingProtocol::DirectFile
|
||||
},
|
||||
rescan: true,
|
||||
transcode: self.transcode_manager.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,31 +137,14 @@ impl IMediaProvider for LocalFilesProvider {
|
||||
.map(|item| to_media_item(item_id.clone(), &item)))
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
item_id: &MediaItemId,
|
||||
quality: &StreamQuality,
|
||||
) -> DomainResult<String> {
|
||||
match quality {
|
||||
StreamQuality::Transcode(_) if self.transcode_manager.is_some() => {
|
||||
let tm = self.transcode_manager.as_ref().unwrap();
|
||||
let rel = decode_id(item_id).ok_or_else(|| {
|
||||
DomainError::InfrastructureError("invalid item id encoding".into())
|
||||
})?;
|
||||
let src = self.index.root_dir.join(&rel);
|
||||
tm.ensure_transcoded(item_id.as_ref(), &src).await?;
|
||||
Ok(format!(
|
||||
"{}/api/v1/files/transcode/{}/playlist.m3u8",
|
||||
self.base_url,
|
||||
item_id.as_ref()
|
||||
))
|
||||
}
|
||||
_ => Ok(format!(
|
||||
"{}/api/v1/files/stream/{}",
|
||||
self.base_url,
|
||||
item_id.as_ref()
|
||||
)),
|
||||
}
|
||||
async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri> {
|
||||
let rel = decode_id(item_id).ok_or_else(|| {
|
||||
DomainError::InfrastructureError("invalid item id encoding".into())
|
||||
})?;
|
||||
let abs_path = self.index.root_dir.join(&rel);
|
||||
Ok(SourceUri::FilePath {
|
||||
path: abs_path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
|
||||
|
||||
@@ -4,13 +4,13 @@ use sqlx::{Row, SqlitePool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use adapter_common::{
|
||||
map_sqlx_error, parse_dt, parse_enum_or_default, parse_recycle_policy, parse_schedule_config,
|
||||
map_sqlx_error, parse_dt, parse_enum_or_default, parse_rotation_policy, parse_schedule_config,
|
||||
parse_uuid, serialize_enum_as_string,
|
||||
};
|
||||
use domain::{
|
||||
ports::channel::{ChannelCommand, ChannelQuery},
|
||||
AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow,
|
||||
DomainError, DomainResult, LogoPosition, ScheduleConfig, SnapshotId, UserId,
|
||||
DomainError, DomainResult, LogoPosition, MediaFilter, ScheduleConfig, SnapshotId, UserId,
|
||||
};
|
||||
|
||||
pub struct SqliteChannelRepository {
|
||||
@@ -23,7 +23,7 @@ impl SqliteChannelRepository {
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, created_at, updated_at";
|
||||
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy AS rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, gap_filler, created_at, updated_at";
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct ChannelRow {
|
||||
@@ -33,10 +33,9 @@ struct ChannelRow {
|
||||
description: Option<String>,
|
||||
timezone: String,
|
||||
schedule_config: String,
|
||||
recycle_policy: String,
|
||||
rotation_policy: String,
|
||||
auto_schedule: i64,
|
||||
access_mode: String,
|
||||
access_password_hash: Option<String>,
|
||||
logo: Option<String>,
|
||||
logo_position: String,
|
||||
logo_opacity: f32,
|
||||
@@ -44,6 +43,7 @@ struct ChannelRow {
|
||||
webhook_poll_interval_secs: i64,
|
||||
webhook_body_template: Option<String>,
|
||||
webhook_headers: Option<String>,
|
||||
gap_filler: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
@@ -53,10 +53,15 @@ impl ChannelRow {
|
||||
let id = ChannelId::from_uuid(parse_uuid(&self.id, "channel id")?);
|
||||
let owner_id = UserId::from_uuid(parse_uuid(&self.owner_id, "owner id")?);
|
||||
let schedule_config = parse_schedule_config(&self.schedule_config)?;
|
||||
let recycle_policy = parse_recycle_policy(&self.recycle_policy)?;
|
||||
let rotation_policy = parse_rotation_policy(&self.rotation_policy)?;
|
||||
let access_mode: AccessMode = parse_enum_or_default(self.access_mode);
|
||||
let logo_position: LogoPosition = parse_enum_or_default(self.logo_position);
|
||||
|
||||
let gap_filler: Option<MediaFilter> = self
|
||||
.gap_filler
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok());
|
||||
|
||||
Ok(Channel::from_persistence(DomainChannelRow {
|
||||
id,
|
||||
owner_id,
|
||||
@@ -64,10 +69,9 @@ impl ChannelRow {
|
||||
description: self.description,
|
||||
timezone: self.timezone,
|
||||
schedule_config,
|
||||
recycle_policy,
|
||||
rotation_policy,
|
||||
auto_schedule: self.auto_schedule != 0,
|
||||
access_mode,
|
||||
access_password_hash: self.access_password_hash,
|
||||
logo: self.logo,
|
||||
logo_position,
|
||||
logo_opacity: self.logo_opacity,
|
||||
@@ -75,6 +79,7 @@ impl ChannelRow {
|
||||
webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32,
|
||||
webhook_body_template: self.webhook_body_template,
|
||||
webhook_headers: self.webhook_headers,
|
||||
gap_filler,
|
||||
created_at: parse_dt(&self.created_at)?,
|
||||
updated_at: parse_dt(&self.updated_at)?,
|
||||
}))
|
||||
@@ -109,18 +114,22 @@ impl ChannelCommand for SqliteChannelRepository {
|
||||
async fn save(&self, channel: &Channel) -> DomainResult<()> {
|
||||
let schedule_config = serde_json::to_string(channel.schedule_config())
|
||||
.map_err(|e| DomainError::RepositoryError(format!("serialize schedule_config: {e}")))?;
|
||||
let recycle_policy = serde_json::to_string(channel.recycle_policy())
|
||||
.map_err(|e| DomainError::RepositoryError(format!("serialize recycle_policy: {e}")))?;
|
||||
let rotation_policy = serde_json::to_string(channel.rotation_policy())
|
||||
.map_err(|e| DomainError::RepositoryError(format!("serialize rotation_policy: {e}")))?;
|
||||
let access_mode = serialize_enum_as_string(channel.access_mode(), "public");
|
||||
let logo_position = serialize_enum_as_string(channel.logo_position(), "top_right");
|
||||
|
||||
let gap_filler_json = channel
|
||||
.gap_filler()
|
||||
.map(|f| serde_json::to_string(f).unwrap_or_default());
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO channels
|
||||
(id, owner_id, name, description, timezone, schedule_config, recycle_policy,
|
||||
auto_schedule, access_mode, access_password_hash, logo, logo_position,
|
||||
auto_schedule, access_mode, logo, logo_position,
|
||||
logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template,
|
||||
webhook_headers, created_at, updated_at)
|
||||
webhook_headers, gap_filler, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
@@ -130,7 +139,6 @@ impl ChannelCommand for SqliteChannelRepository {
|
||||
recycle_policy = excluded.recycle_policy,
|
||||
auto_schedule = excluded.auto_schedule,
|
||||
access_mode = excluded.access_mode,
|
||||
access_password_hash = excluded.access_password_hash,
|
||||
logo = excluded.logo,
|
||||
logo_position = excluded.logo_position,
|
||||
logo_opacity = excluded.logo_opacity,
|
||||
@@ -138,6 +146,7 @@ impl ChannelCommand for SqliteChannelRepository {
|
||||
webhook_poll_interval_secs = excluded.webhook_poll_interval_secs,
|
||||
webhook_body_template = excluded.webhook_body_template,
|
||||
webhook_headers = excluded.webhook_headers,
|
||||
gap_filler = excluded.gap_filler,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
)
|
||||
@@ -147,10 +156,9 @@ impl ChannelCommand for SqliteChannelRepository {
|
||||
.bind(channel.description())
|
||||
.bind(channel.timezone())
|
||||
.bind(&schedule_config)
|
||||
.bind(&recycle_policy)
|
||||
.bind(&rotation_policy)
|
||||
.bind(channel.auto_schedule() as i64)
|
||||
.bind(&access_mode)
|
||||
.bind(channel.access_password_hash())
|
||||
.bind(channel.logo())
|
||||
.bind(&logo_position)
|
||||
.bind(channel.logo_opacity())
|
||||
@@ -158,6 +166,7 @@ impl ChannelCommand for SqliteChannelRepository {
|
||||
.bind(channel.webhook_poll_interval_secs() as i64)
|
||||
.bind(channel.webhook_body_template())
|
||||
.bind(channel.webhook_headers())
|
||||
.bind(&gap_filler_json)
|
||||
.bind(channel.created_at().to_rfc3339())
|
||||
.bind(channel.updated_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
|
||||
@@ -4,9 +4,10 @@ use sqlx::SqlitePool;
|
||||
use adapter_common::{content_type_str, parse_content_type, parse_genres_blob};
|
||||
use domain::{
|
||||
ports::library::{LibraryCommand, LibraryQuery},
|
||||
ContentType, DomainError, DomainResult, LibraryCollection, LibraryItem,
|
||||
LibraryItemRow as DomainLibraryItemRow, LibrarySearchFilter, LibrarySyncLogEntry,
|
||||
LibrarySyncResult, SeasonSummary, ShowSummary,
|
||||
ContentType, DomainError, DomainResult, LibraryCollection,
|
||||
LibrarySearchFilter, LibrarySyncLogEntry,
|
||||
LibrarySyncResult, MediaItem, MediaItemRow as DomainMediaItemRow,
|
||||
MediaRole, SeasonSummary, ShowSummary,
|
||||
};
|
||||
|
||||
pub struct SqliteLibraryRepository {
|
||||
@@ -38,17 +39,19 @@ struct LibraryItemRow {
|
||||
collection_type: Option<String>,
|
||||
thumbnail_url: Option<String>,
|
||||
synced_at: String,
|
||||
chapters: Option<String>,
|
||||
}
|
||||
|
||||
impl LibraryItemRow {
|
||||
fn into_library_item(self) -> LibraryItem {
|
||||
LibraryItem::from_persistence(DomainLibraryItemRow {
|
||||
id: self.id,
|
||||
fn into_media_item(self) -> MediaItem {
|
||||
MediaItem::from_persistence(DomainMediaItemRow {
|
||||
id: domain::MediaItemId::new(&self.id),
|
||||
provider_id: self.provider_id,
|
||||
external_id: self.external_id,
|
||||
title: self.title,
|
||||
content_type: parse_content_type(&self.content_type),
|
||||
duration_secs: self.duration_secs as u32,
|
||||
description: None,
|
||||
series_name: self.series_name,
|
||||
season_number: self.season_number.map(|n| n as u32),
|
||||
episode_number: self.episode_number.map(|n| n as u32),
|
||||
@@ -59,7 +62,13 @@ impl LibraryItemRow {
|
||||
collection_name: self.collection_name,
|
||||
collection_type: self.collection_type,
|
||||
thumbnail_url: self.thumbnail_url,
|
||||
synced_at: self.synced_at,
|
||||
synced_at: Some(self.synced_at),
|
||||
role: MediaRole::default(),
|
||||
chapters: self
|
||||
.chapters
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -93,7 +102,7 @@ struct SeasonSummaryRow {
|
||||
|
||||
#[async_trait]
|
||||
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
|
||||
.pool
|
||||
.begin()
|
||||
@@ -101,14 +110,20 @@ impl LibraryCommand for SqliteLibraryRepository {
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
for item in items {
|
||||
let chapters_json = if item.chapters().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::to_string(item.chapters()).unwrap_or_default())
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO library_items
|
||||
(id, provider_id, external_id, title, content_type, duration_secs,
|
||||
series_name, season_number, episode_number, year, genres, tags,
|
||||
collection_id, collection_name, collection_type, thumbnail_url, synced_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(item.id())
|
||||
.bind(item.id().value())
|
||||
.bind(item.provider_id())
|
||||
.bind(item.external_id())
|
||||
.bind(item.title())
|
||||
@@ -124,7 +139,8 @@ impl LibraryCommand for SqliteLibraryRepository {
|
||||
.bind(item.collection_name())
|
||||
.bind(item.collection_type())
|
||||
.bind(item.thumbnail_url())
|
||||
.bind(item.synced_at())
|
||||
.bind(item.synced_at().unwrap_or(""))
|
||||
.bind(&chapters_json)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
@@ -187,7 +203,7 @@ impl LibraryQuery for SqliteLibraryRepository {
|
||||
async fn search(
|
||||
&self,
|
||||
filter: &LibrarySearchFilter,
|
||||
) -> DomainResult<(Vec<LibraryItem>, u32)> {
|
||||
) -> DomainResult<(Vec<MediaItem>, u32)> {
|
||||
let mut conditions: Vec<String> = vec![];
|
||||
|
||||
if let Some(p) = filter.provider_id() {
|
||||
@@ -263,19 +279,19 @@ impl LibraryQuery for SqliteLibraryRepository {
|
||||
|
||||
Ok((
|
||||
rows.into_iter()
|
||||
.map(LibraryItemRow::into_library_item)
|
||||
.map(LibraryItemRow::into_media_item)
|
||||
.collect(),
|
||||
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 = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.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(
|
||||
|
||||
@@ -11,7 +11,6 @@ pub struct CreateChannelRequest {
|
||||
pub description: Option<String>,
|
||||
pub timezone: String,
|
||||
pub access_mode: Option<String>,
|
||||
pub access_password: Option<String>,
|
||||
pub webhook_url: Option<String>,
|
||||
pub webhook_poll_interval_secs: Option<u32>,
|
||||
pub webhook_body_template: Option<String>,
|
||||
@@ -26,10 +25,9 @@ pub struct UpdateChannelRequest {
|
||||
#[schema(value_type = Option<Object>)]
|
||||
pub schedule_config: Option<domain::models::ScheduleConfigCompat>,
|
||||
#[schema(value_type = Option<Object>)]
|
||||
pub recycle_policy: Option<domain::RecyclePolicy>,
|
||||
pub rotation_policy: Option<domain::RotationPolicy>,
|
||||
pub auto_schedule: Option<bool>,
|
||||
pub access_mode: Option<String>,
|
||||
pub access_password: Option<String>,
|
||||
pub logo: Option<Option<String>>,
|
||||
pub logo_position: Option<String>,
|
||||
pub logo_opacity: Option<f32>,
|
||||
@@ -47,7 +45,7 @@ pub struct ChannelResponse {
|
||||
pub description: Option<String>,
|
||||
pub timezone: String,
|
||||
pub schedule_config: serde_json::Value,
|
||||
pub recycle_policy: serde_json::Value,
|
||||
pub rotation_policy: serde_json::Value,
|
||||
pub auto_schedule: bool,
|
||||
pub access_mode: String,
|
||||
pub logo: Option<String>,
|
||||
@@ -70,7 +68,7 @@ impl From<domain::Channel> for ChannelResponse {
|
||||
description: c.description().map(|s| s.to_string()),
|
||||
timezone: c.timezone().to_string(),
|
||||
schedule_config: serde_json::to_value(c.schedule_config()).unwrap_or_default(),
|
||||
recycle_policy: serde_json::to_value(c.recycle_policy()).unwrap_or_default(),
|
||||
rotation_policy: serde_json::to_value(c.rotation_policy()).unwrap_or_default(),
|
||||
auto_schedule: c.auto_schedule(),
|
||||
access_mode: enum_to_string(c.access_mode()),
|
||||
logo: c.logo().map(|s| s.to_string()),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::common::enum_to_string;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ProviderCapabilitiesResponse {
|
||||
pub collections: bool,
|
||||
@@ -11,9 +9,7 @@ pub struct ProviderCapabilitiesResponse {
|
||||
pub tags: bool,
|
||||
pub decade: bool,
|
||||
pub search: bool,
|
||||
pub streaming_protocol: String,
|
||||
pub rescan: bool,
|
||||
pub transcode: bool,
|
||||
}
|
||||
|
||||
impl From<domain::ports::ProviderCapabilities> for ProviderCapabilitiesResponse {
|
||||
@@ -25,9 +21,7 @@ impl From<domain::ports::ProviderCapabilities> for ProviderCapabilitiesResponse
|
||||
tags: c.tags,
|
||||
decade: c.decade,
|
||||
search: c.search,
|
||||
streaming_protocol: enum_to_string(&c.streaming_protocol),
|
||||
rescan: c.rescan,
|
||||
transcode: c.transcode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ pub struct LibraryItemResponse {
|
||||
pub collection_name: Option<String>,
|
||||
pub collection_type: Option<String>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub synced_at: String,
|
||||
pub synced_at: Option<String>,
|
||||
}
|
||||
|
||||
impl From<domain::LibraryItem> for LibraryItemResponse {
|
||||
fn from(i: domain::LibraryItem) -> Self {
|
||||
impl From<domain::MediaItem> for LibraryItemResponse {
|
||||
fn from(i: domain::MediaItem) -> Self {
|
||||
Self {
|
||||
id: i.id().to_string(),
|
||||
id: i.id().value().to_string(),
|
||||
provider_id: i.provider_id().to_string(),
|
||||
external_id: i.external_id().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_type: i.collection_type().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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::common::enum_to_string;
|
||||
|
||||
const DEFAULT_ACCESS_MODE: &str = "public";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MediaItemResponse {
|
||||
pub id: String,
|
||||
@@ -47,8 +45,6 @@ pub struct SlotResponse {
|
||||
pub end_at: DateTime<Utc>,
|
||||
pub item: MediaItemResponse,
|
||||
pub source_block_id: Uuid,
|
||||
#[serde(default)]
|
||||
pub block_access_mode: String,
|
||||
}
|
||||
|
||||
impl From<domain::ScheduledSlot> for SlotResponse {
|
||||
@@ -59,26 +55,6 @@ impl From<domain::ScheduledSlot> for SlotResponse {
|
||||
end_at: s.end_at(),
|
||||
item: s.item().clone().into(),
|
||||
source_block_id: s.source_block_id().value(),
|
||||
block_access_mode: String::from(DEFAULT_ACCESS_MODE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SlotResponse {
|
||||
pub fn with_block_access(slot: domain::ScheduledSlot, channel: &domain::Channel) -> Self {
|
||||
let block_access_mode = channel
|
||||
.schedule_config()
|
||||
.all_blocks()
|
||||
.find(|b| b.id() == slot.source_block_id())
|
||||
.map(|b| enum_to_string(b.access_mode()))
|
||||
.unwrap_or_else(|| String::from(DEFAULT_ACCESS_MODE));
|
||||
Self {
|
||||
id: slot.id().value(),
|
||||
start_at: slot.start_at(),
|
||||
end_at: slot.end_at(),
|
||||
item: slot.item().clone().into(),
|
||||
source_block_id: slot.source_block_id().value(),
|
||||
block_access_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +63,6 @@ impl SlotResponse {
|
||||
pub struct CurrentBroadcastResponse {
|
||||
pub slot: SlotResponse,
|
||||
pub offset_secs: u32,
|
||||
pub block_access_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
use domain::models::ActivityEvent;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::AdminDeps;
|
||||
use super::queries::GetActivityLogQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &AdminDeps,
|
||||
query: GetActivityLogQuery,
|
||||
) -> DomainResult<Vec<ActivityEvent>> {
|
||||
deps.activity_query.recent(query.limit).await
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::AdminDeps;
|
||||
use super::queries::GetSettingsQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &AdminDeps,
|
||||
_query: GetSettingsQuery,
|
||||
) -> DomainResult<Vec<(String, String)>> {
|
||||
deps.settings_repo.get_all().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_settings.rs"]
|
||||
mod tests;
|
||||
@@ -1,10 +1,6 @@
|
||||
pub mod activity_log;
|
||||
pub mod commands;
|
||||
pub mod deps;
|
||||
pub mod get_settings;
|
||||
pub mod queries;
|
||||
pub mod update_settings;
|
||||
|
||||
pub use commands::UpdateSettingsCommand;
|
||||
pub use deps::AdminDeps;
|
||||
pub use queries::{GetActivityLogQuery, GetSettingsQuery};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
pub struct GetSettingsQuery;
|
||||
|
||||
pub struct GetActivityLogQuery {
|
||||
pub limit: u32,
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{InMemoryActivityLog, InMemoryAppSettings};
|
||||
|
||||
use crate::admin::commands::UpdateSettingsCommand;
|
||||
use crate::admin::deps::AdminDeps;
|
||||
use crate::admin::queries::GetSettingsQuery;
|
||||
use crate::admin::{get_settings, update_settings};
|
||||
|
||||
fn make_deps() -> AdminDeps {
|
||||
AdminDeps {
|
||||
settings_repo: Arc::new(InMemoryAppSettings::new()),
|
||||
activity_query: Arc::new(InMemoryActivityLog::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_empty_settings() {
|
||||
let deps = make_deps();
|
||||
let settings = get_settings::execute(&deps, GetSettingsQuery).await.unwrap();
|
||||
assert!(settings.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_returns_stored_settings() {
|
||||
let deps = make_deps();
|
||||
|
||||
update_settings::execute(
|
||||
&deps,
|
||||
UpdateSettingsCommand {
|
||||
settings: vec![
|
||||
("a".into(), "1".into()),
|
||||
("b".into(), "2".into()),
|
||||
],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let settings = get_settings::execute(&deps, GetSettingsQuery).await.unwrap();
|
||||
assert_eq!(settings.len(), 2);
|
||||
|
||||
let keys: Vec<&str> = settings.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert!(keys.contains(&"a"));
|
||||
assert!(keys.contains(&"b"));
|
||||
}
|
||||
@@ -121,10 +121,10 @@ async fn login_fails_for_unknown_email() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_fails_for_oidc_only_user() {
|
||||
async fn login_fails_for_user_without_password() {
|
||||
let repo = Arc::new(InMemoryUserRepository::new());
|
||||
let email = Email::new("oidc@example.com").unwrap();
|
||||
let user = domain::models::User::new("oidc|subject", email);
|
||||
let email = Email::new("external@example.com").unwrap();
|
||||
let user = domain::models::User::new("external|subject", email);
|
||||
repo.store.lock().unwrap().insert(user.id(), user);
|
||||
|
||||
let deps = AuthDeps {
|
||||
@@ -138,7 +138,7 @@ async fn login_fails_for_oidc_only_user() {
|
||||
let result = login::execute(
|
||||
&deps,
|
||||
LoginCommand {
|
||||
email: "oidc@example.com".into(),
|
||||
email: "external@example.com".into(),
|
||||
password: "password123".into(),
|
||||
remember_me: false,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use domain::models::ScheduleConfig;
|
||||
use domain::value_objects::{ChannelId, RecyclePolicy, UserId};
|
||||
use domain::value_objects::{ChannelId, RotationPolicy, UserId};
|
||||
|
||||
pub struct CreateChannelCommand {
|
||||
pub owner_id: UserId,
|
||||
@@ -14,7 +14,7 @@ pub struct UpdateChannelCommand {
|
||||
pub description: Option<Option<String>>,
|
||||
pub timezone: Option<String>,
|
||||
pub schedule_config: Option<ScheduleConfig>,
|
||||
pub recycle_policy: Option<RecyclePolicy>,
|
||||
pub rotation_policy: Option<RotationPolicy>,
|
||||
pub auto_schedule: Option<bool>,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,3 @@ pub struct ChannelCommandDeps {
|
||||
pub channel_query: Arc<dyn ChannelQuery>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct ChannelQueryDeps {
|
||||
pub channel_query: Arc<dyn ChannelQuery>,
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
use domain::models::Channel;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::ChannelQueryDeps;
|
||||
use super::queries::GetChannelQuery;
|
||||
|
||||
pub async fn execute(deps: &ChannelQueryDeps, query: GetChannelQuery) -> DomainResult<Option<Channel>> {
|
||||
deps.channel_query.find_by_id(query.channel_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get.rs"]
|
||||
mod tests;
|
||||
@@ -1,13 +0,0 @@
|
||||
use domain::models::Channel;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::ChannelQueryDeps;
|
||||
use super::queries::ListChannelsQuery;
|
||||
|
||||
pub async fn execute(deps: &ChannelQueryDeps, _query: ListChannelsQuery) -> DomainResult<Vec<Channel>> {
|
||||
deps.channel_query.find_all().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list.rs"]
|
||||
mod tests;
|
||||
@@ -1,13 +0,0 @@
|
||||
use domain::models::Channel;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::ChannelQueryDeps;
|
||||
use super::queries::ListByOwnerQuery;
|
||||
|
||||
pub async fn execute(deps: &ChannelQueryDeps, query: ListByOwnerQuery) -> DomainResult<Vec<Channel>> {
|
||||
deps.channel_query.find_by_owner(query.owner_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list_by_owner.rs"]
|
||||
mod tests;
|
||||
@@ -2,15 +2,10 @@ pub mod commands;
|
||||
pub mod create;
|
||||
pub mod delete;
|
||||
pub mod deps;
|
||||
pub mod get;
|
||||
pub mod list;
|
||||
pub mod list_by_owner;
|
||||
pub mod queries;
|
||||
pub mod update;
|
||||
|
||||
pub use commands::{CreateChannelCommand, DeleteChannelCommand, UpdateChannelCommand};
|
||||
pub use deps::{ChannelCommandDeps, ChannelQueryDeps};
|
||||
pub use queries::{GetChannelQuery, ListByOwnerQuery, ListChannelsQuery};
|
||||
pub use deps::ChannelCommandDeps;
|
||||
|
||||
use domain::models::Channel;
|
||||
use domain::value_objects::{ChannelId, UserId};
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
use domain::value_objects::{ChannelId, UserId};
|
||||
|
||||
pub struct GetChannelQuery {
|
||||
pub channel_id: ChannelId,
|
||||
}
|
||||
|
||||
pub struct ListChannelsQuery;
|
||||
|
||||
pub struct ListByOwnerQuery {
|
||||
pub owner_id: UserId,
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
|
||||
use domain::value_objects::{ChannelId, UserId};
|
||||
|
||||
use crate::channels::commands::CreateChannelCommand;
|
||||
use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps};
|
||||
use crate::channels::queries::GetChannelQuery;
|
||||
use crate::channels::{create, get};
|
||||
|
||||
fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) {
|
||||
let repo = Arc::new(InMemoryChannelRepository::new());
|
||||
let cmd_deps = ChannelCommandDeps {
|
||||
channel_command: repo.clone(),
|
||||
channel_query: repo.clone(),
|
||||
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||
};
|
||||
let query_deps = ChannelQueryDeps {
|
||||
channel_query: repo,
|
||||
};
|
||||
(cmd_deps, query_deps)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_existing_channel() {
|
||||
let (cmd_deps, query_deps) = make_deps();
|
||||
|
||||
let channel = create::execute(
|
||||
&cmd_deps,
|
||||
CreateChannelCommand {
|
||||
owner_id: UserId::generate(),
|
||||
name: "Findable".into(),
|
||||
timezone: "UTC".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let found = get::execute(
|
||||
&query_deps,
|
||||
GetChannelQuery {
|
||||
channel_id: channel.id(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().name(), "Findable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_nonexistent_returns_none() {
|
||||
let (_, query_deps) = make_deps();
|
||||
|
||||
let found = get::execute(
|
||||
&query_deps,
|
||||
GetChannelQuery {
|
||||
channel_id: ChannelId::generate(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(found.is_none());
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
use crate::channels::commands::CreateChannelCommand;
|
||||
use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps};
|
||||
use crate::channels::queries::ListChannelsQuery;
|
||||
use crate::channels::{create, list};
|
||||
|
||||
fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) {
|
||||
let repo = Arc::new(InMemoryChannelRepository::new());
|
||||
let cmd_deps = ChannelCommandDeps {
|
||||
channel_command: repo.clone(),
|
||||
channel_query: repo.clone(),
|
||||
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||
};
|
||||
let query_deps = ChannelQueryDeps {
|
||||
channel_query: repo,
|
||||
};
|
||||
(cmd_deps, query_deps)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_empty_returns_empty() {
|
||||
let (_, query_deps) = make_deps();
|
||||
|
||||
let channels = list::execute(&query_deps, ListChannelsQuery).await.unwrap();
|
||||
assert!(channels.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_returns_all_channels() {
|
||||
let (cmd_deps, query_deps) = make_deps();
|
||||
|
||||
for name in ["A", "B", "C"] {
|
||||
create::execute(
|
||||
&cmd_deps,
|
||||
CreateChannelCommand {
|
||||
owner_id: UserId::generate(),
|
||||
name: name.into(),
|
||||
timezone: "UTC".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let channels = list::execute(&query_deps, ListChannelsQuery).await.unwrap();
|
||||
assert_eq!(channels.len(), 3);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
use crate::channels::commands::CreateChannelCommand;
|
||||
use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps};
|
||||
use crate::channels::queries::ListByOwnerQuery;
|
||||
use crate::channels::{create, list_by_owner};
|
||||
|
||||
fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) {
|
||||
let repo = Arc::new(InMemoryChannelRepository::new());
|
||||
let cmd_deps = ChannelCommandDeps {
|
||||
channel_command: repo.clone(),
|
||||
channel_query: repo.clone(),
|
||||
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||
};
|
||||
let query_deps = ChannelQueryDeps {
|
||||
channel_query: repo,
|
||||
};
|
||||
(cmd_deps, query_deps)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filters_by_owner() {
|
||||
let (cmd_deps, query_deps) = make_deps();
|
||||
let alice = UserId::generate();
|
||||
let bob = UserId::generate();
|
||||
|
||||
// Alice: 2 channels
|
||||
for name in ["Alice-1", "Alice-2"] {
|
||||
create::execute(
|
||||
&cmd_deps,
|
||||
CreateChannelCommand {
|
||||
owner_id: alice,
|
||||
name: name.into(),
|
||||
timezone: "UTC".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Bob: 1 channel
|
||||
create::execute(
|
||||
&cmd_deps,
|
||||
CreateChannelCommand {
|
||||
owner_id: bob,
|
||||
name: "Bob-1".into(),
|
||||
timezone: "UTC".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let alice_channels = list_by_owner::execute(
|
||||
&query_deps,
|
||||
ListByOwnerQuery {
|
||||
owner_id: alice,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(alice_channels.len(), 2);
|
||||
assert!(alice_channels.iter().all(|c| c.owner_id() == alice));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_channels_returns_empty() {
|
||||
let (_, query_deps) = make_deps();
|
||||
|
||||
let channels = list_by_owner::execute(
|
||||
&query_deps,
|
||||
ListByOwnerQuery {
|
||||
owner_id: UserId::generate(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(channels.is_empty());
|
||||
}
|
||||
@@ -43,7 +43,7 @@ async fn updates_channel_name() {
|
||||
description: None,
|
||||
timezone: None,
|
||||
schedule_config: None,
|
||||
recycle_policy: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
},
|
||||
)
|
||||
@@ -80,7 +80,7 @@ async fn update_fails_if_not_owner() {
|
||||
description: None,
|
||||
timezone: None,
|
||||
schedule_config: None,
|
||||
recycle_policy: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
},
|
||||
)
|
||||
@@ -106,7 +106,7 @@ async fn update_nonexistent_channel_returns_not_found() {
|
||||
description: None,
|
||||
timezone: None,
|
||||
schedule_config: None,
|
||||
recycle_policy: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
},
|
||||
)
|
||||
@@ -146,7 +146,7 @@ async fn update_config_creates_snapshot() {
|
||||
description: None,
|
||||
timezone: None,
|
||||
schedule_config: Some(new_config),
|
||||
recycle_policy: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
},
|
||||
)
|
||||
@@ -185,7 +185,7 @@ async fn update_without_config_skips_snapshot() {
|
||||
description: None,
|
||||
timezone: None,
|
||||
schedule_config: None,
|
||||
recycle_policy: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
},
|
||||
)
|
||||
@@ -223,7 +223,7 @@ async fn update_description_clear() {
|
||||
description: Some(Some("A description".into())),
|
||||
timezone: None,
|
||||
schedule_config: None,
|
||||
recycle_policy: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
},
|
||||
)
|
||||
@@ -241,7 +241,7 @@ async fn update_description_clear() {
|
||||
description: Some(None),
|
||||
timezone: None,
|
||||
schedule_config: None,
|
||||
recycle_policy: None,
|
||||
rotation_policy: None,
|
||||
auto_schedule: None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -29,8 +29,8 @@ pub async fn execute(deps: &ChannelCommandDeps, cmd: UpdateChannelCommand) -> Do
|
||||
if let Some(config) = cmd.schedule_config {
|
||||
channel.set_schedule_config(config);
|
||||
}
|
||||
if let Some(policy) = cmd.recycle_policy {
|
||||
channel.set_recycle_policy(policy);
|
||||
if let Some(policy) = cmd.rotation_policy {
|
||||
channel.set_rotation_policy(policy);
|
||||
}
|
||||
if let Some(auto) = cmd.auto_schedule {
|
||||
channel.set_auto_schedule(auto);
|
||||
|
||||
@@ -41,9 +41,7 @@ pub fn execute(deps: &ConfigDeps, _query: GetConfigQuery) -> SystemConfig {
|
||||
tags: false,
|
||||
decade: false,
|
||||
search: false,
|
||||
streaming_protocol: domain::ports::StreamingProtocol::DirectFile,
|
||||
rescan: false,
|
||||
transcode: false,
|
||||
});
|
||||
|
||||
SystemConfig {
|
||||
|
||||
@@ -5,12 +5,6 @@ pub struct SaveSnapshotCommand {
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
pub struct PatchLabelCommand {
|
||||
pub channel_id: ChannelId,
|
||||
pub snapshot_id: SnapshotId,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
pub struct RestoreSnapshotCommand {
|
||||
pub channel_id: ChannelId,
|
||||
pub snapshot_id: SnapshotId,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
use domain::models::ChannelConfigSnapshot;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::ConfigSnapshotDeps;
|
||||
use super::queries::GetSnapshotQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &ConfigSnapshotDeps,
|
||||
query: GetSnapshotQuery,
|
||||
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
||||
deps.channel_query
|
||||
.get_config_snapshot(query.channel_id, query.snapshot_id)
|
||||
.await
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
use domain::models::ChannelConfigSnapshot;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::ConfigSnapshotDeps;
|
||||
use super::queries::ListSnapshotsQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &ConfigSnapshotDeps,
|
||||
query: ListSnapshotsQuery,
|
||||
) -> DomainResult<Vec<ChannelConfigSnapshot>> {
|
||||
deps.channel_query.list_config_snapshots(query.channel_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list.rs"]
|
||||
mod tests;
|
||||
@@ -1,12 +1,7 @@
|
||||
pub mod commands;
|
||||
pub mod deps;
|
||||
pub mod get;
|
||||
pub mod list;
|
||||
pub mod patch_label;
|
||||
pub mod queries;
|
||||
pub mod restore;
|
||||
pub mod save;
|
||||
|
||||
pub use commands::{PatchLabelCommand, RestoreSnapshotCommand, SaveSnapshotCommand};
|
||||
pub use commands::{RestoreSnapshotCommand, SaveSnapshotCommand};
|
||||
pub use deps::ConfigSnapshotDeps;
|
||||
pub use queries::{GetSnapshotQuery, ListSnapshotsQuery};
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
use domain::models::ChannelConfigSnapshot;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::commands::PatchLabelCommand;
|
||||
use super::deps::ConfigSnapshotDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &ConfigSnapshotDeps,
|
||||
cmd: PatchLabelCommand,
|
||||
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
||||
deps.channel_command
|
||||
.patch_config_snapshot_label(cmd.channel_id, cmd.snapshot_id, cmd.label)
|
||||
.await
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
use domain::value_objects::{ChannelId, SnapshotId};
|
||||
|
||||
pub struct ListSnapshotsQuery {
|
||||
pub channel_id: ChannelId,
|
||||
}
|
||||
|
||||
pub struct GetSnapshotQuery {
|
||||
pub channel_id: ChannelId,
|
||||
pub snapshot_id: SnapshotId,
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::Channel;
|
||||
use domain::testing::InMemoryChannelRepository;
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
use crate::config_snapshots::commands::SaveSnapshotCommand;
|
||||
use crate::config_snapshots::deps::ConfigSnapshotDeps;
|
||||
use crate::config_snapshots::queries::ListSnapshotsQuery;
|
||||
use crate::config_snapshots::{list, save};
|
||||
|
||||
fn make_deps() -> (ConfigSnapshotDeps, Arc<InMemoryChannelRepository>) {
|
||||
let repo = Arc::new(InMemoryChannelRepository::new());
|
||||
let deps = ConfigSnapshotDeps {
|
||||
channel_command: repo.clone(),
|
||||
channel_query: repo.clone(),
|
||||
};
|
||||
(deps, repo)
|
||||
}
|
||||
|
||||
async fn seed_channel(repo: &InMemoryChannelRepository) -> Channel {
|
||||
let channel = Channel::new(UserId::generate(), "Test Channel", "UTC");
|
||||
repo.channels
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(channel.id(), channel.clone());
|
||||
channel
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_empty() {
|
||||
let (deps, repo) = make_deps();
|
||||
let channel = seed_channel(&repo).await;
|
||||
|
||||
let snaps = list::execute(
|
||||
&deps,
|
||||
ListSnapshotsQuery {
|
||||
channel_id: channel.id(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(snaps.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_returns_saved_snapshots() {
|
||||
let (deps, repo) = make_deps();
|
||||
let channel = seed_channel(&repo).await;
|
||||
|
||||
for label in ["first", "second"] {
|
||||
save::execute(
|
||||
&deps,
|
||||
SaveSnapshotCommand {
|
||||
channel_id: channel.id(),
|
||||
label: Some(label.into()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let snaps = list::execute(
|
||||
&deps,
|
||||
ListSnapshotsQuery {
|
||||
channel_id: channel.id(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(snaps.len(), 2);
|
||||
// Newest first
|
||||
assert_eq!(snaps[0].version_num(), 2);
|
||||
assert_eq!(snaps[1].version_num(), 1);
|
||||
}
|
||||
@@ -9,7 +9,3 @@ pub struct LibraryCommandDeps {
|
||||
pub provider_registry: Arc<dyn IProviderRegistry>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct LibraryQueryDeps {
|
||||
pub library_query: Arc<dyn LibraryQuery>,
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
use domain::models::LibraryItem;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::LibraryQueryDeps;
|
||||
use super::queries::GetItemQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &LibraryQueryDeps,
|
||||
query: GetItemQuery,
|
||||
) -> DomainResult<Option<LibraryItem>> {
|
||||
deps.library_query.get_by_id(&query.item_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_item.rs"]
|
||||
mod tests;
|
||||
@@ -1,16 +0,0 @@
|
||||
use domain::models::LibrarySyncLogEntry;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::LibraryQueryDeps;
|
||||
use super::queries::GetSyncStatusQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &LibraryQueryDeps,
|
||||
_query: GetSyncStatusQuery,
|
||||
) -> DomainResult<Vec<LibrarySyncLogEntry>> {
|
||||
deps.library_query.latest_sync_status().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_sync_status.rs"]
|
||||
mod tests;
|
||||
@@ -1,18 +0,0 @@
|
||||
use domain::models::LibraryCollection;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::LibraryQueryDeps;
|
||||
use super::queries::ListCollectionsQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &LibraryQueryDeps,
|
||||
query: ListCollectionsQuery,
|
||||
) -> DomainResult<Vec<LibraryCollection>> {
|
||||
deps.library_query
|
||||
.list_collections(query.provider_id.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list_collections.rs"]
|
||||
mod tests;
|
||||
@@ -1,21 +0,0 @@
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::LibraryQueryDeps;
|
||||
use super::parse_content_type;
|
||||
use super::queries::ListGenresQuery;
|
||||
|
||||
pub async fn execute(deps: &LibraryQueryDeps, query: ListGenresQuery) -> DomainResult<Vec<String>> {
|
||||
let content_type = query
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(parse_content_type)
|
||||
.transpose()?;
|
||||
|
||||
deps.library_query
|
||||
.list_genres(content_type.as_ref(), query.provider_id.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list_genres.rs"]
|
||||
mod tests;
|
||||
@@ -1,18 +0,0 @@
|
||||
use domain::models::SeasonSummary;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::LibraryQueryDeps;
|
||||
use super::queries::ListSeasonsQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &LibraryQueryDeps,
|
||||
query: ListSeasonsQuery,
|
||||
) -> DomainResult<Vec<SeasonSummary>> {
|
||||
deps.library_query
|
||||
.list_seasons(&query.series_name, query.provider_id.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list_seasons.rs"]
|
||||
mod tests;
|
||||
@@ -1,22 +0,0 @@
|
||||
use domain::models::ShowSummary;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::LibraryQueryDeps;
|
||||
use super::queries::ListShowsQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &LibraryQueryDeps,
|
||||
query: ListShowsQuery,
|
||||
) -> DomainResult<Vec<ShowSummary>> {
|
||||
deps.library_query
|
||||
.list_shows(
|
||||
query.provider_id.as_deref(),
|
||||
query.search_term.as_deref(),
|
||||
&query.genres,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list_shows.rs"]
|
||||
mod tests;
|
||||
@@ -1,26 +1,17 @@
|
||||
pub mod commands;
|
||||
pub mod deps;
|
||||
pub mod get_item;
|
||||
pub mod get_sync_status;
|
||||
pub mod list_collections;
|
||||
pub mod list_genres;
|
||||
pub mod list_seasons;
|
||||
pub mod list_shows;
|
||||
pub mod queries;
|
||||
pub mod search;
|
||||
pub mod sync;
|
||||
|
||||
pub use commands::TriggerSyncCommand;
|
||||
pub use deps::{LibraryCommandDeps, LibraryQueryDeps};
|
||||
pub use queries::{
|
||||
GetItemQuery, GetSyncStatusQuery, ListCollectionsQuery, ListGenresQuery, ListSeasonsQuery,
|
||||
ListShowsQuery, SearchItemsQuery,
|
||||
};
|
||||
pub use deps::LibraryCommandDeps;
|
||||
pub use queries::SearchItemsQuery;
|
||||
|
||||
use domain::errors::{DomainError, DomainResult};
|
||||
use domain::value_objects::ContentType;
|
||||
|
||||
pub(crate) fn parse_content_type(s: &str) -> DomainResult<ContentType> {
|
||||
pub fn parse_content_type(s: &str) -> DomainResult<ContentType> {
|
||||
match s {
|
||||
"movie" => Ok(ContentType::Movie),
|
||||
"episode" => Ok(ContentType::Episode),
|
||||
|
||||
@@ -10,29 +10,3 @@ pub struct SearchItemsQuery {
|
||||
pub offset: u32,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
pub struct ListCollectionsQuery {
|
||||
pub provider_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ListShowsQuery {
|
||||
pub provider_id: Option<String>,
|
||||
pub search_term: Option<String>,
|
||||
pub genres: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct ListSeasonsQuery {
|
||||
pub series_name: String,
|
||||
pub provider_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ListGenresQuery {
|
||||
pub content_type: Option<String>,
|
||||
pub provider_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct GetItemQuery {
|
||||
pub item_id: String,
|
||||
}
|
||||
|
||||
pub struct GetSyncStatusQuery;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use domain::DomainResult;
|
||||
use domain::models::LibraryItem;
|
||||
use domain::models::MediaItem;
|
||||
use domain::value_objects::LibrarySearchFilter;
|
||||
|
||||
use super::deps::LibraryQueryDeps;
|
||||
use super::deps::LibraryCommandDeps;
|
||||
use super::parse_content_type;
|
||||
use super::queries::SearchItemsQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &LibraryQueryDeps,
|
||||
deps: &LibraryCommandDeps,
|
||||
query: SearchItemsQuery,
|
||||
) -> DomainResult<(Vec<LibraryItem>, u32)> {
|
||||
) -> DomainResult<(Vec<MediaItem>, u32)> {
|
||||
let content_type = query
|
||||
.content_type
|
||||
.as_deref()
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
use domain::models::LibraryItem;
|
||||
use domain::value_objects::ContentType;
|
||||
|
||||
use crate::library::get_item;
|
||||
use crate::library::queries::GetItemQuery;
|
||||
|
||||
#[path = "helpers.rs"]
|
||||
mod helpers;
|
||||
|
||||
fn seed_item(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||
let item = LibraryItem::new("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01");
|
||||
repo.items
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(item.id().to_string(), item);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_existing_item() {
|
||||
let (_cmd, query, repo) = helpers::make_deps();
|
||||
seed_item(&repo);
|
||||
|
||||
let item = get_item::execute(
|
||||
&query,
|
||||
GetItemQuery {
|
||||
item_id: "test::m1".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(item.is_some());
|
||||
assert_eq!(item.unwrap().title(), "Die Hard");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_nonexistent_item_returns_none() {
|
||||
let (_cmd, query, _repo) = helpers::make_deps();
|
||||
|
||||
let item = get_item::execute(
|
||||
&query,
|
||||
GetItemQuery {
|
||||
item_id: "test::missing".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(item.is_none());
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
use crate::library::get_sync_status;
|
||||
use crate::library::queries::GetSyncStatusQuery;
|
||||
|
||||
#[path = "helpers.rs"]
|
||||
mod helpers;
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_sync_status_empty() {
|
||||
let (_cmd, query, _repo) = helpers::make_deps();
|
||||
|
||||
let entries = get_sync_status::execute(&query, GetSyncStatusQuery)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(entries.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_sync_status_after_sync() {
|
||||
let (cmd, query, _repo) = helpers::make_deps();
|
||||
|
||||
// Start a sync log entry
|
||||
let log_id = domain::ports::LibraryCommand::log_sync_start(&*cmd.library_command, "test")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Finish it
|
||||
let result = domain::models::LibrarySyncResult::new("test", 42, 500);
|
||||
domain::ports::LibraryCommand::log_sync_finish(&*cmd.library_command, log_id, &result)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = get_sync_status::execute(&query, GetSyncStatusQuery)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].provider_id(), "test");
|
||||
assert_eq!(entries[0].items_found(), 42);
|
||||
assert_eq!(entries[0].status(), "success");
|
||||
}
|
||||
@@ -4,16 +4,13 @@ use async_trait::async_trait;
|
||||
|
||||
use domain::errors::DomainResult;
|
||||
use domain::models::MediaItem;
|
||||
use domain::ports::{
|
||||
Collection, IProviderRegistry, ProviderCapabilities, SeriesSummary, StreamQuality,
|
||||
StreamingProtocol,
|
||||
};
|
||||
use domain::ports::{Collection, IProviderRegistry, ProviderCapabilities, SeriesSummary};
|
||||
use domain::SourceUri;
|
||||
use domain::testing::{InMemoryLibraryRepository, NoopEventPublisher, NoopLibrarySync};
|
||||
use domain::value_objects::{ContentType, MediaFilter, MediaItemId};
|
||||
|
||||
use crate::library::deps::{LibraryCommandDeps, LibraryQueryDeps};
|
||||
use crate::library::deps::LibraryCommandDeps;
|
||||
|
||||
/// Minimal IProviderRegistry for library tests.
|
||||
pub(crate) struct TestProviderRegistry;
|
||||
|
||||
#[async_trait]
|
||||
@@ -30,11 +27,7 @@ impl IProviderRegistry for TestProviderRegistry {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
_item_id: &MediaItemId,
|
||||
_quality: &StreamQuality,
|
||||
) -> DomainResult<String> {
|
||||
async fn get_source_uri(&self, _item_id: &MediaItemId) -> DomainResult<SourceUri> {
|
||||
Err(domain::DomainError::InfrastructureError(
|
||||
"TestProviderRegistry does not support streaming".into(),
|
||||
))
|
||||
@@ -56,9 +49,7 @@ impl IProviderRegistry for TestProviderRegistry {
|
||||
tags: false,
|
||||
decade: false,
|
||||
search: true,
|
||||
streaming_protocol: StreamingProtocol::Hls,
|
||||
rescan: false,
|
||||
transcode: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,24 +74,14 @@ impl IProviderRegistry for TestProviderRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build LibraryCommandDeps and LibraryQueryDeps backed by InMemory repos.
|
||||
///
|
||||
/// Returns deps plus the underlying repo for seeding test data.
|
||||
pub(crate) fn make_deps() -> (
|
||||
LibraryCommandDeps,
|
||||
LibraryQueryDeps,
|
||||
Arc<InMemoryLibraryRepository>,
|
||||
) {
|
||||
pub(crate) fn make_deps() -> (LibraryCommandDeps, Arc<InMemoryLibraryRepository>) {
|
||||
let repo = Arc::new(InMemoryLibraryRepository::new());
|
||||
let cmd_deps = LibraryCommandDeps {
|
||||
let deps = LibraryCommandDeps {
|
||||
library_command: repo.clone(),
|
||||
library_query: repo.clone(),
|
||||
library_sync: Arc::new(NoopLibrarySync::new()),
|
||||
provider_registry: Arc::new(TestProviderRegistry),
|
||||
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||
};
|
||||
let query_deps = LibraryQueryDeps {
|
||||
library_query: repo.clone(),
|
||||
};
|
||||
(cmd_deps, query_deps, repo)
|
||||
(deps, repo)
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
use domain::models::{LibraryItem, LibraryItemRow};
|
||||
use domain::value_objects::ContentType;
|
||||
|
||||
use crate::library::list_collections;
|
||||
use crate::library::queries::ListCollectionsQuery;
|
||||
|
||||
#[path = "helpers.rs"]
|
||||
mod helpers;
|
||||
|
||||
fn seed_with_collections(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||
let mut store = repo.items.lock().unwrap();
|
||||
|
||||
let item = LibraryItem::from_persistence(LibraryItemRow {
|
||||
id: "test::m1".into(),
|
||||
provider_id: "test".into(),
|
||||
external_id: "m1".into(),
|
||||
title: "Die Hard".into(),
|
||||
content_type: ContentType::Movie,
|
||||
duration_secs: 7800,
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
year: None,
|
||||
genres: vec![],
|
||||
tags: vec![],
|
||||
collection_id: Some("col-1".into()),
|
||||
collection_name: Some("Movies".into()),
|
||||
collection_type: Some("movies".into()),
|
||||
thumbnail_url: None,
|
||||
synced_at: "2026-01-01".into(),
|
||||
});
|
||||
store.insert(item.id().to_string(), item);
|
||||
|
||||
let item2 = LibraryItem::from_persistence(LibraryItemRow {
|
||||
id: "test::e1".into(),
|
||||
provider_id: "test".into(),
|
||||
external_id: "e1".into(),
|
||||
title: "BB S01E01".into(),
|
||||
content_type: ContentType::Episode,
|
||||
duration_secs: 2700,
|
||||
series_name: Some("Breaking Bad".into()),
|
||||
season_number: Some(1),
|
||||
episode_number: Some(1),
|
||||
year: None,
|
||||
genres: vec![],
|
||||
tags: vec![],
|
||||
collection_id: Some("col-2".into()),
|
||||
collection_name: Some("TV Shows".into()),
|
||||
collection_type: Some("tvshows".into()),
|
||||
thumbnail_url: None,
|
||||
synced_at: "2026-01-01".into(),
|
||||
});
|
||||
store.insert(item2.id().to_string(), item2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_collections_returns_distinct() {
|
||||
let (_cmd, query, repo) = helpers::make_deps();
|
||||
seed_with_collections(&repo);
|
||||
|
||||
let cols = list_collections::execute(
|
||||
&query,
|
||||
ListCollectionsQuery { provider_id: None },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cols.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_collections_empty_library() {
|
||||
let (_cmd, query, _repo) = helpers::make_deps();
|
||||
|
||||
let cols = list_collections::execute(
|
||||
&query,
|
||||
ListCollectionsQuery { provider_id: None },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(cols.is_empty());
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
use domain::models::{LibraryItem, LibraryItemRow};
|
||||
use domain::value_objects::ContentType;
|
||||
|
||||
use crate::library::list_genres;
|
||||
use crate::library::queries::ListGenresQuery;
|
||||
|
||||
#[path = "helpers.rs"]
|
||||
mod helpers;
|
||||
|
||||
fn seed_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||
let mut store = repo.items.lock().unwrap();
|
||||
|
||||
let item1 = LibraryItem::from_persistence(LibraryItemRow {
|
||||
id: "test::m1".into(),
|
||||
provider_id: "test".into(),
|
||||
external_id: "m1".into(),
|
||||
title: "Die Hard".into(),
|
||||
content_type: ContentType::Movie,
|
||||
duration_secs: 7800,
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
year: None,
|
||||
genres: vec!["Action".into(), "Thriller".into()],
|
||||
tags: vec![],
|
||||
collection_id: None,
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
thumbnail_url: None,
|
||||
synced_at: "2026-01-01".into(),
|
||||
});
|
||||
let item2 = LibraryItem::from_persistence(LibraryItemRow {
|
||||
id: "test::m2".into(),
|
||||
provider_id: "test".into(),
|
||||
external_id: "m2".into(),
|
||||
title: "Alien".into(),
|
||||
content_type: ContentType::Movie,
|
||||
duration_secs: 7020,
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
year: None,
|
||||
genres: vec!["Sci-Fi".into(), "Action".into()],
|
||||
tags: vec![],
|
||||
collection_id: None,
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
thumbnail_url: None,
|
||||
synced_at: "2026-01-01".into(),
|
||||
});
|
||||
|
||||
store.insert(item1.id().to_string(), item1);
|
||||
store.insert(item2.id().to_string(), item2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_genres_returns_unique() {
|
||||
let (_cmd, query, repo) = helpers::make_deps();
|
||||
seed_with_genres(&repo);
|
||||
|
||||
let genres = list_genres::execute(
|
||||
&query,
|
||||
ListGenresQuery {
|
||||
content_type: None,
|
||||
provider_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(genres.len(), 3); // Action, Sci-Fi, Thriller (deduped)
|
||||
assert!(genres.contains(&"Action".to_string()));
|
||||
assert!(genres.contains(&"Sci-Fi".to_string()));
|
||||
assert!(genres.contains(&"Thriller".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_genres_empty_library() {
|
||||
let (_cmd, query, _repo) = helpers::make_deps();
|
||||
|
||||
let genres = list_genres::execute(
|
||||
&query,
|
||||
ListGenresQuery {
|
||||
content_type: None,
|
||||
provider_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(genres.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_genres_invalid_content_type_errors() {
|
||||
let (_cmd, query, _repo) = helpers::make_deps();
|
||||
|
||||
let result = list_genres::execute(
|
||||
&query,
|
||||
ListGenresQuery {
|
||||
content_type: Some("invalid".into()),
|
||||
provider_id: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
use domain::models::{LibraryItem, LibraryItemRow};
|
||||
use domain::value_objects::ContentType;
|
||||
|
||||
use crate::library::list_seasons;
|
||||
use crate::library::queries::ListSeasonsQuery;
|
||||
|
||||
#[path = "helpers.rs"]
|
||||
mod helpers;
|
||||
|
||||
fn seed_episodes(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||
let mut store = repo.items.lock().unwrap();
|
||||
|
||||
for (i, season) in [1u32, 1, 2, 2, 2, 3].iter().enumerate() {
|
||||
let item = LibraryItem::from_persistence(LibraryItemRow {
|
||||
id: format!("test::e{i}"),
|
||||
provider_id: "test".into(),
|
||||
external_id: format!("e{i}"),
|
||||
title: format!("BB S{season:02}E{:02}", i + 1),
|
||||
content_type: ContentType::Episode,
|
||||
duration_secs: 2700,
|
||||
series_name: Some("Breaking Bad".into()),
|
||||
season_number: Some(*season),
|
||||
episode_number: Some(i as u32 + 1),
|
||||
year: None,
|
||||
genres: vec![],
|
||||
tags: vec![],
|
||||
collection_id: None,
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
thumbnail_url: None,
|
||||
synced_at: "2026-01-01".into(),
|
||||
});
|
||||
store.insert(item.id().to_string(), item);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_seasons_for_series() {
|
||||
let (_cmd, query, repo) = helpers::make_deps();
|
||||
seed_episodes(&repo);
|
||||
|
||||
let seasons = list_seasons::execute(
|
||||
&query,
|
||||
ListSeasonsQuery {
|
||||
series_name: "Breaking Bad".into(),
|
||||
provider_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(seasons.len(), 3);
|
||||
// Sorted by season_number
|
||||
assert_eq!(seasons[0].season_number(), 1);
|
||||
assert_eq!(seasons[0].episode_count(), 2);
|
||||
assert_eq!(seasons[1].season_number(), 2);
|
||||
assert_eq!(seasons[1].episode_count(), 3);
|
||||
assert_eq!(seasons[2].season_number(), 3);
|
||||
assert_eq!(seasons[2].episode_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_seasons_unknown_series() {
|
||||
let (_cmd, query, _repo) = helpers::make_deps();
|
||||
|
||||
let seasons = list_seasons::execute(
|
||||
&query,
|
||||
ListSeasonsQuery {
|
||||
series_name: "Nonexistent".into(),
|
||||
provider_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(seasons.is_empty());
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
use domain::models::{LibraryItem, LibraryItemRow};
|
||||
use domain::value_objects::ContentType;
|
||||
|
||||
use crate::library::list_shows;
|
||||
use crate::library::queries::ListShowsQuery;
|
||||
|
||||
#[path = "helpers.rs"]
|
||||
mod helpers;
|
||||
|
||||
fn seed_episodes(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||
let mut store = repo.items.lock().unwrap();
|
||||
|
||||
for (i, (series, season)) in [
|
||||
("Breaking Bad", 1u32),
|
||||
("Breaking Bad", 1),
|
||||
("Breaking Bad", 2),
|
||||
("The Wire", 1),
|
||||
("The Wire", 1),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let item = LibraryItem::from_persistence(LibraryItemRow {
|
||||
id: format!("test::e{i}"),
|
||||
provider_id: "test".into(),
|
||||
external_id: format!("e{i}"),
|
||||
title: format!("{series} S{season:02}E{i:02}"),
|
||||
content_type: ContentType::Episode,
|
||||
duration_secs: 2700,
|
||||
series_name: Some(series.to_string()),
|
||||
season_number: Some(*season),
|
||||
episode_number: Some(i as u32 + 1),
|
||||
year: None,
|
||||
genres: vec![],
|
||||
tags: vec![],
|
||||
collection_id: None,
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
thumbnail_url: None,
|
||||
synced_at: "2026-01-01".into(),
|
||||
});
|
||||
store.insert(item.id().to_string(), item);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_shows_returns_summaries() {
|
||||
let (_cmd, query, repo) = helpers::make_deps();
|
||||
seed_episodes(&repo);
|
||||
|
||||
let shows = list_shows::execute(
|
||||
&query,
|
||||
ListShowsQuery {
|
||||
provider_id: None,
|
||||
search_term: None,
|
||||
genres: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(shows.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_shows_with_search_term() {
|
||||
let (_cmd, query, repo) = helpers::make_deps();
|
||||
seed_episodes(&repo);
|
||||
|
||||
let shows = list_shows::execute(
|
||||
&query,
|
||||
ListShowsQuery {
|
||||
provider_id: None,
|
||||
search_term: Some("breaking".into()),
|
||||
genres: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(shows.len(), 1);
|
||||
assert_eq!(shows[0].series_name(), "Breaking Bad");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_shows_empty() {
|
||||
let (_cmd, query, _repo) = helpers::make_deps();
|
||||
|
||||
let shows = list_shows::execute(
|
||||
&query,
|
||||
ListShowsQuery {
|
||||
provider_id: None,
|
||||
search_term: None,
|
||||
genres: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(shows.is_empty());
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use domain::models::{LibraryItem, LibraryItemRow};
|
||||
use domain::value_objects::ContentType;
|
||||
use domain::models::{MediaItem, MediaItemRow};
|
||||
use domain::value_objects::{ContentType, MediaItemId, MediaRole};
|
||||
|
||||
use crate::library::queries::SearchItemsQuery;
|
||||
use crate::library::search;
|
||||
@@ -10,25 +10,26 @@ mod helpers;
|
||||
fn seed_items(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||
let mut store = repo.items.lock().unwrap();
|
||||
let items = vec![
|
||||
LibraryItem::new("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01"),
|
||||
LibraryItem::new("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", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01"),
|
||||
MediaItem::new_library("test", "m2", "Alien", ContentType::Movie, 7020, "2026-01-01"),
|
||||
MediaItem::new_library("test", "e1", "BB S01E01", ContentType::Episode, 2700, "2026-01-01"),
|
||||
];
|
||||
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>) {
|
||||
let mut store = repo.items.lock().unwrap();
|
||||
|
||||
let action = LibraryItem::from_persistence(LibraryItemRow {
|
||||
id: "test::m1".into(),
|
||||
let action = MediaItem::from_persistence(MediaItemRow {
|
||||
id: MediaItemId::new("test::m1"),
|
||||
provider_id: "test".into(),
|
||||
external_id: "m1".into(),
|
||||
title: "Die Hard".into(),
|
||||
content_type: ContentType::Movie,
|
||||
duration_secs: 7800,
|
||||
description: None,
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
@@ -39,15 +40,18 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
thumbnail_url: None,
|
||||
synced_at: "2026-01-01".into(),
|
||||
synced_at: Some("2026-01-01".into()),
|
||||
role: MediaRole::default(),
|
||||
chapters: Vec::new(),
|
||||
});
|
||||
let scifi = LibraryItem::from_persistence(LibraryItemRow {
|
||||
id: "test::m2".into(),
|
||||
let scifi = MediaItem::from_persistence(MediaItemRow {
|
||||
id: MediaItemId::new("test::m2"),
|
||||
provider_id: "test".into(),
|
||||
external_id: "m2".into(),
|
||||
title: "Alien".into(),
|
||||
content_type: ContentType::Movie,
|
||||
duration_secs: 7020,
|
||||
description: None,
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
@@ -58,15 +62,18 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
thumbnail_url: None,
|
||||
synced_at: "2026-01-01".into(),
|
||||
synced_at: Some("2026-01-01".into()),
|
||||
role: MediaRole::default(),
|
||||
chapters: Vec::new(),
|
||||
});
|
||||
let comedy = LibraryItem::from_persistence(LibraryItemRow {
|
||||
id: "test::m3".into(),
|
||||
let comedy = MediaItem::from_persistence(MediaItemRow {
|
||||
id: MediaItemId::new("test::m3"),
|
||||
provider_id: "test".into(),
|
||||
external_id: "m3".into(),
|
||||
title: "Airplane!".into(),
|
||||
content_type: ContentType::Movie,
|
||||
duration_secs: 5280,
|
||||
description: None,
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
@@ -77,21 +84,23 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
thumbnail_url: None,
|
||||
synced_at: "2026-01-01".into(),
|
||||
synced_at: Some("2026-01-01".into()),
|
||||
role: MediaRole::default(),
|
||||
chapters: Vec::new(),
|
||||
});
|
||||
|
||||
store.insert(action.id().to_string(), action);
|
||||
store.insert(scifi.id().to_string(), scifi);
|
||||
store.insert(comedy.id().to_string(), comedy);
|
||||
store.insert(action.id().value().to_string(), action);
|
||||
store.insert(scifi.id().value().to_string(), scifi);
|
||||
store.insert(comedy.id().value().to_string(), comedy);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_empty_filter_returns_all() {
|
||||
let (_cmd, query, repo) = helpers::make_deps();
|
||||
let (deps, repo) = helpers::make_deps();
|
||||
seed_items(&repo);
|
||||
|
||||
let (items, total) = search::execute(
|
||||
&query,
|
||||
&deps,
|
||||
SearchItemsQuery {
|
||||
provider_id: None,
|
||||
content_type: None,
|
||||
@@ -114,11 +123,11 @@ async fn search_empty_filter_returns_all() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_by_content_type() {
|
||||
let (_cmd, query, repo) = helpers::make_deps();
|
||||
let (deps, repo) = helpers::make_deps();
|
||||
seed_items(&repo);
|
||||
|
||||
let (items, total) = search::execute(
|
||||
&query,
|
||||
&deps,
|
||||
SearchItemsQuery {
|
||||
provider_id: None,
|
||||
content_type: Some("movie".into()),
|
||||
@@ -141,11 +150,11 @@ async fn search_by_content_type() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_by_genre() {
|
||||
let (_cmd, query, repo) = helpers::make_deps();
|
||||
let (deps, repo) = helpers::make_deps();
|
||||
seed_items_with_genres(&repo);
|
||||
|
||||
let (items, total) = search::execute(
|
||||
&query,
|
||||
&deps,
|
||||
SearchItemsQuery {
|
||||
provider_id: None,
|
||||
content_type: None,
|
||||
@@ -169,11 +178,11 @@ async fn search_by_genre() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_by_search_term() {
|
||||
let (_cmd, query, repo) = helpers::make_deps();
|
||||
let (deps, repo) = helpers::make_deps();
|
||||
seed_items(&repo);
|
||||
|
||||
let (items, total) = search::execute(
|
||||
&query,
|
||||
&deps,
|
||||
SearchItemsQuery {
|
||||
provider_id: None,
|
||||
content_type: None,
|
||||
@@ -197,10 +206,10 @@ async fn search_by_search_term() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_invalid_content_type_errors() {
|
||||
let (_cmd, query, _repo) = helpers::make_deps();
|
||||
let (deps, _repo) = helpers::make_deps();
|
||||
|
||||
let result = search::execute(
|
||||
&query,
|
||||
&deps,
|
||||
SearchItemsQuery {
|
||||
provider_id: None,
|
||||
content_type: Some("bogus".into()),
|
||||
@@ -221,11 +230,11 @@ async fn search_invalid_content_type_errors() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_pagination() {
|
||||
let (_cmd, query, repo) = helpers::make_deps();
|
||||
let (deps, repo) = helpers::make_deps();
|
||||
seed_items(&repo);
|
||||
|
||||
let (items, total) = search::execute(
|
||||
&query,
|
||||
&deps,
|
||||
SearchItemsQuery {
|
||||
provider_id: None,
|
||||
content_type: None,
|
||||
|
||||
@@ -6,10 +6,10 @@ mod helpers;
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_sync_returns_provider_ids() {
|
||||
let (cmd, _query, _repo) = helpers::make_deps();
|
||||
let (deps, _repo) = helpers::make_deps();
|
||||
|
||||
let ids = sync::execute(
|
||||
&cmd,
|
||||
&deps,
|
||||
TriggerSyncCommand { provider_id: None },
|
||||
)
|
||||
.await
|
||||
@@ -20,10 +20,10 @@ async fn trigger_sync_returns_provider_ids() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_sync_specific_provider() {
|
||||
let (cmd, _query, _repo) = helpers::make_deps();
|
||||
let (deps, _repo) = helpers::make_deps();
|
||||
|
||||
let ids = sync::execute(
|
||||
&cmd,
|
||||
&deps,
|
||||
TriggerSyncCommand {
|
||||
provider_id: Some("test".into()),
|
||||
},
|
||||
@@ -36,16 +36,15 @@ async fn trigger_sync_specific_provider() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_sync_while_running_errors() {
|
||||
let (cmd, _query, repo) = helpers::make_deps();
|
||||
let (deps, repo) = helpers::make_deps();
|
||||
|
||||
// Simulate a running sync by inserting a log entry with "running" status
|
||||
repo.items.lock().unwrap(); // just verify repo is accessible
|
||||
let _log_id = domain::ports::LibraryCommand::log_sync_start(&*cmd.library_command, "test")
|
||||
repo.items.lock().unwrap();
|
||||
let _log_id = domain::ports::LibraryCommand::log_sync_start(&*deps.library_command, "test")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = sync::execute(
|
||||
&cmd,
|
||||
&deps,
|
||||
TriggerSyncCommand { provider_id: None },
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -4,7 +4,3 @@ pub struct UpsertProviderCommand {
|
||||
pub config: serde_json::Value,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
pub struct DeleteProviderCommand {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::commands::DeleteProviderCommand;
|
||||
use super::deps::ProviderDeps;
|
||||
|
||||
pub async fn execute(deps: &ProviderDeps, cmd: DeleteProviderCommand) -> DomainResult<()> {
|
||||
deps.provider_config_command.delete(&cmd.id).await
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
use domain::models::ProviderConfigRow;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::ProviderDeps;
|
||||
use super::queries::GetProviderQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &ProviderDeps,
|
||||
query: GetProviderQuery,
|
||||
) -> DomainResult<Option<ProviderConfigRow>> {
|
||||
deps.provider_config_query.get_by_id(&query.id).await
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
use domain::models::ProviderConfigRow;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::ProviderDeps;
|
||||
use super::queries::ListProvidersQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &ProviderDeps,
|
||||
_query: ListProvidersQuery,
|
||||
) -> DomainResult<Vec<ProviderConfigRow>> {
|
||||
deps.provider_config_query.get_all().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list.rs"]
|
||||
mod tests;
|
||||
@@ -1,11 +1,6 @@
|
||||
pub mod commands;
|
||||
pub mod delete;
|
||||
pub mod deps;
|
||||
pub mod get;
|
||||
pub mod list;
|
||||
pub mod queries;
|
||||
pub mod upsert;
|
||||
|
||||
pub use commands::{DeleteProviderCommand, UpsertProviderCommand};
|
||||
pub use commands::UpsertProviderCommand;
|
||||
pub use deps::ProviderDeps;
|
||||
pub use queries::{GetProviderQuery, ListProvidersQuery};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
pub struct ListProvidersQuery;
|
||||
|
||||
pub struct GetProviderQuery {
|
||||
pub id: String,
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryProviderConfig;
|
||||
|
||||
use crate::providers::commands::UpsertProviderCommand;
|
||||
use crate::providers::deps::ProviderDeps;
|
||||
use crate::providers::queries::ListProvidersQuery;
|
||||
use crate::providers::{list, upsert};
|
||||
|
||||
fn make_deps() -> ProviderDeps {
|
||||
let repo = Arc::new(InMemoryProviderConfig::new());
|
||||
ProviderDeps {
|
||||
provider_config_command: repo.clone(),
|
||||
provider_config_query: repo,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_empty() {
|
||||
let deps = make_deps();
|
||||
let providers = list::execute(&deps, ListProvidersQuery).await.unwrap();
|
||||
assert!(providers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_returns_all_providers() {
|
||||
let deps = make_deps();
|
||||
|
||||
for id in ["jf-1", "local-1"] {
|
||||
upsert::execute(
|
||||
&deps,
|
||||
UpsertProviderCommand {
|
||||
id: id.into(),
|
||||
provider_type: "jellyfin".into(),
|
||||
config: serde_json::json!({}),
|
||||
enabled: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let providers = list::execute(&deps, ListProvidersQuery).await.unwrap();
|
||||
assert_eq!(providers.len(), 2);
|
||||
}
|
||||
@@ -3,8 +3,3 @@ use uuid::Uuid;
|
||||
pub struct GenerateScheduleCommand {
|
||||
pub channel_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct DeleteSchedulesAfterCommand {
|
||||
pub channel_id: Uuid,
|
||||
pub target_generation: u32,
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
use domain::value_objects::ChannelId;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::commands::DeleteSchedulesAfterCommand;
|
||||
use super::deps::ScheduleDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &ScheduleDeps,
|
||||
cmd: DeleteSchedulesAfterCommand,
|
||||
) -> DomainResult<()> {
|
||||
let channel_id = ChannelId::from(cmd.channel_id);
|
||||
deps.schedule_command
|
||||
.delete_schedules_after(channel_id, cmd.target_generation)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/delete_after.rs"]
|
||||
mod tests;
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{ChannelQuery, EventPublisher, ScheduleCommand, ScheduleQuery};
|
||||
use domain::ports::{ChannelQuery, EventPublisher, IProviderRegistry, ScheduleCommand, ScheduleQuery};
|
||||
use domain::ScheduleEngineService;
|
||||
|
||||
pub struct ScheduleDeps {
|
||||
@@ -9,4 +9,5 @@ pub struct ScheduleDeps {
|
||||
pub schedule_query: Arc<dyn ScheduleQuery>,
|
||||
pub schedule_command: Arc<dyn ScheduleCommand>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub provider_registry: Arc<dyn IProviderRegistry>,
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
use chrono::Utc;
|
||||
|
||||
use domain::models::GeneratedSchedule;
|
||||
use domain::value_objects::ChannelId;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::ScheduleDeps;
|
||||
use super::queries::GetActiveScheduleQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &ScheduleDeps,
|
||||
query: GetActiveScheduleQuery,
|
||||
) -> DomainResult<Option<GeneratedSchedule>> {
|
||||
let channel_id = ChannelId::from(query.channel_id);
|
||||
deps.schedule_query.find_active(channel_id, Utc::now()).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_active.rs"]
|
||||
mod tests;
|
||||
@@ -1,13 +1,12 @@
|
||||
use chrono::Utc;
|
||||
|
||||
use domain::ports::StreamQuality;
|
||||
use domain::value_objects::ChannelId;
|
||||
use domain::value_objects::{ChannelId, SourceUri};
|
||||
use domain::{DomainResult, ScheduleEngineService};
|
||||
|
||||
use super::deps::ScheduleDeps;
|
||||
use super::queries::GetStreamUrlQuery;
|
||||
use super::queries::GetSourceQuery;
|
||||
|
||||
pub async fn execute(deps: &ScheduleDeps, query: GetStreamUrlQuery) -> DomainResult<Option<String>> {
|
||||
pub async fn execute(deps: &ScheduleDeps, query: GetSourceQuery) -> DomainResult<Option<SourceUri>> {
|
||||
let channel_id = ChannelId::from(query.channel_id);
|
||||
let now = Utc::now();
|
||||
|
||||
@@ -22,9 +21,9 @@ pub async fn execute(deps: &ScheduleDeps, query: GetStreamUrlQuery) -> DomainRes
|
||||
};
|
||||
|
||||
let item_id = broadcast.slot().item().id().clone();
|
||||
let url = deps
|
||||
.schedule_engine
|
||||
.get_stream_url(&item_id, &StreamQuality::Direct)
|
||||
let uri = deps
|
||||
.provider_registry
|
||||
.get_source_uri(&item_id)
|
||||
.await?;
|
||||
Ok(Some(url))
|
||||
Ok(Some(uri))
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use domain::models::GeneratedSchedule;
|
||||
use domain::value_objects::ChannelId;
|
||||
use domain::DomainResult;
|
||||
|
||||
use super::deps::ScheduleDeps;
|
||||
use super::queries::ListHistoryQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &ScheduleDeps,
|
||||
query: ListHistoryQuery,
|
||||
) -> DomainResult<Vec<GeneratedSchedule>> {
|
||||
let channel_id = ChannelId::from(query.channel_id);
|
||||
deps.schedule_query
|
||||
.list_schedule_history(channel_id)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list_history.rs"]
|
||||
mod tests;
|
||||
@@ -1,17 +1,11 @@
|
||||
pub mod commands;
|
||||
pub mod delete_after;
|
||||
pub mod deps;
|
||||
pub mod generate;
|
||||
pub mod get_active;
|
||||
pub mod get_current_broadcast;
|
||||
pub mod get_epg;
|
||||
pub mod get_stream_url;
|
||||
pub mod list_history;
|
||||
pub mod get_source;
|
||||
pub mod queries;
|
||||
|
||||
pub use commands::{DeleteSchedulesAfterCommand, GenerateScheduleCommand};
|
||||
pub use commands::GenerateScheduleCommand;
|
||||
pub use deps::ScheduleDeps;
|
||||
pub use queries::{
|
||||
GetActiveScheduleQuery, GetCurrentBroadcastQuery, GetEpgQuery, GetStreamUrlQuery,
|
||||
ListHistoryQuery,
|
||||
};
|
||||
pub use queries::{GetCurrentBroadcastQuery, GetEpgQuery, GetSourceQuery};
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct GetActiveScheduleQuery {
|
||||
pub channel_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct GetCurrentBroadcastQuery {
|
||||
pub channel_id: Uuid,
|
||||
}
|
||||
@@ -12,10 +8,6 @@ pub struct GetEpgQuery {
|
||||
pub channel_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct GetStreamUrlQuery {
|
||||
pub channel_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct ListHistoryQuery {
|
||||
pub struct GetSourceQuery {
|
||||
pub channel_id: Uuid,
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
use domain::models::{Channel, GeneratedSchedule};
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
use crate::schedule::commands::DeleteSchedulesAfterCommand;
|
||||
use crate::schedule::delete_after;
|
||||
use crate::schedule::queries::ListHistoryQuery;
|
||||
use crate::schedule::list_history;
|
||||
|
||||
#[path = "helpers.rs"]
|
||||
mod helpers;
|
||||
use helpers::make_schedule_deps;
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_after_removes_later_generations() {
|
||||
let (deps, channel_repo, schedule_repo) = make_schedule_deps();
|
||||
|
||||
let channel = Channel::new(UserId::generate(), "Cleanup", "UTC");
|
||||
let channel_id = channel.id();
|
||||
channel_repo
|
||||
.channels
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(channel_id, channel);
|
||||
|
||||
// Manually insert schedules with different generations.
|
||||
let now = chrono::Utc::now();
|
||||
for generation in 1..=3 {
|
||||
let sched = GeneratedSchedule::new(
|
||||
channel_id,
|
||||
now,
|
||||
now + chrono::Duration::hours(24),
|
||||
generation,
|
||||
vec![],
|
||||
);
|
||||
schedule_repo
|
||||
.schedules
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(sched.id(), sched);
|
||||
}
|
||||
|
||||
// Delete generations > 1.
|
||||
delete_after::execute(
|
||||
&deps,
|
||||
DeleteSchedulesAfterCommand {
|
||||
channel_id: channel_id.value(),
|
||||
target_generation: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let remaining = list_history::execute(
|
||||
&deps,
|
||||
ListHistoryQuery {
|
||||
channel_id: channel_id.value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].generation(), 1);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
use crate::schedule::get_active;
|
||||
use crate::schedule::queries::GetActiveScheduleQuery;
|
||||
|
||||
#[path = "helpers.rs"]
|
||||
mod helpers;
|
||||
use helpers::make_schedule_deps;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_when_no_schedule_exists() {
|
||||
let (deps, _, _) = make_schedule_deps();
|
||||
|
||||
let result = get_active::execute(
|
||||
&deps,
|
||||
GetActiveScheduleQuery {
|
||||
channel_id: uuid::Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
@@ -4,17 +4,14 @@ use async_trait::async_trait;
|
||||
|
||||
use domain::errors::DomainResult;
|
||||
use domain::models::MediaItem;
|
||||
use domain::ports::{
|
||||
Collection, IProviderRegistry, ProviderCapabilities,
|
||||
SeriesSummary, StreamQuality, StreamingProtocol,
|
||||
};
|
||||
use domain::testing::{InMemoryChannelRepository, InMemoryScheduleRepository, NoopEventPublisher};
|
||||
use domain::ports::{Collection, IProviderRegistry, ProviderCapabilities, SeriesSummary};
|
||||
use domain::SourceUri;
|
||||
use domain::testing::{InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository, NoopEventPublisher};
|
||||
use domain::value_objects::{ContentType, MediaFilter, MediaItemId};
|
||||
use domain::ScheduleEngineService;
|
||||
|
||||
use crate::schedule::deps::ScheduleDeps;
|
||||
|
||||
/// Minimal IProviderRegistry backed by a NoopMediaProvider.
|
||||
pub(crate) struct TestProviderRegistry;
|
||||
|
||||
#[async_trait]
|
||||
@@ -31,11 +28,7 @@ impl IProviderRegistry for TestProviderRegistry {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
_item_id: &MediaItemId,
|
||||
_quality: &StreamQuality,
|
||||
) -> DomainResult<String> {
|
||||
async fn get_source_uri(&self, _item_id: &MediaItemId) -> DomainResult<SourceUri> {
|
||||
Err(domain::DomainError::InfrastructureError(
|
||||
"TestProviderRegistry does not support streaming".into(),
|
||||
))
|
||||
@@ -57,9 +50,7 @@ impl IProviderRegistry for TestProviderRegistry {
|
||||
tags: false,
|
||||
decade: false,
|
||||
search: false,
|
||||
streaming_protocol: StreamingProtocol::Hls,
|
||||
rescan: false,
|
||||
transcode: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,9 +75,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() -> (
|
||||
ScheduleDeps,
|
||||
Arc<InMemoryChannelRepository>,
|
||||
@@ -94,10 +82,11 @@ pub(crate) fn make_schedule_deps() -> (
|
||||
) {
|
||||
let channel_repo = Arc::new(InMemoryChannelRepository::new());
|
||||
let schedule_repo = Arc::new(InMemoryScheduleRepository::new());
|
||||
let library_repo = Arc::new(InMemoryLibraryRepository::new());
|
||||
let provider_registry = Arc::new(TestProviderRegistry);
|
||||
|
||||
let engine = Arc::new(ScheduleEngineService::new(
|
||||
provider_registry,
|
||||
library_repo,
|
||||
channel_repo.clone(),
|
||||
schedule_repo.clone(),
|
||||
schedule_repo.clone(),
|
||||
@@ -109,6 +98,7 @@ pub(crate) fn make_schedule_deps() -> (
|
||||
schedule_query: schedule_repo.clone(),
|
||||
schedule_command: schedule_repo.clone(),
|
||||
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||
provider_registry,
|
||||
};
|
||||
|
||||
(deps, channel_repo, schedule_repo)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
use crate::schedule::list_history;
|
||||
use crate::schedule::queries::ListHistoryQuery;
|
||||
|
||||
#[path = "helpers.rs"]
|
||||
mod helpers;
|
||||
use helpers::make_schedule_deps;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_for_new_channel() {
|
||||
let (deps, _, _) = make_schedule_deps();
|
||||
|
||||
let result = list_history::execute(
|
||||
&deps,
|
||||
ListHistoryQuery {
|
||||
channel_id: uuid::Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::value_objects::{ChannelId, ScheduleId, SlotId};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum DomainEvent {
|
||||
BroadcastTransition {
|
||||
@@ -18,6 +20,58 @@ pub enum DomainEvent {
|
||||
UserRegistered { user_id: crate::value_objects::UserId },
|
||||
}
|
||||
|
||||
pub struct EventEnvelope {
|
||||
id: i64,
|
||||
event: DomainEvent,
|
||||
retry_count: u32,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
impl EventEnvelope {
|
||||
pub fn new(id: i64, event: DomainEvent) -> Self {
|
||||
Self {
|
||||
id,
|
||||
event,
|
||||
retry_count: 0,
|
||||
created_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
id: i64,
|
||||
event: DomainEvent,
|
||||
retry_count: u32,
|
||||
created_at: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
event,
|
||||
retry_count,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> i64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn event(&self) -> &DomainEvent {
|
||||
&self.event
|
||||
}
|
||||
|
||||
pub fn into_event(self) -> DomainEvent {
|
||||
self.event
|
||||
}
|
||||
|
||||
pub fn retry_count(&self) -> u32 {
|
||||
self.retry_count
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &str {
|
||||
&self.created_at
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/mod.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::value_objects::{
|
||||
AccessMode, BlockId, ChannelId, FillStrategy, LogoPosition, MediaFilter, MediaItemId,
|
||||
RecyclePolicy, UserId, Weekday,
|
||||
AccessMode, BlockId, ChannelId, FillStrategy, InterstitialRule, LogoPosition, MediaFilter,
|
||||
MediaItemId, MidRollRule, RotationPolicy, UserId, Weekday,
|
||||
};
|
||||
|
||||
const SECONDS_IN_DAY: u32 = 86_400;
|
||||
@@ -19,10 +19,9 @@ pub struct Channel {
|
||||
description: Option<String>,
|
||||
timezone: String,
|
||||
schedule_config: ScheduleConfig,
|
||||
recycle_policy: RecyclePolicy,
|
||||
rotation_policy: RotationPolicy,
|
||||
auto_schedule: bool,
|
||||
access_mode: AccessMode,
|
||||
access_password_hash: Option<String>,
|
||||
logo: Option<String>,
|
||||
logo_position: LogoPosition,
|
||||
logo_opacity: f32,
|
||||
@@ -30,6 +29,8 @@ pub struct Channel {
|
||||
webhook_poll_interval_secs: u32,
|
||||
webhook_body_template: Option<String>,
|
||||
webhook_headers: Option<String>,
|
||||
#[serde(default)]
|
||||
gap_filler: Option<MediaFilter>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -41,10 +42,9 @@ pub struct ChannelRow {
|
||||
pub description: Option<String>,
|
||||
pub timezone: String,
|
||||
pub schedule_config: ScheduleConfig,
|
||||
pub recycle_policy: RecyclePolicy,
|
||||
pub rotation_policy: RotationPolicy,
|
||||
pub auto_schedule: bool,
|
||||
pub access_mode: AccessMode,
|
||||
pub access_password_hash: Option<String>,
|
||||
pub logo: Option<String>,
|
||||
pub logo_position: LogoPosition,
|
||||
pub logo_opacity: f32,
|
||||
@@ -52,6 +52,7 @@ pub struct ChannelRow {
|
||||
pub webhook_poll_interval_secs: u32,
|
||||
pub webhook_body_template: Option<String>,
|
||||
pub webhook_headers: Option<String>,
|
||||
pub gap_filler: Option<MediaFilter>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -70,10 +71,9 @@ impl Channel {
|
||||
description: None,
|
||||
timezone: timezone.into(),
|
||||
schedule_config: ScheduleConfig::default(),
|
||||
recycle_policy: RecyclePolicy::default(),
|
||||
rotation_policy: RotationPolicy::default(),
|
||||
auto_schedule: false,
|
||||
access_mode: AccessMode::default(),
|
||||
access_password_hash: None,
|
||||
logo: None,
|
||||
logo_position: LogoPosition::default(),
|
||||
logo_opacity: DEFAULT_LOGO_OPACITY,
|
||||
@@ -81,6 +81,7 @@ impl Channel {
|
||||
webhook_poll_interval_secs: DEFAULT_WEBHOOK_POLL_INTERVAL_SECS,
|
||||
webhook_body_template: None,
|
||||
webhook_headers: None,
|
||||
gap_filler: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -94,10 +95,9 @@ impl Channel {
|
||||
description: row.description,
|
||||
timezone: row.timezone,
|
||||
schedule_config: row.schedule_config,
|
||||
recycle_policy: row.recycle_policy,
|
||||
rotation_policy: row.rotation_policy,
|
||||
auto_schedule: row.auto_schedule,
|
||||
access_mode: row.access_mode,
|
||||
access_password_hash: row.access_password_hash,
|
||||
logo: row.logo,
|
||||
logo_position: row.logo_position,
|
||||
logo_opacity: row.logo_opacity,
|
||||
@@ -105,6 +105,7 @@ impl Channel {
|
||||
webhook_poll_interval_secs: row.webhook_poll_interval_secs,
|
||||
webhook_body_template: row.webhook_body_template,
|
||||
webhook_headers: row.webhook_headers,
|
||||
gap_filler: row.gap_filler,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
@@ -134,8 +135,8 @@ impl Channel {
|
||||
&self.schedule_config
|
||||
}
|
||||
|
||||
pub fn recycle_policy(&self) -> &RecyclePolicy {
|
||||
&self.recycle_policy
|
||||
pub fn rotation_policy(&self) -> &RotationPolicy {
|
||||
&self.rotation_policy
|
||||
}
|
||||
|
||||
pub fn auto_schedule(&self) -> bool {
|
||||
@@ -146,10 +147,6 @@ impl Channel {
|
||||
&self.access_mode
|
||||
}
|
||||
|
||||
pub fn access_password_hash(&self) -> Option<&str> {
|
||||
self.access_password_hash.as_deref()
|
||||
}
|
||||
|
||||
pub fn logo(&self) -> Option<&str> {
|
||||
self.logo.as_deref()
|
||||
}
|
||||
@@ -206,8 +203,8 @@ impl Channel {
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn set_recycle_policy(&mut self, policy: RecyclePolicy) {
|
||||
self.recycle_policy = policy;
|
||||
pub fn set_rotation_policy(&mut self, policy: RotationPolicy) {
|
||||
self.rotation_policy = policy;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
@@ -215,6 +212,15 @@ impl Channel {
|
||||
self.auto_schedule = enabled;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn gap_filler(&self) -> Option<&MediaFilter> {
|
||||
self.gap_filler.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_gap_filler(&mut self, gap_filler: Option<MediaFilter>) {
|
||||
self.gap_filler = gap_filler;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
// deny_unknown_fields required so #[serde(untagged)] compat enum correctly rejects V1 payloads
|
||||
@@ -331,13 +337,13 @@ pub struct ProgrammingBlock {
|
||||
loop_on_finish: bool,
|
||||
|
||||
#[serde(default)]
|
||||
ignore_recycle_policy: bool,
|
||||
ignore_rotation_policy: bool,
|
||||
|
||||
#[serde(default)]
|
||||
access_mode: AccessMode,
|
||||
interstitial_rule: Option<InterstitialRule>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
access_password_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
mid_roll_rule: Option<MidRollRule>,
|
||||
}
|
||||
|
||||
impl ProgrammingBlock {
|
||||
@@ -356,12 +362,11 @@ impl ProgrammingBlock {
|
||||
content: BlockContent::Algorithmic {
|
||||
filter,
|
||||
strategy,
|
||||
provider_id: String::new(),
|
||||
},
|
||||
loop_on_finish: true,
|
||||
ignore_recycle_policy: false,
|
||||
access_mode: AccessMode::default(),
|
||||
access_password_hash: None,
|
||||
ignore_rotation_policy: false,
|
||||
interstitial_rule: None,
|
||||
mid_roll_rule: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,12 +383,11 @@ impl ProgrammingBlock {
|
||||
duration_mins,
|
||||
content: BlockContent::Manual {
|
||||
items,
|
||||
provider_id: String::new(),
|
||||
},
|
||||
loop_on_finish: true,
|
||||
ignore_recycle_policy: false,
|
||||
access_mode: AccessMode::default(),
|
||||
access_password_hash: None,
|
||||
ignore_rotation_policy: false,
|
||||
interstitial_rule: None,
|
||||
mid_roll_rule: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,16 +415,16 @@ impl ProgrammingBlock {
|
||||
self.loop_on_finish
|
||||
}
|
||||
|
||||
pub fn ignore_recycle_policy(&self) -> bool {
|
||||
self.ignore_recycle_policy
|
||||
pub fn ignore_rotation_policy(&self) -> bool {
|
||||
self.ignore_rotation_policy
|
||||
}
|
||||
|
||||
pub fn access_mode(&self) -> &AccessMode {
|
||||
&self.access_mode
|
||||
pub fn interstitial_rule(&self) -> Option<&InterstitialRule> {
|
||||
self.interstitial_rule.as_ref()
|
||||
}
|
||||
|
||||
pub fn access_password_hash(&self) -> Option<&str> {
|
||||
self.access_password_hash.as_deref()
|
||||
pub fn mid_roll_rule(&self) -> Option<&MidRollRule> {
|
||||
self.mid_roll_rule.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,14 +433,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::{Chapter, ChannelId, ContentType, MediaItemId, MediaRole, PlaybackRecordId};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaItem {
|
||||
@@ -10,14 +10,27 @@ 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,
|
||||
#[serde(default)]
|
||||
chapters: Vec<Chapter>,
|
||||
}
|
||||
|
||||
pub struct MediaItemRow {
|
||||
@@ -34,6 +47,13 @@ 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,
|
||||
pub chapters: Vec<Chapter>,
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
@@ -57,6 +77,48 @@ 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(),
|
||||
chapters: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
chapters: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +137,13 @@ 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,
|
||||
chapters: row.chapters,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +198,34 @@ 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
|
||||
}
|
||||
|
||||
pub fn chapters(&self) -> &[Chapter] {
|
||||
&self.chapters
|
||||
}
|
||||
}
|
||||
|
||||
#[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};
|
||||
|
||||
@@ -94,7 +94,7 @@ fn programming_block_getters() {
|
||||
assert_eq!(block.start_time(), t(8, 0));
|
||||
assert_eq!(block.duration_mins(), 120);
|
||||
assert!(block.loop_on_finish());
|
||||
assert!(!block.ignore_recycle_policy());
|
||||
assert!(!block.ignore_rotation_policy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -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");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use crate::value_objects::PlaybackRecordId;
|
||||
use crate::value_objects::{Chapter, PlaybackRecordId};
|
||||
|
||||
#[test]
|
||||
fn media_item_new_defaults() {
|
||||
@@ -15,6 +15,33 @@ 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);
|
||||
assert!(item.chapters().is_empty());
|
||||
}
|
||||
|
||||
#[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 +60,13 @@ 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,
|
||||
chapters: vec![Chapter::new(Some("Intro".into()), 0.0, 30.0)],
|
||||
});
|
||||
assert_eq!(item.title(), "Breaking Bad S01E01");
|
||||
assert_eq!(item.series_name(), Some("Breaking Bad"));
|
||||
@@ -40,6 +74,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]
|
||||
|
||||
@@ -3,10 +3,10 @@ use super::*;
|
||||
#[test]
|
||||
fn new_generates_id_and_timestamp() {
|
||||
let email = Email::new("test@example.com").unwrap();
|
||||
let user = User::new("oidc|123", email);
|
||||
let user = User::new("external|123", email);
|
||||
assert!(!user.is_admin());
|
||||
assert!(user.password_hash().is_none());
|
||||
assert_eq!(user.subject(), "oidc|123");
|
||||
assert_eq!(user.subject(), "external|123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::errors::DomainResult;
|
||||
pub use crate::events::DomainEvent;
|
||||
pub use crate::events::{DomainEvent, EventEnvelope};
|
||||
|
||||
#[async_trait]
|
||||
pub trait EventPublisher: Send + Sync {
|
||||
@@ -10,7 +10,9 @@ pub trait EventPublisher: Send + Sync {
|
||||
|
||||
#[async_trait]
|
||||
pub trait EventConsumer: Send + Sync {
|
||||
async fn recv(&self) -> DomainResult<DomainEvent>;
|
||||
async fn poll_next(&self) -> DomainResult<Option<EventEnvelope>>;
|
||||
async fn ack(&self, event_id: i64) -> DomainResult<()>;
|
||||
async fn nack(&self, event_id: i64, error: &str) -> DomainResult<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,20 +3,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::errors::{DomainError, DomainResult};
|
||||
use crate::models::MediaItem;
|
||||
use crate::value_objects::{ContentType, MediaFilter, MediaItemId};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StreamQuality {
|
||||
Direct,
|
||||
Transcode(u32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StreamingProtocol {
|
||||
Hls,
|
||||
DirectFile,
|
||||
}
|
||||
use crate::value_objects::{ContentType, MediaFilter, MediaItemId, SourceUri};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderCapabilities {
|
||||
@@ -26,9 +13,7 @@ pub struct ProviderCapabilities {
|
||||
pub tags: bool,
|
||||
pub decade: bool,
|
||||
pub search: bool,
|
||||
pub streaming_protocol: StreamingProtocol,
|
||||
pub rescan: bool,
|
||||
pub transcode: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -55,11 +40,7 @@ pub trait IMediaProvider: Send + Sync {
|
||||
|
||||
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>>;
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
item_id: &MediaItemId,
|
||||
quality: &StreamQuality,
|
||||
) -> DomainResult<String>;
|
||||
async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri>;
|
||||
|
||||
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
@@ -95,11 +76,7 @@ pub trait IProviderRegistry: Send + Sync {
|
||||
|
||||
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>>;
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
item_id: &MediaItemId,
|
||||
quality: &StreamQuality,
|
||||
) -> DomainResult<String>;
|
||||
async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri>;
|
||||
|
||||
fn provider_ids(&self) -> Vec<String>;
|
||||
|
||||
|
||||
@@ -13,11 +13,10 @@ pub mod user;
|
||||
pub use activity::{ActivityLogCommand, ActivityLogQuery};
|
||||
pub use auth::{AuthService, TokenService};
|
||||
pub use channel::{ChannelCommand, ChannelQuery};
|
||||
pub use events::{DomainEvent, EventConsumer, EventHandler, EventPublisher};
|
||||
pub use events::{DomainEvent, EventConsumer, EventEnvelope, EventHandler, EventPublisher};
|
||||
pub use library::{LibraryCommand, LibraryQuery, LibrarySyncAdapter};
|
||||
pub use media::{
|
||||
Collection, IMediaProvider, IProviderRegistry, ProviderCapabilities, SeriesSummary,
|
||||
StreamQuality, StreamingProtocol,
|
||||
};
|
||||
pub use provider_config::{ProviderConfigCommand, ProviderConfigQuery};
|
||||
pub use schedule::{ScheduleCommand, ScheduleQuery};
|
||||
|
||||
@@ -34,6 +34,15 @@ pub(super) fn fill_block<'a>(
|
||||
}
|
||||
result
|
||||
}
|
||||
FillStrategy::Alternating => {
|
||||
fill_alternating(candidates, pool, target_secs)
|
||||
}
|
||||
FillStrategy::Weighted => {
|
||||
fill_weighted(candidates, pool, target_secs)
|
||||
}
|
||||
FillStrategy::Marathon => {
|
||||
fill_marathon(candidates, pool, target_secs, loop_on_finish)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +127,199 @@ pub(super) fn fill_sequential<'a>(
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn fill_alternating<'a>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
target_secs: u32,
|
||||
) -> Vec<&'a MediaItem> {
|
||||
if pool.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let available: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
|
||||
|
||||
let mut groups: Vec<Vec<&MediaItem>> = Vec::new();
|
||||
let mut seen_series: HashSet<Option<&str>> = HashSet::new();
|
||||
|
||||
for item in candidates {
|
||||
if !available.contains(item.id()) {
|
||||
continue;
|
||||
}
|
||||
let key = item.series_name();
|
||||
if !seen_series.contains(&key) {
|
||||
seen_series.insert(key);
|
||||
groups.push(Vec::new());
|
||||
}
|
||||
let group_idx = groups.len() - 1;
|
||||
// Find the group for this series
|
||||
let idx = groups
|
||||
.iter()
|
||||
.position(|g| {
|
||||
g.first()
|
||||
.map(|f| f.series_name() == item.series_name())
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.unwrap_or(group_idx);
|
||||
groups[idx].push(item);
|
||||
}
|
||||
|
||||
if groups.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut cursors: Vec<usize> = vec![0; groups.len()];
|
||||
let mut remaining = target_secs;
|
||||
let mut result = Vec::new();
|
||||
let mut stale_count = 0;
|
||||
|
||||
loop {
|
||||
if stale_count >= groups.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
for (gi, group) in groups.iter().enumerate() {
|
||||
if remaining == 0 {
|
||||
return result;
|
||||
}
|
||||
if cursors[gi] >= group.len() {
|
||||
stale_count += 1;
|
||||
continue;
|
||||
}
|
||||
let item = group[cursors[gi]];
|
||||
if item.duration_secs() <= remaining {
|
||||
remaining -= item.duration_secs();
|
||||
result.push(item);
|
||||
cursors[gi] += 1;
|
||||
stale_count = 0;
|
||||
} else {
|
||||
cursors[gi] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn fill_weighted<'a>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
target_secs: u32,
|
||||
) -> Vec<&'a MediaItem> {
|
||||
if pool.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let pool_ids: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
|
||||
let candidate_ids: HashSet<&MediaItemId> = candidates.iter().map(|i| i.id()).collect();
|
||||
|
||||
let mut fresh: Vec<&MediaItem> = pool
|
||||
.iter()
|
||||
.filter(|i| !candidate_ids.contains(i.id()) || pool_ids.contains(i.id()))
|
||||
.collect();
|
||||
|
||||
let all_in_pool: Vec<&MediaItem> = pool.iter().collect();
|
||||
|
||||
let mut rng = StdRng::from_entropy();
|
||||
fresh.shuffle(&mut rng);
|
||||
|
||||
let mut remaining = target_secs;
|
||||
let mut result = Vec::new();
|
||||
let mut used: HashSet<&MediaItemId> = HashSet::new();
|
||||
|
||||
for item in &fresh {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
if used.contains(item.id()) {
|
||||
continue;
|
||||
}
|
||||
if item.duration_secs() <= remaining {
|
||||
remaining -= item.duration_secs();
|
||||
used.insert(item.id());
|
||||
result.push(*item);
|
||||
}
|
||||
}
|
||||
|
||||
if remaining > 0 {
|
||||
let mut rest: Vec<&MediaItem> = all_in_pool
|
||||
.iter()
|
||||
.filter(|i| !used.contains(i.id()))
|
||||
.copied()
|
||||
.collect();
|
||||
rest.shuffle(&mut rng);
|
||||
for item in rest {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
if item.duration_secs() <= remaining {
|
||||
remaining -= item.duration_secs();
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn fill_marathon<'a>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
target_secs: u32,
|
||||
loop_on_finish: bool,
|
||||
) -> Vec<&'a MediaItem> {
|
||||
if pool.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let available: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
|
||||
|
||||
let ordered: Vec<&MediaItem> = candidates
|
||||
.iter()
|
||||
.filter(|item| available.contains(item.id()))
|
||||
.collect();
|
||||
|
||||
if ordered.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut remaining = target_secs;
|
||||
let mut result = Vec::new();
|
||||
|
||||
if loop_on_finish {
|
||||
let mut idx = 0;
|
||||
while remaining > 0 {
|
||||
let item = ordered[idx % ordered.len()];
|
||||
if item.duration_secs() <= remaining {
|
||||
remaining -= item.duration_secs();
|
||||
result.push(item);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
idx += 1;
|
||||
if idx > ordered.len() * 100 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for item in &ordered {
|
||||
if item.duration_secs() <= remaining {
|
||||
remaining -= item.duration_secs();
|
||||
result.push(*item);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
if let Some(&first) = ordered.first() {
|
||||
result.push(first);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/fill.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -8,11 +8,11 @@ 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, RecyclePolicy, Weekday};
|
||||
use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery};
|
||||
use crate::value_objects::{BlockId, ChannelId, FillStrategy, LibrarySearchFilter, MediaItemId, RotationPolicy, Weekday};
|
||||
|
||||
mod fill;
|
||||
mod recycle;
|
||||
mod rotation;
|
||||
|
||||
const SCHEDULE_DURATION_DAYS: i64 = 7;
|
||||
|
||||
@@ -22,23 +22,22 @@ 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,
|
||||
ignore_recycle_policy: bool,
|
||||
ignore_rotation_policy: bool,
|
||||
}
|
||||
|
||||
struct RecycleContext<'a> {
|
||||
struct RotationContext<'a> {
|
||||
history: &'a [PlaybackRecord],
|
||||
policy: &'a RecyclePolicy,
|
||||
policy: &'a RotationPolicy,
|
||||
generation: u32,
|
||||
last_item_id: Option<&'a MediaItemId>,
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -131,9 +130,9 @@ impl ScheduleEngineService {
|
||||
start: slot_start,
|
||||
end: slot_end,
|
||||
},
|
||||
RecycleContext {
|
||||
RotationContext {
|
||||
history: &history,
|
||||
policy: channel.recycle_policy(),
|
||||
policy: channel.rotation_policy(),
|
||||
generation,
|
||||
last_item_id,
|
||||
},
|
||||
@@ -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,
|
||||
@@ -255,29 +246,27 @@ impl ScheduleEngineService {
|
||||
&self,
|
||||
block: &ProgrammingBlock,
|
||||
window: BlockTimeWindow,
|
||||
recycle: RecycleContext<'_>,
|
||||
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(),
|
||||
loop_on_finish: block.loop_on_finish(),
|
||||
ignore_recycle_policy: block.ignore_recycle_policy(),
|
||||
ignore_rotation_policy: block.ignore_rotation_policy(),
|
||||
},
|
||||
window,
|
||||
recycle,
|
||||
rotation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -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));
|
||||
@@ -313,21 +302,19 @@ impl ScheduleEngineService {
|
||||
&self,
|
||||
params: AlgorithmicParams<'_>,
|
||||
window: BlockTimeWindow,
|
||||
recycle: RecycleContext<'_>,
|
||||
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![]);
|
||||
}
|
||||
|
||||
let pool = if params.ignore_recycle_policy {
|
||||
let pool = if params.ignore_rotation_policy {
|
||||
candidates.clone()
|
||||
} else {
|
||||
recycle::apply_recycle_policy(&candidates, recycle.history, recycle.policy, recycle.generation)
|
||||
rotation::apply_rotation_policy(&candidates, rotation.history, rotation.policy, rotation.generation)
|
||||
};
|
||||
let target_secs = (window.end - window.start).num_seconds() as u32;
|
||||
let selected = fill::fill_block(
|
||||
@@ -335,7 +322,7 @@ impl ScheduleEngineService {
|
||||
&pool,
|
||||
target_secs,
|
||||
params.strategy,
|
||||
recycle.last_item_id,
|
||||
rotation.last_item_id,
|
||||
params.loop_on_finish,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ use std::collections::HashSet;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::models::{MediaItem, PlaybackRecord};
|
||||
use crate::value_objects::{MediaItemId, RecyclePolicy};
|
||||
use crate::value_objects::{MediaItemId, RotationPolicy};
|
||||
|
||||
pub(super) fn apply_recycle_policy(
|
||||
pub(super) fn apply_rotation_policy(
|
||||
candidates: &[MediaItem],
|
||||
history: &[PlaybackRecord],
|
||||
policy: &RecyclePolicy,
|
||||
policy: &RotationPolicy,
|
||||
current_generation: u32,
|
||||
) -> Vec<MediaItem> {
|
||||
let now = Utc::now();
|
||||
@@ -41,7 +41,7 @@ pub(super) fn apply_recycle_policy(
|
||||
(candidates.len() as f32 * policy.min_available_ratio).ceil() as usize;
|
||||
|
||||
if available.len() < min_count {
|
||||
// Pool too small after cooldowns — recycle everything
|
||||
// Pool too small after cooldowns — rotate everything back in
|
||||
candidates.to_vec()
|
||||
} else {
|
||||
available
|
||||
@@ -49,5 +49,5 @@ pub(super) fn apply_recycle_policy(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/recycle.rs"]
|
||||
#[path = "tests/rotation.rs"]
|
||||
mod tests;
|
||||
@@ -68,3 +68,148 @@ fn random_fill_respects_budget() {
|
||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
|
||||
fn episode(id: &str, series: &str, secs: u32) -> MediaItem {
|
||||
let mut i = item(id, secs);
|
||||
// Use serde to set series_name since fields are private
|
||||
let mut val = serde_json::to_value(&i).unwrap();
|
||||
val["series_name"] = serde_json::Value::String(series.into());
|
||||
serde_json::from_value(val).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_cycles_through_series() {
|
||||
let candidates = vec![
|
||||
episode("s1e1", "Show A", 60),
|
||||
episode("s1e2", "Show A", 60),
|
||||
episode("s2e1", "Show B", 60),
|
||||
episode("s2e2", "Show B", 60),
|
||||
];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_alternating(&candidates, &pool, 240);
|
||||
assert_eq!(result.len(), 4);
|
||||
assert_eq!(result[0].series_name(), Some("Show A"));
|
||||
assert_eq!(result[1].series_name(), Some("Show B"));
|
||||
assert_eq!(result[2].series_name(), Some("Show A"));
|
||||
assert_eq!(result[3].series_name(), Some("Show B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_respects_budget() {
|
||||
let candidates = vec![
|
||||
episode("s1e1", "Show A", 100),
|
||||
episode("s2e1", "Show B", 100),
|
||||
];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_alternating(&candidates, &pool, 150);
|
||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_empty_pool() {
|
||||
let candidates: Vec<MediaItem> = vec![];
|
||||
let pool: Vec<MediaItem> = vec![];
|
||||
let result = fill_alternating(&candidates, &pool, 300);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_respects_budget() {
|
||||
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
|
||||
let candidates = pool.clone();
|
||||
let result = fill_weighted(&candidates, &pool, 200);
|
||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_no_duplicates() {
|
||||
let pool = vec![item("a", 50), item("b", 50), item("c", 50)];
|
||||
let candidates = pool.clone();
|
||||
let result = fill_weighted(&candidates, &pool, 150);
|
||||
let ids: Vec<&str> = result.iter().map(|i| i.id().value()).collect();
|
||||
let unique: HashSet<&str> = ids.iter().copied().collect();
|
||||
assert_eq!(ids.len(), unique.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_empty_pool() {
|
||||
let candidates: Vec<MediaItem> = vec![];
|
||||
let pool: Vec<MediaItem> = vec![];
|
||||
let result = fill_weighted(&candidates, &pool, 300);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marathon_starts_from_beginning() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60), item("ep3", 60)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_marathon(&candidates, &pool, 180, false);
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].id().value(), "ep1");
|
||||
assert_eq!(result[1].id().value(), "ep2");
|
||||
assert_eq!(result[2].id().value(), "ep3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marathon_loops_when_enabled() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_marathon(&candidates, &pool, 240, true);
|
||||
assert_eq!(result.len(), 4);
|
||||
assert_eq!(result[0].id().value(), "ep1");
|
||||
assert_eq!(result[1].id().value(), "ep2");
|
||||
assert_eq!(result[2].id().value(), "ep1");
|
||||
assert_eq!(result[3].id().value(), "ep2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marathon_stops_without_loop() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_marathon(&candidates, &pool, 300, false);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marathon_empty_pool() {
|
||||
let candidates: Vec<MediaItem> = vec![];
|
||||
let pool: Vec<MediaItem> = vec![];
|
||||
let result = fill_marathon(&candidates, &pool, 300, true);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marathon_oversize_single_item() {
|
||||
let candidates = vec![item("ep1", 9999)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_marathon(&candidates, &pool, 60, true);
|
||||
assert_eq!(result.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_block_dispatches_alternating() {
|
||||
let candidates = vec![item("a", 100), item("b", 100)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Alternating, None, true);
|
||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_block_dispatches_weighted() {
|
||||
let candidates = vec![item("a", 100), item("b", 100)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Weighted, None, true);
|
||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_block_dispatches_marathon() {
|
||||
let candidates = vec![item("a", 100), item("b", 100)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Marathon, None, true);
|
||||
assert_eq!(result[0].id().value(), "a");
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ fn record(item_id: &str, generation: u32) -> PlaybackRecord {
|
||||
#[test]
|
||||
fn no_history_returns_all() {
|
||||
let pool = vec![item("a"), item("b"), item("c")];
|
||||
let policy = RecyclePolicy {
|
||||
let policy = RotationPolicy {
|
||||
cooldown_days: Some(7),
|
||||
cooldown_generations: None,
|
||||
min_available_ratio: 0.2,
|
||||
};
|
||||
let result = apply_recycle_policy(&pool, &[], &policy, 1);
|
||||
let result = apply_rotation_policy(&pool, &[], &policy, 1);
|
||||
assert_eq!(result.len(), 3);
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ fn no_history_returns_all() {
|
||||
fn generation_cooldown_excludes() {
|
||||
let pool = vec![item("a"), item("b"), item("c")];
|
||||
let history = vec![record("a", 1)];
|
||||
let policy = RecyclePolicy {
|
||||
let policy = RotationPolicy {
|
||||
cooldown_days: None,
|
||||
cooldown_generations: Some(2),
|
||||
min_available_ratio: 0.0,
|
||||
};
|
||||
let result = apply_recycle_policy(&pool, &history, &policy, 2);
|
||||
let result = apply_rotation_policy(&pool, &history, &policy, 2);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(result.iter().all(|i| i.id().value() != "a"));
|
||||
}
|
||||
@@ -40,11 +40,11 @@ fn generation_cooldown_excludes() {
|
||||
fn min_available_ratio_waives_cooldown() {
|
||||
let pool = vec![item("a"), item("b")];
|
||||
let history = vec![record("a", 1), record("b", 1)];
|
||||
let policy = RecyclePolicy {
|
||||
let policy = RotationPolicy {
|
||||
cooldown_days: None,
|
||||
cooldown_generations: Some(5),
|
||||
min_available_ratio: 0.5,
|
||||
};
|
||||
let result = apply_recycle_policy(&pool, &history, &policy, 2);
|
||||
let result = apply_rotation_policy(&pool, &history, &policy, 2);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::errors::DomainResult;
|
||||
use crate::events::{DomainEvent, EventEnvelope};
|
||||
use crate::models::{
|
||||
ActivityEvent, Channel, ChannelConfigSnapshot, GeneratedSchedule, LibraryCollection,
|
||||
LibraryItem, LibrarySyncLogEntry, LibrarySyncResult, PlaybackRecord, ProviderConfigRow,
|
||||
LibrarySyncLogEntry, LibrarySyncResult, MediaItem, PlaybackRecord, ProviderConfigRow,
|
||||
ScheduleConfig, SeasonSummary, ShowSummary,
|
||||
};
|
||||
use crate::ports::{
|
||||
ActivityLogCommand, ActivityLogQuery, AppSettingsRepository, ChannelCommand, ChannelQuery,
|
||||
LibraryCommand, LibraryQuery, ProviderConfigCommand, ProviderConfigQuery, ScheduleCommand,
|
||||
ScheduleQuery, TranscodeSettingsRepository, UserCommand, UserQuery,
|
||||
EventConsumer, EventPublisher, LibraryCommand, LibraryQuery, ProviderConfigCommand,
|
||||
ProviderConfigQuery, ScheduleCommand, ScheduleQuery, TranscodeSettingsRepository, UserCommand,
|
||||
UserQuery,
|
||||
};
|
||||
use crate::value_objects::{
|
||||
BlockId, ChannelId, ContentType, LibrarySearchFilter, MediaItemId, ScheduleId, SnapshotId,
|
||||
@@ -367,7 +369,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 +392,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 +445,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 +478,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())
|
||||
}
|
||||
|
||||
@@ -777,3 +779,63 @@ impl TranscodeSettingsRepository for InMemoryTranscodeSettings {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InMemoryEventBus {
|
||||
queue: Mutex<VecDeque<(i64, DomainEvent)>>,
|
||||
next_id: Mutex<i64>,
|
||||
}
|
||||
|
||||
impl InMemoryEventBus {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
queue: Mutex::new(VecDeque::new()),
|
||||
next_id: Mutex::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn events(&self) -> Vec<DomainEvent> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(_, e)| e.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryEventBus {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for InMemoryEventBus {
|
||||
async fn publish(&self, event: DomainEvent) -> DomainResult<()> {
|
||||
let mut next = self.next_id.lock().unwrap();
|
||||
let id = *next;
|
||||
*next += 1;
|
||||
self.queue.lock().unwrap().push_back((id, event));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventConsumer for InMemoryEventBus {
|
||||
async fn poll_next(&self) -> DomainResult<Option<EventEnvelope>> {
|
||||
let front = self.queue.lock().unwrap().front().cloned();
|
||||
Ok(front.map(|(id, event)| EventEnvelope::new(id, event)))
|
||||
}
|
||||
|
||||
async fn ack(&self, event_id: i64) -> DomainResult<()> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(id, _)| *id != event_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn nack(&self, _event_id: i64, _error: &str) -> DomainResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::errors::DomainResult;
|
||||
use crate::events::DomainEvent;
|
||||
use crate::events::{DomainEvent, EventEnvelope};
|
||||
use crate::models::{
|
||||
ActivityEvent, LibrarySyncResult, MediaItem,
|
||||
};
|
||||
use crate::ports::{
|
||||
ActivityLogCommand, ActivityLogQuery, EventPublisher, IMediaProvider, LibrarySyncAdapter,
|
||||
ProviderCapabilities, StreamQuality, StreamingProtocol,
|
||||
ActivityLogCommand, ActivityLogQuery, EventConsumer, EventPublisher, IMediaProvider,
|
||||
LibrarySyncAdapter, ProviderCapabilities,
|
||||
};
|
||||
use crate::value_objects::{ChannelId, MediaFilter, MediaItemId};
|
||||
use crate::value_objects::{ChannelId, MediaFilter, MediaItemId, SourceUri};
|
||||
|
||||
pub struct NoopEventPublisher;
|
||||
|
||||
@@ -32,6 +32,35 @@ impl EventPublisher for NoopEventPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoopEventConsumer;
|
||||
|
||||
impl NoopEventConsumer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NoopEventConsumer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventConsumer for NoopEventConsumer {
|
||||
async fn poll_next(&self) -> DomainResult<Option<EventEnvelope>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn ack(&self, _event_id: i64) -> DomainResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn nack(&self, _event_id: i64, _error: &str) -> DomainResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoopMediaProvider;
|
||||
|
||||
impl NoopMediaProvider {
|
||||
@@ -56,9 +85,7 @@ impl IMediaProvider for NoopMediaProvider {
|
||||
tags: false,
|
||||
decade: false,
|
||||
search: false,
|
||||
streaming_protocol: StreamingProtocol::Hls,
|
||||
rescan: false,
|
||||
transcode: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +97,7 @@ impl IMediaProvider for NoopMediaProvider {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
_item_id: &MediaItemId,
|
||||
_quality: &StreamQuality,
|
||||
) -> DomainResult<String> {
|
||||
async fn get_source_uri(&self, _item_id: &MediaItemId) -> DomainResult<SourceUri> {
|
||||
Err(crate::errors::DomainError::InfrastructureError(
|
||||
"NoopMediaProvider does not support streaming".into(),
|
||||
))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user