Compare commits

...

7 Commits

Author SHA1 Message Date
60524c56f7 add domain concepts: FillStrategy variants, Chapter, InterstitialRule, MidRollRule, gap_filler
- FillStrategy: +Alternating (round-robin series), +Weighted (fresh-first), +Marathon (always ep1, loops)
- Chapter value object on MediaItem (JSON column in library_items)
- InterstitialRule + MidRollRule on ProgrammingBlock (data model only)
- gap_filler: Option<MediaFilter> on Channel (persisted as JSON column)
- migration: 20260712000002_add_chapters_and_gap_filler.sql
2026-07-12 07:41:57 +02:00
39f5f99bfd refactor: replace get_stream_url with get_source_uri per ADR-0002
Providers now expose a source URI (for FFmpeg) instead of a
viewer-facing stream URL. Playout Service will own transcoding.

- Add SourceUri value object (NetworkUrl | FilePath)
- Remove StreamQuality, StreamingProtocol from domain
- Simplify ProviderCapabilities (drop streaming_protocol, transcode)
- Jellyfin: return static direct stream URL
- Local files: return absolute file path
- Rename use case get_stream_url -> get_source
- Stream endpoint returns JSON SourceUri instead of 307 redirect
2026-07-12 07:34:13 +02:00
abcf69ce7e extract background tasks into worker binary
Move auto_scheduler, library_sync, broadcast_poller, webhook_consumer
from presentation/background/ into crates/worker/src/jobs/.
Presentation is now a pure HTTP server.
2026-07-12 07:28:17 +02:00
826e824b58 replace tokio::broadcast event bus with SQLite-backed event queue (ADR-0003)
- DomainEvent: add Serialize/Deserialize
- EventConsumer port: poll_next/ack/nack replacing recv()
- EventEnvelope: wraps event with id/retry_count/created_at
- SqliteEventPublisher/SqliteEventConsumer: INSERT/poll/DLQ
- migration: event_queue + dead_letter_queue tables
- InMemoryEventBus: combined publisher+consumer test double
- NoopEventConsumer: test stub
- webhook_consumer: polls EventConsumer instead of broadcast::Receiver
- presentation+mcp wiring: create from SqlitePool
2026-07-12 07:23:21 +02:00
e2393be635 collapse 20 pass-through use cases; handlers call ports directly
delete get/list/list_by_owner channels, get_settings/activity_log admin,
get_item/get_sync_status/list_collections/list_seasons/list_shows/list_genres library,
get/list/delete providers, get/list/patch_label config_snapshots,
get_active/list_history/delete_after schedule — all single-delegation.

remove ChannelQueryDeps, LibraryQueryDeps, deleted query/command structs.
add direct port fields to AppState. update MCP crate accordingly.
2026-07-12 07:18:26 +02:00
a6558e15b2 kill LibraryItem, unify to MediaItem; decouple schedule engine from providers
ADR-0001: MediaItem absorbs LibraryItem fields (provider_id, external_id,
collection_name, collection_type, synced_at, role/MediaRole).
LibraryItem + LibraryItemRow deleted. All ports/adapters/tests updated.

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

BlockContent provider_id field removed (meaningless when querying library).
2026-07-12 07:02:08 +02:00
773e228e21 rename RecyclePolicy→RotationPolicy, collapse AccessMode, clean OIDC refs 2026-07-12 06:50:25 +02:00
128 changed files with 2040 additions and 2371 deletions

31
Cargo.lock generated
View File

@@ -34,7 +34,11 @@ name = "adapter-event-publisher"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono",
"domain", "domain",
"serde",
"serde_json",
"sqlx",
"tokio", "tokio",
"tracing", "tracing",
] ]
@@ -1804,9 +1808,7 @@ dependencies = [
"chrono", "chrono",
"domain", "domain",
"dotenvy", "dotenvy",
"handlebars",
"infra-wiring", "infra-wiring",
"reqwest",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
@@ -3365,6 +3367,31 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 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]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.3" version = "0.6.3"

View File

@@ -1,5 +1,5 @@
[workspace] [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"] exclude = ["k-tv-backend", "k-tv-frontend"]
resolver = "2" resolver = "2"

View File

@@ -1,5 +1,5 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{DomainError, RecyclePolicy, ScheduleConfig, ScheduleConfigCompat}; use domain::{DomainError, RotationPolicy, ScheduleConfig, ScheduleConfigCompat};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use uuid::Uuid; use uuid::Uuid;
@@ -32,8 +32,8 @@ pub fn parse_schedule_config(json: &str) -> Result<ScheduleConfig, DomainError>
Ok(ScheduleConfig::from(compat)) Ok(ScheduleConfig::from(compat))
} }
pub fn parse_recycle_policy(json: &str) -> Result<RecyclePolicy, DomainError> { pub fn parse_rotation_policy(json: &str) -> Result<RotationPolicy, DomainError> {
parse_json(json, "recycle_policy") parse_json(json, "rotation_policy")
} }
pub fn parse_enum_or_default<T: DeserializeOwned + Default>(value: String) -> T { pub fn parse_enum_or_default<T: DeserializeOwned + Default>(value: String) -> T {
@@ -151,9 +151,9 @@ mod tests {
} }
#[test] #[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 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)); assert_eq!(policy.cooldown_days, Some(7));
} }

View File

@@ -8,3 +8,7 @@ domain = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
sqlx = { workspace = true, features = ["sqlite"] }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }

View File

@@ -1,42 +1,170 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::errors::{DomainError, DomainResult}; use domain::errors::{DomainError, DomainResult};
use domain::events::DomainEvent; use domain::events::{DomainEvent, EventEnvelope};
use domain::ports::events::{EventConsumer, EventPublisher}; use domain::ports::events::{EventConsumer, EventPublisher};
use tokio::sync::broadcast; use sqlx::SqlitePool;
pub struct ChannelEventBus { fn event_type_label(event: &DomainEvent) -> &'static str {
tx: broadcast::Sender<DomainEvent>, 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 struct SqliteEventPublisher {
pub fn new(capacity: usize) -> Self { pool: SqlitePool,
let (tx, _) = broadcast::channel(capacity);
Self { tx }
} }
pub fn subscriber(&self) -> broadcast::Receiver<DomainEvent> { impl SqliteEventPublisher {
self.tx.subscribe() pub fn new(pool: SqlitePool) -> Self {
} Self { pool }
pub fn sender(&self) -> broadcast::Sender<DomainEvent> {
self.tx.clone()
} }
} }
#[async_trait] #[async_trait]
impl EventPublisher for ChannelEventBus { impl EventPublisher for SqliteEventPublisher {
async fn publish(&self, event: DomainEvent) -> DomainResult<()> { 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(()) Ok(())
} }
} }
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] #[async_trait]
impl EventConsumer for ChannelEventBus { impl EventConsumer for SqliteEventConsumer {
async fn recv(&self) -> DomainResult<DomainEvent> { async fn poll_next(&self) -> DomainResult<Option<EventEnvelope>> {
let mut rx = self.tx.subscribe(); let row: Option<EventRow> = sqlx::query_as(
rx.recv() "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 .await
.map_err(|e| DomainError::InfrastructureError(e.to_string())) .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(())
}

View File

@@ -1,4 +1,4 @@
use domain::{ContentType, MediaItem, MediaItemId, MediaItemRow}; use domain::{ContentType, MediaItem, MediaItemId, MediaItemRow, MediaRole};
use crate::models::JellyfinItem; use crate::models::JellyfinItem;
@@ -17,7 +17,7 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
.unwrap_or(0); .unwrap_or(0);
Some(MediaItem::from_persistence(MediaItemRow { Some(MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new(item.id), id: MediaItemId::new(&item.id),
title: item.name, title: item.name,
content_type, content_type,
duration_secs, duration_secs,
@@ -30,5 +30,12 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
episode_number: item.index_number, episode_number: item.index_number,
thumbnail_url: None, thumbnail_url: None,
collection_id: None, collection_id: None,
provider_id: String::new(),
external_id: item.id,
collection_name: None,
collection_type: None,
synced_at: None,
role: MediaRole::default(),
chapters: Vec::new(),
})) }))
} }

View File

@@ -37,20 +37,6 @@ pub(crate) struct JellyfinItem {
pub recursive_item_count: Option<u32>, 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 { pub(crate) fn jellyfin_item_type(ct: &ContentType) -> &'static str {
match ct { match ct {
ContentType::Movie => "Movie", ContentType::Movie => "Movie",

View File

@@ -1,18 +1,11 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{ use domain::ports::{Collection, IMediaProvider, ProviderCapabilities, SeriesSummary};
Collection, IMediaProvider, ProviderCapabilities, SeriesSummary, StreamQuality, use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, SourceUri};
StreamingProtocol,
};
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId};
use crate::config::JellyfinConfig; use crate::config::JellyfinConfig;
use crate::mapping::{map_jellyfin_item, TICKS_PER_SEC}; use crate::mapping::{map_jellyfin_item, TICKS_PER_SEC};
use crate::models::{ use crate::models::{jellyfin_item_type, JellyfinItemsResponse};
jellyfin_item_type, JellyfinItemsResponse, JellyfinPlaybackInfoResponse,
};
const FALLBACK_HLS_BITRATE: u32 = 8_000_000;
pub struct JellyfinMediaProvider { pub struct JellyfinMediaProvider {
client: reqwest::Client, client: reqwest::Client,
@@ -129,16 +122,6 @@ impl JellyfinMediaProvider {
Ok(items) 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] #[async_trait]
@@ -151,9 +134,7 @@ impl IMediaProvider for JellyfinMediaProvider {
tags: true, tags: true,
decade: true, decade: true,
search: true, search: true,
streaming_protocol: StreamingProtocol::Hls,
rescan: false, rescan: false,
transcode: false,
} }
} }
@@ -350,52 +331,14 @@ impl IMediaProvider for JellyfinMediaProvider {
Ok(body.items.into_iter().map(|item| item.name).collect()) Ok(body.items.into_iter().map(|item| item.name).collect())
} }
async fn get_stream_url( async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri> {
&self, Ok(SourceUri::NetworkUrl {
item_id: &MediaItemId, url: format!(
quality: &StreamQuality, "{}/Videos/{}/stream?static=true&api_key={}",
) -> DomainResult<String> {
match quality {
StreamQuality::Direct => {
let url = format!(
"{}/Items/{}/PlaybackInfo",
self.config.base_url, self.config.base_url,
item_id.as_ref() item_id.as_ref(),
); self.config.api_key,
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)),
}
} }
} }

View File

@@ -30,7 +30,7 @@ impl LocalFilesBundle {
}); });
let provider = let provider =
LocalFilesProvider::new(Arc::clone(&local_index), &config, transcode_manager.clone()); LocalFilesProvider::new(Arc::clone(&local_index), &config);
Self { Self {
provider, provider,

View File

@@ -1,36 +1,23 @@
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{ use domain::ports::{Collection, IMediaProvider, ProviderCapabilities};
Collection, IMediaProvider, ProviderCapabilities, StreamQuality, StreamingProtocol, use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, MediaItemRow, MediaRole, SourceUri};
};
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, MediaItemRow};
use crate::config::LocalFilesConfig; use crate::config::LocalFilesConfig;
use crate::index::{decode_id, LocalIndex}; use crate::index::{decode_id, LocalIndex};
use crate::scanner::LocalFileItem; use crate::scanner::LocalFileItem;
use crate::transcoder::TranscodeManager;
pub struct LocalFilesProvider { pub struct LocalFilesProvider {
pub index: Arc<LocalIndex>, pub index: Arc<LocalIndex>,
base_url: String,
transcode_manager: Option<Arc<TranscodeManager>>,
} }
const SHORT_DURATION_SECS: u32 = 1200; const SHORT_DURATION_SECS: u32 = 1200;
const DECADE_SPAN: u16 = 9; const DECADE_SPAN: u16 = 9;
impl LocalFilesProvider { impl LocalFilesProvider {
pub fn new( pub fn new(index: Arc<LocalIndex>, _config: &LocalFilesConfig) -> Self {
index: Arc<LocalIndex>, Self { index }
config: &LocalFilesConfig,
transcode_manager: Option<Arc<TranscodeManager>>,
) -> Self {
Self {
index,
base_url: config.base_url.trim_end_matches('/').to_string(),
transcode_manager,
}
} }
} }
@@ -54,6 +41,13 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
episode_number: None, episode_number: None,
thumbnail_url: None, thumbnail_url: None,
collection_id: None, collection_id: None,
provider_id: String::new(),
external_id: String::new(),
collection_name: None,
collection_type: None,
synced_at: None,
role: MediaRole::default(),
chapters: Vec::new(),
}) })
} }
@@ -67,13 +61,7 @@ impl IMediaProvider for LocalFilesProvider {
tags: true, tags: true,
decade: true, decade: true,
search: true, search: true,
streaming_protocol: if self.transcode_manager.is_some() {
StreamingProtocol::Hls
} else {
StreamingProtocol::DirectFile
},
rescan: true, 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))) .map(|item| to_media_item(item_id.clone(), &item)))
} }
async fn get_stream_url( async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri> {
&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(|| { let rel = decode_id(item_id).ok_or_else(|| {
DomainError::InfrastructureError("invalid item id encoding".into()) DomainError::InfrastructureError("invalid item id encoding".into())
})?; })?;
let src = self.index.root_dir.join(&rel); let abs_path = self.index.root_dir.join(&rel);
tm.ensure_transcoded(item_id.as_ref(), &src).await?; Ok(SourceUri::FilePath {
Ok(format!( path: abs_path.to_string_lossy().into_owned(),
"{}/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 list_collections(&self) -> DomainResult<Vec<Collection>> { async fn list_collections(&self) -> DomainResult<Vec<Collection>> {

View File

@@ -4,13 +4,13 @@ use sqlx::{Row, SqlitePool};
use uuid::Uuid; use uuid::Uuid;
use adapter_common::{ 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, parse_uuid, serialize_enum_as_string,
}; };
use domain::{ use domain::{
ports::channel::{ChannelCommand, ChannelQuery}, ports::channel::{ChannelCommand, ChannelQuery},
AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow, AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow,
DomainError, DomainResult, LogoPosition, ScheduleConfig, SnapshotId, UserId, DomainError, DomainResult, LogoPosition, MediaFilter, ScheduleConfig, SnapshotId, UserId,
}; };
pub struct SqliteChannelRepository { 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)] #[derive(Debug, sqlx::FromRow)]
struct ChannelRow { struct ChannelRow {
@@ -33,10 +33,9 @@ struct ChannelRow {
description: Option<String>, description: Option<String>,
timezone: String, timezone: String,
schedule_config: String, schedule_config: String,
recycle_policy: String, rotation_policy: String,
auto_schedule: i64, auto_schedule: i64,
access_mode: String, access_mode: String,
access_password_hash: Option<String>,
logo: Option<String>, logo: Option<String>,
logo_position: String, logo_position: String,
logo_opacity: f32, logo_opacity: f32,
@@ -44,6 +43,7 @@ struct ChannelRow {
webhook_poll_interval_secs: i64, webhook_poll_interval_secs: i64,
webhook_body_template: Option<String>, webhook_body_template: Option<String>,
webhook_headers: Option<String>, webhook_headers: Option<String>,
gap_filler: Option<String>,
created_at: String, created_at: String,
updated_at: String, updated_at: String,
} }
@@ -53,10 +53,15 @@ impl ChannelRow {
let id = ChannelId::from_uuid(parse_uuid(&self.id, "channel id")?); 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 owner_id = UserId::from_uuid(parse_uuid(&self.owner_id, "owner id")?);
let schedule_config = parse_schedule_config(&self.schedule_config)?; 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 access_mode: AccessMode = parse_enum_or_default(self.access_mode);
let logo_position: LogoPosition = parse_enum_or_default(self.logo_position); 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 { Ok(Channel::from_persistence(DomainChannelRow {
id, id,
owner_id, owner_id,
@@ -64,10 +69,9 @@ impl ChannelRow {
description: self.description, description: self.description,
timezone: self.timezone, timezone: self.timezone,
schedule_config, schedule_config,
recycle_policy, rotation_policy,
auto_schedule: self.auto_schedule != 0, auto_schedule: self.auto_schedule != 0,
access_mode, access_mode,
access_password_hash: self.access_password_hash,
logo: self.logo, logo: self.logo,
logo_position, logo_position,
logo_opacity: self.logo_opacity, logo_opacity: self.logo_opacity,
@@ -75,6 +79,7 @@ impl ChannelRow {
webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32, webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32,
webhook_body_template: self.webhook_body_template, webhook_body_template: self.webhook_body_template,
webhook_headers: self.webhook_headers, webhook_headers: self.webhook_headers,
gap_filler,
created_at: parse_dt(&self.created_at)?, created_at: parse_dt(&self.created_at)?,
updated_at: parse_dt(&self.updated_at)?, updated_at: parse_dt(&self.updated_at)?,
})) }))
@@ -109,18 +114,22 @@ impl ChannelCommand for SqliteChannelRepository {
async fn save(&self, channel: &Channel) -> DomainResult<()> { async fn save(&self, channel: &Channel) -> DomainResult<()> {
let schedule_config = serde_json::to_string(channel.schedule_config()) let schedule_config = serde_json::to_string(channel.schedule_config())
.map_err(|e| DomainError::RepositoryError(format!("serialize schedule_config: {e}")))?; .map_err(|e| DomainError::RepositoryError(format!("serialize schedule_config: {e}")))?;
let recycle_policy = serde_json::to_string(channel.recycle_policy()) let rotation_policy = serde_json::to_string(channel.rotation_policy())
.map_err(|e| DomainError::RepositoryError(format!("serialize recycle_policy: {e}")))?; .map_err(|e| DomainError::RepositoryError(format!("serialize rotation_policy: {e}")))?;
let access_mode = serialize_enum_as_string(channel.access_mode(), "public"); let access_mode = serialize_enum_as_string(channel.access_mode(), "public");
let logo_position = serialize_enum_as_string(channel.logo_position(), "top_right"); 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( sqlx::query(
r#" r#"
INSERT INTO channels INSERT INTO channels
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, (id, owner_id, name, description, timezone, schedule_config, 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, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
@@ -130,7 +139,6 @@ impl ChannelCommand for SqliteChannelRepository {
recycle_policy = excluded.recycle_policy, recycle_policy = excluded.recycle_policy,
auto_schedule = excluded.auto_schedule, auto_schedule = excluded.auto_schedule,
access_mode = excluded.access_mode, access_mode = excluded.access_mode,
access_password_hash = excluded.access_password_hash,
logo = excluded.logo, logo = excluded.logo,
logo_position = excluded.logo_position, logo_position = excluded.logo_position,
logo_opacity = excluded.logo_opacity, logo_opacity = excluded.logo_opacity,
@@ -138,6 +146,7 @@ impl ChannelCommand for SqliteChannelRepository {
webhook_poll_interval_secs = excluded.webhook_poll_interval_secs, webhook_poll_interval_secs = excluded.webhook_poll_interval_secs,
webhook_body_template = excluded.webhook_body_template, webhook_body_template = excluded.webhook_body_template,
webhook_headers = excluded.webhook_headers, webhook_headers = excluded.webhook_headers,
gap_filler = excluded.gap_filler,
updated_at = excluded.updated_at updated_at = excluded.updated_at
"#, "#,
) )
@@ -147,10 +156,9 @@ impl ChannelCommand for SqliteChannelRepository {
.bind(channel.description()) .bind(channel.description())
.bind(channel.timezone()) .bind(channel.timezone())
.bind(&schedule_config) .bind(&schedule_config)
.bind(&recycle_policy) .bind(&rotation_policy)
.bind(channel.auto_schedule() as i64) .bind(channel.auto_schedule() as i64)
.bind(&access_mode) .bind(&access_mode)
.bind(channel.access_password_hash())
.bind(channel.logo()) .bind(channel.logo())
.bind(&logo_position) .bind(&logo_position)
.bind(channel.logo_opacity()) .bind(channel.logo_opacity())
@@ -158,6 +166,7 @@ impl ChannelCommand for SqliteChannelRepository {
.bind(channel.webhook_poll_interval_secs() as i64) .bind(channel.webhook_poll_interval_secs() as i64)
.bind(channel.webhook_body_template()) .bind(channel.webhook_body_template())
.bind(channel.webhook_headers()) .bind(channel.webhook_headers())
.bind(&gap_filler_json)
.bind(channel.created_at().to_rfc3339()) .bind(channel.created_at().to_rfc3339())
.bind(channel.updated_at().to_rfc3339()) .bind(channel.updated_at().to_rfc3339())
.execute(&self.pool) .execute(&self.pool)

View File

@@ -4,9 +4,10 @@ use sqlx::SqlitePool;
use adapter_common::{content_type_str, parse_content_type, parse_genres_blob}; use adapter_common::{content_type_str, parse_content_type, parse_genres_blob};
use domain::{ use domain::{
ports::library::{LibraryCommand, LibraryQuery}, ports::library::{LibraryCommand, LibraryQuery},
ContentType, DomainError, DomainResult, LibraryCollection, LibraryItem, ContentType, DomainError, DomainResult, LibraryCollection,
LibraryItemRow as DomainLibraryItemRow, LibrarySearchFilter, LibrarySyncLogEntry, LibrarySearchFilter, LibrarySyncLogEntry,
LibrarySyncResult, SeasonSummary, ShowSummary, LibrarySyncResult, MediaItem, MediaItemRow as DomainMediaItemRow,
MediaRole, SeasonSummary, ShowSummary,
}; };
pub struct SqliteLibraryRepository { pub struct SqliteLibraryRepository {
@@ -38,17 +39,19 @@ struct LibraryItemRow {
collection_type: Option<String>, collection_type: Option<String>,
thumbnail_url: Option<String>, thumbnail_url: Option<String>,
synced_at: String, synced_at: String,
chapters: Option<String>,
} }
impl LibraryItemRow { impl LibraryItemRow {
fn into_library_item(self) -> LibraryItem { fn into_media_item(self) -> MediaItem {
LibraryItem::from_persistence(DomainLibraryItemRow { MediaItem::from_persistence(DomainMediaItemRow {
id: self.id, id: domain::MediaItemId::new(&self.id),
provider_id: self.provider_id, provider_id: self.provider_id,
external_id: self.external_id, external_id: self.external_id,
title: self.title, title: self.title,
content_type: parse_content_type(&self.content_type), content_type: parse_content_type(&self.content_type),
duration_secs: self.duration_secs as u32, duration_secs: self.duration_secs as u32,
description: None,
series_name: self.series_name, series_name: self.series_name,
season_number: self.season_number.map(|n| n as u32), season_number: self.season_number.map(|n| n as u32),
episode_number: self.episode_number.map(|n| n as u32), episode_number: self.episode_number.map(|n| n as u32),
@@ -59,7 +62,13 @@ impl LibraryItemRow {
collection_name: self.collection_name, collection_name: self.collection_name,
collection_type: self.collection_type, collection_type: self.collection_type,
thumbnail_url: self.thumbnail_url, thumbnail_url: self.thumbnail_url,
synced_at: self.synced_at, synced_at: Some(self.synced_at),
role: MediaRole::default(),
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] #[async_trait]
impl LibraryCommand for SqliteLibraryRepository { impl LibraryCommand for SqliteLibraryRepository {
async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> { async fn upsert_items(&self, _provider_id: &str, items: Vec<MediaItem>) -> DomainResult<()> {
let mut tx = self let mut tx = self
.pool .pool
.begin() .begin()
@@ -101,14 +110,20 @@ impl LibraryCommand for SqliteLibraryRepository {
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
for item in items { 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( sqlx::query(
"INSERT OR REPLACE INTO library_items "INSERT OR REPLACE INTO library_items
(id, provider_id, external_id, title, content_type, duration_secs, (id, provider_id, external_id, title, content_type, duration_secs,
series_name, season_number, episode_number, year, genres, tags, series_name, season_number, episode_number, year, genres, tags,
collection_id, collection_name, collection_type, thumbnail_url, synced_at) collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
) )
.bind(item.id()) .bind(item.id().value())
.bind(item.provider_id()) .bind(item.provider_id())
.bind(item.external_id()) .bind(item.external_id())
.bind(item.title()) .bind(item.title())
@@ -124,7 +139,8 @@ impl LibraryCommand for SqliteLibraryRepository {
.bind(item.collection_name()) .bind(item.collection_name())
.bind(item.collection_type()) .bind(item.collection_type())
.bind(item.thumbnail_url()) .bind(item.thumbnail_url())
.bind(item.synced_at()) .bind(item.synced_at().unwrap_or(""))
.bind(&chapters_json)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
@@ -187,7 +203,7 @@ impl LibraryQuery for SqliteLibraryRepository {
async fn search( async fn search(
&self, &self,
filter: &LibrarySearchFilter, filter: &LibrarySearchFilter,
) -> DomainResult<(Vec<LibraryItem>, u32)> { ) -> DomainResult<(Vec<MediaItem>, u32)> {
let mut conditions: Vec<String> = vec![]; let mut conditions: Vec<String> = vec![];
if let Some(p) = filter.provider_id() { if let Some(p) = filter.provider_id() {
@@ -263,19 +279,19 @@ impl LibraryQuery for SqliteLibraryRepository {
Ok(( Ok((
rows.into_iter() rows.into_iter()
.map(LibraryItemRow::into_library_item) .map(LibraryItemRow::into_media_item)
.collect(), .collect(),
total as u32, total as u32,
)) ))
} }
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>> { async fn get_by_id(&self, id: &str) -> DomainResult<Option<MediaItem>> {
let row = sqlx::query_as::<_, LibraryItemRow>("SELECT * FROM library_items WHERE id = ?") let row = sqlx::query_as::<_, LibraryItemRow>("SELECT * FROM library_items WHERE id = ?")
.bind(id) .bind(id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(row.map(LibraryItemRow::into_library_item)) Ok(row.map(LibraryItemRow::into_media_item))
} }
async fn list_collections( async fn list_collections(

View File

@@ -11,7 +11,6 @@ pub struct CreateChannelRequest {
pub description: Option<String>, pub description: Option<String>,
pub timezone: String, pub timezone: String,
pub access_mode: Option<String>, pub access_mode: Option<String>,
pub access_password: Option<String>,
pub webhook_url: Option<String>, pub webhook_url: Option<String>,
pub webhook_poll_interval_secs: Option<u32>, pub webhook_poll_interval_secs: Option<u32>,
pub webhook_body_template: Option<String>, pub webhook_body_template: Option<String>,
@@ -26,10 +25,9 @@ pub struct UpdateChannelRequest {
#[schema(value_type = Option<Object>)] #[schema(value_type = Option<Object>)]
pub schedule_config: Option<domain::models::ScheduleConfigCompat>, pub schedule_config: Option<domain::models::ScheduleConfigCompat>,
#[schema(value_type = Option<Object>)] #[schema(value_type = Option<Object>)]
pub recycle_policy: Option<domain::RecyclePolicy>, pub rotation_policy: Option<domain::RotationPolicy>,
pub auto_schedule: Option<bool>, pub auto_schedule: Option<bool>,
pub access_mode: Option<String>, pub access_mode: Option<String>,
pub access_password: Option<String>,
pub logo: Option<Option<String>>, pub logo: Option<Option<String>>,
pub logo_position: Option<String>, pub logo_position: Option<String>,
pub logo_opacity: Option<f32>, pub logo_opacity: Option<f32>,
@@ -47,7 +45,7 @@ pub struct ChannelResponse {
pub description: Option<String>, pub description: Option<String>,
pub timezone: String, pub timezone: String,
pub schedule_config: serde_json::Value, pub schedule_config: serde_json::Value,
pub recycle_policy: serde_json::Value, pub rotation_policy: serde_json::Value,
pub auto_schedule: bool, pub auto_schedule: bool,
pub access_mode: String, pub access_mode: String,
pub logo: Option<String>, pub logo: Option<String>,
@@ -70,7 +68,7 @@ impl From<domain::Channel> for ChannelResponse {
description: c.description().map(|s| s.to_string()), description: c.description().map(|s| s.to_string()),
timezone: c.timezone().to_string(), timezone: c.timezone().to_string(),
schedule_config: serde_json::to_value(c.schedule_config()).unwrap_or_default(), 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(), auto_schedule: c.auto_schedule(),
access_mode: enum_to_string(c.access_mode()), access_mode: enum_to_string(c.access_mode()),
logo: c.logo().map(|s| s.to_string()), logo: c.logo().map(|s| s.to_string()),

View File

@@ -1,8 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::common::enum_to_string;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProviderCapabilitiesResponse { pub struct ProviderCapabilitiesResponse {
pub collections: bool, pub collections: bool,
@@ -11,9 +9,7 @@ pub struct ProviderCapabilitiesResponse {
pub tags: bool, pub tags: bool,
pub decade: bool, pub decade: bool,
pub search: bool, pub search: bool,
pub streaming_protocol: String,
pub rescan: bool, pub rescan: bool,
pub transcode: bool,
} }
impl From<domain::ports::ProviderCapabilities> for ProviderCapabilitiesResponse { impl From<domain::ports::ProviderCapabilities> for ProviderCapabilitiesResponse {
@@ -25,9 +21,7 @@ impl From<domain::ports::ProviderCapabilities> for ProviderCapabilitiesResponse
tags: c.tags, tags: c.tags,
decade: c.decade, decade: c.decade,
search: c.search, search: c.search,
streaming_protocol: enum_to_string(&c.streaming_protocol),
rescan: c.rescan, rescan: c.rescan,
transcode: c.transcode,
} }
} }
} }

View File

@@ -21,13 +21,13 @@ pub struct LibraryItemResponse {
pub collection_name: Option<String>, pub collection_name: Option<String>,
pub collection_type: Option<String>, pub collection_type: Option<String>,
pub thumbnail_url: Option<String>, pub thumbnail_url: Option<String>,
pub synced_at: String, pub synced_at: Option<String>,
} }
impl From<domain::LibraryItem> for LibraryItemResponse { impl From<domain::MediaItem> for LibraryItemResponse {
fn from(i: domain::LibraryItem) -> Self { fn from(i: domain::MediaItem) -> Self {
Self { Self {
id: i.id().to_string(), id: i.id().value().to_string(),
provider_id: i.provider_id().to_string(), provider_id: i.provider_id().to_string(),
external_id: i.external_id().to_string(), external_id: i.external_id().to_string(),
title: i.title().to_string(), title: i.title().to_string(),
@@ -43,7 +43,7 @@ impl From<domain::LibraryItem> for LibraryItemResponse {
collection_name: i.collection_name().map(|s| s.to_string()), collection_name: i.collection_name().map(|s| s.to_string()),
collection_type: i.collection_type().map(|s| s.to_string()), collection_type: i.collection_type().map(|s| s.to_string()),
thumbnail_url: i.thumbnail_url().map(|s| s.to_string()), thumbnail_url: i.thumbnail_url().map(|s| s.to_string()),
synced_at: i.synced_at().to_string(), synced_at: i.synced_at().map(|s| s.to_string()),
} }
} }
} }

View File

@@ -5,8 +5,6 @@ use uuid::Uuid;
use crate::common::enum_to_string; use crate::common::enum_to_string;
const DEFAULT_ACCESS_MODE: &str = "public";
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MediaItemResponse { pub struct MediaItemResponse {
pub id: String, pub id: String,
@@ -47,8 +45,6 @@ pub struct SlotResponse {
pub end_at: DateTime<Utc>, pub end_at: DateTime<Utc>,
pub item: MediaItemResponse, pub item: MediaItemResponse,
pub source_block_id: Uuid, pub source_block_id: Uuid,
#[serde(default)]
pub block_access_mode: String,
} }
impl From<domain::ScheduledSlot> for SlotResponse { impl From<domain::ScheduledSlot> for SlotResponse {
@@ -59,26 +55,6 @@ impl From<domain::ScheduledSlot> for SlotResponse {
end_at: s.end_at(), end_at: s.end_at(),
item: s.item().clone().into(), item: s.item().clone().into(),
source_block_id: s.source_block_id().value(), 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 struct CurrentBroadcastResponse {
pub slot: SlotResponse, pub slot: SlotResponse,
pub offset_secs: u32, pub offset_secs: u32,
pub block_access_mode: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]

View File

@@ -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
}

View File

@@ -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;

View File

@@ -1,10 +1,6 @@
pub mod activity_log;
pub mod commands; pub mod commands;
pub mod deps; pub mod deps;
pub mod get_settings;
pub mod queries;
pub mod update_settings; pub mod update_settings;
pub use commands::UpdateSettingsCommand; pub use commands::UpdateSettingsCommand;
pub use deps::AdminDeps; pub use deps::AdminDeps;
pub use queries::{GetActivityLogQuery, GetSettingsQuery};

View File

@@ -1,5 +0,0 @@
pub struct GetSettingsQuery;
pub struct GetActivityLogQuery {
pub limit: u32,
}

View File

@@ -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"));
}

View File

@@ -121,10 +121,10 @@ async fn login_fails_for_unknown_email() {
} }
#[tokio::test] #[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 repo = Arc::new(InMemoryUserRepository::new());
let email = Email::new("oidc@example.com").unwrap(); let email = Email::new("external@example.com").unwrap();
let user = domain::models::User::new("oidc|subject", email); let user = domain::models::User::new("external|subject", email);
repo.store.lock().unwrap().insert(user.id(), user); repo.store.lock().unwrap().insert(user.id(), user);
let deps = AuthDeps { let deps = AuthDeps {
@@ -138,7 +138,7 @@ async fn login_fails_for_oidc_only_user() {
let result = login::execute( let result = login::execute(
&deps, &deps,
LoginCommand { LoginCommand {
email: "oidc@example.com".into(), email: "external@example.com".into(),
password: "password123".into(), password: "password123".into(),
remember_me: false, remember_me: false,
}, },

View File

@@ -1,5 +1,5 @@
use domain::models::ScheduleConfig; use domain::models::ScheduleConfig;
use domain::value_objects::{ChannelId, RecyclePolicy, UserId}; use domain::value_objects::{ChannelId, RotationPolicy, UserId};
pub struct CreateChannelCommand { pub struct CreateChannelCommand {
pub owner_id: UserId, pub owner_id: UserId,
@@ -14,7 +14,7 @@ pub struct UpdateChannelCommand {
pub description: Option<Option<String>>, pub description: Option<Option<String>>,
pub timezone: Option<String>, pub timezone: Option<String>,
pub schedule_config: Option<ScheduleConfig>, pub schedule_config: Option<ScheduleConfig>,
pub recycle_policy: Option<RecyclePolicy>, pub rotation_policy: Option<RotationPolicy>,
pub auto_schedule: Option<bool>, pub auto_schedule: Option<bool>,
} }

View File

@@ -7,7 +7,3 @@ pub struct ChannelCommandDeps {
pub channel_query: Arc<dyn ChannelQuery>, pub channel_query: Arc<dyn ChannelQuery>,
pub event_publisher: Arc<dyn EventPublisher>, pub event_publisher: Arc<dyn EventPublisher>,
} }
pub struct ChannelQueryDeps {
pub channel_query: Arc<dyn ChannelQuery>,
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -2,15 +2,10 @@ pub mod commands;
pub mod create; pub mod create;
pub mod delete; pub mod delete;
pub mod deps; pub mod deps;
pub mod get;
pub mod list;
pub mod list_by_owner;
pub mod queries;
pub mod update; pub mod update;
pub use commands::{CreateChannelCommand, DeleteChannelCommand, UpdateChannelCommand}; pub use commands::{CreateChannelCommand, DeleteChannelCommand, UpdateChannelCommand};
pub use deps::{ChannelCommandDeps, ChannelQueryDeps}; pub use deps::ChannelCommandDeps;
pub use queries::{GetChannelQuery, ListByOwnerQuery, ListChannelsQuery};
use domain::models::Channel; use domain::models::Channel;
use domain::value_objects::{ChannelId, UserId}; use domain::value_objects::{ChannelId, UserId};

View File

@@ -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,
}

View File

@@ -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());
}

View File

@@ -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);
}

View File

@@ -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());
}

View File

@@ -43,7 +43,7 @@ async fn updates_channel_name() {
description: None, description: None,
timezone: None, timezone: None,
schedule_config: None, schedule_config: None,
recycle_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
}, },
) )
@@ -80,7 +80,7 @@ async fn update_fails_if_not_owner() {
description: None, description: None,
timezone: None, timezone: None,
schedule_config: None, schedule_config: None,
recycle_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
}, },
) )
@@ -106,7 +106,7 @@ async fn update_nonexistent_channel_returns_not_found() {
description: None, description: None,
timezone: None, timezone: None,
schedule_config: None, schedule_config: None,
recycle_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
}, },
) )
@@ -146,7 +146,7 @@ async fn update_config_creates_snapshot() {
description: None, description: None,
timezone: None, timezone: None,
schedule_config: Some(new_config), schedule_config: Some(new_config),
recycle_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
}, },
) )
@@ -185,7 +185,7 @@ async fn update_without_config_skips_snapshot() {
description: None, description: None,
timezone: None, timezone: None,
schedule_config: None, schedule_config: None,
recycle_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
}, },
) )
@@ -223,7 +223,7 @@ async fn update_description_clear() {
description: Some(Some("A description".into())), description: Some(Some("A description".into())),
timezone: None, timezone: None,
schedule_config: None, schedule_config: None,
recycle_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
}, },
) )
@@ -241,7 +241,7 @@ async fn update_description_clear() {
description: Some(None), description: Some(None),
timezone: None, timezone: None,
schedule_config: None, schedule_config: None,
recycle_policy: None, rotation_policy: None,
auto_schedule: None, auto_schedule: None,
}, },
) )

View File

@@ -29,8 +29,8 @@ pub async fn execute(deps: &ChannelCommandDeps, cmd: UpdateChannelCommand) -> Do
if let Some(config) = cmd.schedule_config { if let Some(config) = cmd.schedule_config {
channel.set_schedule_config(config); channel.set_schedule_config(config);
} }
if let Some(policy) = cmd.recycle_policy { if let Some(policy) = cmd.rotation_policy {
channel.set_recycle_policy(policy); channel.set_rotation_policy(policy);
} }
if let Some(auto) = cmd.auto_schedule { if let Some(auto) = cmd.auto_schedule {
channel.set_auto_schedule(auto); channel.set_auto_schedule(auto);

View File

@@ -41,9 +41,7 @@ pub fn execute(deps: &ConfigDeps, _query: GetConfigQuery) -> SystemConfig {
tags: false, tags: false,
decade: false, decade: false,
search: false, search: false,
streaming_protocol: domain::ports::StreamingProtocol::DirectFile,
rescan: false, rescan: false,
transcode: false,
}); });
SystemConfig { SystemConfig {

View File

@@ -5,12 +5,6 @@ pub struct SaveSnapshotCommand {
pub label: Option<String>, pub label: Option<String>,
} }
pub struct PatchLabelCommand {
pub channel_id: ChannelId,
pub snapshot_id: SnapshotId,
pub label: Option<String>,
}
pub struct RestoreSnapshotCommand { pub struct RestoreSnapshotCommand {
pub channel_id: ChannelId, pub channel_id: ChannelId,
pub snapshot_id: SnapshotId, pub snapshot_id: SnapshotId,

View File

@@ -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
}

View File

@@ -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;

View File

@@ -1,12 +1,7 @@
pub mod commands; pub mod commands;
pub mod deps; pub mod deps;
pub mod get;
pub mod list;
pub mod patch_label;
pub mod queries;
pub mod restore; pub mod restore;
pub mod save; pub mod save;
pub use commands::{PatchLabelCommand, RestoreSnapshotCommand, SaveSnapshotCommand}; pub use commands::{RestoreSnapshotCommand, SaveSnapshotCommand};
pub use deps::ConfigSnapshotDeps; pub use deps::ConfigSnapshotDeps;
pub use queries::{GetSnapshotQuery, ListSnapshotsQuery};

View File

@@ -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
}

View File

@@ -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,
}

View File

@@ -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);
}

View File

@@ -9,7 +9,3 @@ pub struct LibraryCommandDeps {
pub provider_registry: Arc<dyn IProviderRegistry>, pub provider_registry: Arc<dyn IProviderRegistry>,
pub event_publisher: Arc<dyn EventPublisher>, pub event_publisher: Arc<dyn EventPublisher>,
} }
pub struct LibraryQueryDeps {
pub library_query: Arc<dyn LibraryQuery>,
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,26 +1,17 @@
pub mod commands; pub mod commands;
pub mod deps; 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 queries;
pub mod search; pub mod search;
pub mod sync; pub mod sync;
pub use commands::TriggerSyncCommand; pub use commands::TriggerSyncCommand;
pub use deps::{LibraryCommandDeps, LibraryQueryDeps}; pub use deps::LibraryCommandDeps;
pub use queries::{ pub use queries::SearchItemsQuery;
GetItemQuery, GetSyncStatusQuery, ListCollectionsQuery, ListGenresQuery, ListSeasonsQuery,
ListShowsQuery, SearchItemsQuery,
};
use domain::errors::{DomainError, DomainResult}; use domain::errors::{DomainError, DomainResult};
use domain::value_objects::ContentType; 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 { match s {
"movie" => Ok(ContentType::Movie), "movie" => Ok(ContentType::Movie),
"episode" => Ok(ContentType::Episode), "episode" => Ok(ContentType::Episode),

View File

@@ -10,29 +10,3 @@ pub struct SearchItemsQuery {
pub offset: u32, pub offset: u32,
pub limit: 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;

View File

@@ -1,15 +1,15 @@
use domain::DomainResult; use domain::DomainResult;
use domain::models::LibraryItem; use domain::models::MediaItem;
use domain::value_objects::LibrarySearchFilter; use domain::value_objects::LibrarySearchFilter;
use super::deps::LibraryQueryDeps; use super::deps::LibraryCommandDeps;
use super::parse_content_type; use super::parse_content_type;
use super::queries::SearchItemsQuery; use super::queries::SearchItemsQuery;
pub async fn execute( pub async fn execute(
deps: &LibraryQueryDeps, deps: &LibraryCommandDeps,
query: SearchItemsQuery, query: SearchItemsQuery,
) -> DomainResult<(Vec<LibraryItem>, u32)> { ) -> DomainResult<(Vec<MediaItem>, u32)> {
let content_type = query let content_type = query
.content_type .content_type
.as_deref() .as_deref()

View File

@@ -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());
}

View File

@@ -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");
}

View File

@@ -4,16 +4,13 @@ use async_trait::async_trait;
use domain::errors::DomainResult; use domain::errors::DomainResult;
use domain::models::MediaItem; use domain::models::MediaItem;
use domain::ports::{ use domain::ports::{Collection, IProviderRegistry, ProviderCapabilities, SeriesSummary};
Collection, IProviderRegistry, ProviderCapabilities, SeriesSummary, StreamQuality, use domain::SourceUri;
StreamingProtocol,
};
use domain::testing::{InMemoryLibraryRepository, NoopEventPublisher, NoopLibrarySync}; use domain::testing::{InMemoryLibraryRepository, NoopEventPublisher, NoopLibrarySync};
use domain::value_objects::{ContentType, MediaFilter, MediaItemId}; 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; pub(crate) struct TestProviderRegistry;
#[async_trait] #[async_trait]
@@ -30,11 +27,7 @@ impl IProviderRegistry for TestProviderRegistry {
Ok(None) Ok(None)
} }
async fn get_stream_url( async fn get_source_uri(&self, _item_id: &MediaItemId) -> DomainResult<SourceUri> {
&self,
_item_id: &MediaItemId,
_quality: &StreamQuality,
) -> DomainResult<String> {
Err(domain::DomainError::InfrastructureError( Err(domain::DomainError::InfrastructureError(
"TestProviderRegistry does not support streaming".into(), "TestProviderRegistry does not support streaming".into(),
)) ))
@@ -56,9 +49,7 @@ impl IProviderRegistry for TestProviderRegistry {
tags: false, tags: false,
decade: false, decade: false,
search: true, search: true,
streaming_protocol: StreamingProtocol::Hls,
rescan: false, rescan: false,
transcode: false,
}) })
} }
@@ -83,24 +74,14 @@ impl IProviderRegistry for TestProviderRegistry {
} }
} }
/// Build LibraryCommandDeps and LibraryQueryDeps backed by InMemory repos. pub(crate) fn make_deps() -> (LibraryCommandDeps, Arc<InMemoryLibraryRepository>) {
///
/// Returns deps plus the underlying repo for seeding test data.
pub(crate) fn make_deps() -> (
LibraryCommandDeps,
LibraryQueryDeps,
Arc<InMemoryLibraryRepository>,
) {
let repo = Arc::new(InMemoryLibraryRepository::new()); let repo = Arc::new(InMemoryLibraryRepository::new());
let cmd_deps = LibraryCommandDeps { let deps = LibraryCommandDeps {
library_command: repo.clone(), library_command: repo.clone(),
library_query: repo.clone(), library_query: repo.clone(),
library_sync: Arc::new(NoopLibrarySync::new()), library_sync: Arc::new(NoopLibrarySync::new()),
provider_registry: Arc::new(TestProviderRegistry), provider_registry: Arc::new(TestProviderRegistry),
event_publisher: Arc::new(NoopEventPublisher::new()), event_publisher: Arc::new(NoopEventPublisher::new()),
}; };
let query_deps = LibraryQueryDeps { (deps, repo)
library_query: repo.clone(),
};
(cmd_deps, query_deps, repo)
} }

View File

@@ -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());
}

View File

@@ -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());
}

View File

@@ -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());
}

View File

@@ -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());
}

View File

@@ -1,5 +1,5 @@
use domain::models::{LibraryItem, LibraryItemRow}; use domain::models::{MediaItem, MediaItemRow};
use domain::value_objects::ContentType; use domain::value_objects::{ContentType, MediaItemId, MediaRole};
use crate::library::queries::SearchItemsQuery; use crate::library::queries::SearchItemsQuery;
use crate::library::search; use crate::library::search;
@@ -10,25 +10,26 @@ mod helpers;
fn seed_items(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) { fn seed_items(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
let mut store = repo.items.lock().unwrap(); let mut store = repo.items.lock().unwrap();
let items = vec![ let items = vec![
LibraryItem::new("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01"), MediaItem::new_library("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01"),
LibraryItem::new("test", "m2", "Alien", ContentType::Movie, 7020, "2026-01-01"), MediaItem::new_library("test", "m2", "Alien", ContentType::Movie, 7020, "2026-01-01"),
LibraryItem::new("test", "e1", "BB S01E01", ContentType::Episode, 2700, "2026-01-01"), MediaItem::new_library("test", "e1", "BB S01E01", ContentType::Episode, 2700, "2026-01-01"),
]; ];
for item in items { for item in items {
store.insert(item.id().to_string(), item); store.insert(item.id().value().to_string(), item);
} }
} }
fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) { fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
let mut store = repo.items.lock().unwrap(); let mut store = repo.items.lock().unwrap();
let action = LibraryItem::from_persistence(LibraryItemRow { let action = MediaItem::from_persistence(MediaItemRow {
id: "test::m1".into(), id: MediaItemId::new("test::m1"),
provider_id: "test".into(), provider_id: "test".into(),
external_id: "m1".into(), external_id: "m1".into(),
title: "Die Hard".into(), title: "Die Hard".into(),
content_type: ContentType::Movie, content_type: ContentType::Movie,
duration_secs: 7800, duration_secs: 7800,
description: None,
series_name: None, series_name: None,
season_number: None, season_number: None,
episode_number: None, episode_number: None,
@@ -39,15 +40,18 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
collection_name: None, collection_name: None,
collection_type: None, collection_type: None,
thumbnail_url: None, thumbnail_url: None,
synced_at: "2026-01-01".into(), synced_at: Some("2026-01-01".into()),
role: MediaRole::default(),
chapters: Vec::new(),
}); });
let scifi = LibraryItem::from_persistence(LibraryItemRow { let scifi = MediaItem::from_persistence(MediaItemRow {
id: "test::m2".into(), id: MediaItemId::new("test::m2"),
provider_id: "test".into(), provider_id: "test".into(),
external_id: "m2".into(), external_id: "m2".into(),
title: "Alien".into(), title: "Alien".into(),
content_type: ContentType::Movie, content_type: ContentType::Movie,
duration_secs: 7020, duration_secs: 7020,
description: None,
series_name: None, series_name: None,
season_number: None, season_number: None,
episode_number: None, episode_number: None,
@@ -58,15 +62,18 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
collection_name: None, collection_name: None,
collection_type: None, collection_type: None,
thumbnail_url: None, thumbnail_url: None,
synced_at: "2026-01-01".into(), synced_at: Some("2026-01-01".into()),
role: MediaRole::default(),
chapters: Vec::new(),
}); });
let comedy = LibraryItem::from_persistence(LibraryItemRow { let comedy = MediaItem::from_persistence(MediaItemRow {
id: "test::m3".into(), id: MediaItemId::new("test::m3"),
provider_id: "test".into(), provider_id: "test".into(),
external_id: "m3".into(), external_id: "m3".into(),
title: "Airplane!".into(), title: "Airplane!".into(),
content_type: ContentType::Movie, content_type: ContentType::Movie,
duration_secs: 5280, duration_secs: 5280,
description: None,
series_name: None, series_name: None,
season_number: None, season_number: None,
episode_number: None, episode_number: None,
@@ -77,21 +84,23 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
collection_name: None, collection_name: None,
collection_type: None, collection_type: None,
thumbnail_url: None, thumbnail_url: None,
synced_at: "2026-01-01".into(), synced_at: Some("2026-01-01".into()),
role: MediaRole::default(),
chapters: Vec::new(),
}); });
store.insert(action.id().to_string(), action); store.insert(action.id().value().to_string(), action);
store.insert(scifi.id().to_string(), scifi); store.insert(scifi.id().value().to_string(), scifi);
store.insert(comedy.id().to_string(), comedy); store.insert(comedy.id().value().to_string(), comedy);
} }
#[tokio::test] #[tokio::test]
async fn search_empty_filter_returns_all() { async fn search_empty_filter_returns_all() {
let (_cmd, query, repo) = helpers::make_deps(); let (deps, repo) = helpers::make_deps();
seed_items(&repo); seed_items(&repo);
let (items, total) = search::execute( let (items, total) = search::execute(
&query, &deps,
SearchItemsQuery { SearchItemsQuery {
provider_id: None, provider_id: None,
content_type: None, content_type: None,
@@ -114,11 +123,11 @@ async fn search_empty_filter_returns_all() {
#[tokio::test] #[tokio::test]
async fn search_by_content_type() { async fn search_by_content_type() {
let (_cmd, query, repo) = helpers::make_deps(); let (deps, repo) = helpers::make_deps();
seed_items(&repo); seed_items(&repo);
let (items, total) = search::execute( let (items, total) = search::execute(
&query, &deps,
SearchItemsQuery { SearchItemsQuery {
provider_id: None, provider_id: None,
content_type: Some("movie".into()), content_type: Some("movie".into()),
@@ -141,11 +150,11 @@ async fn search_by_content_type() {
#[tokio::test] #[tokio::test]
async fn search_by_genre() { async fn search_by_genre() {
let (_cmd, query, repo) = helpers::make_deps(); let (deps, repo) = helpers::make_deps();
seed_items_with_genres(&repo); seed_items_with_genres(&repo);
let (items, total) = search::execute( let (items, total) = search::execute(
&query, &deps,
SearchItemsQuery { SearchItemsQuery {
provider_id: None, provider_id: None,
content_type: None, content_type: None,
@@ -169,11 +178,11 @@ async fn search_by_genre() {
#[tokio::test] #[tokio::test]
async fn search_by_search_term() { async fn search_by_search_term() {
let (_cmd, query, repo) = helpers::make_deps(); let (deps, repo) = helpers::make_deps();
seed_items(&repo); seed_items(&repo);
let (items, total) = search::execute( let (items, total) = search::execute(
&query, &deps,
SearchItemsQuery { SearchItemsQuery {
provider_id: None, provider_id: None,
content_type: None, content_type: None,
@@ -197,10 +206,10 @@ async fn search_by_search_term() {
#[tokio::test] #[tokio::test]
async fn search_invalid_content_type_errors() { async fn search_invalid_content_type_errors() {
let (_cmd, query, _repo) = helpers::make_deps(); let (deps, _repo) = helpers::make_deps();
let result = search::execute( let result = search::execute(
&query, &deps,
SearchItemsQuery { SearchItemsQuery {
provider_id: None, provider_id: None,
content_type: Some("bogus".into()), content_type: Some("bogus".into()),
@@ -221,11 +230,11 @@ async fn search_invalid_content_type_errors() {
#[tokio::test] #[tokio::test]
async fn search_pagination() { async fn search_pagination() {
let (_cmd, query, repo) = helpers::make_deps(); let (deps, repo) = helpers::make_deps();
seed_items(&repo); seed_items(&repo);
let (items, total) = search::execute( let (items, total) = search::execute(
&query, &deps,
SearchItemsQuery { SearchItemsQuery {
provider_id: None, provider_id: None,
content_type: None, content_type: None,

View File

@@ -6,10 +6,10 @@ mod helpers;
#[tokio::test] #[tokio::test]
async fn trigger_sync_returns_provider_ids() { async fn trigger_sync_returns_provider_ids() {
let (cmd, _query, _repo) = helpers::make_deps(); let (deps, _repo) = helpers::make_deps();
let ids = sync::execute( let ids = sync::execute(
&cmd, &deps,
TriggerSyncCommand { provider_id: None }, TriggerSyncCommand { provider_id: None },
) )
.await .await
@@ -20,10 +20,10 @@ async fn trigger_sync_returns_provider_ids() {
#[tokio::test] #[tokio::test]
async fn trigger_sync_specific_provider() { async fn trigger_sync_specific_provider() {
let (cmd, _query, _repo) = helpers::make_deps(); let (deps, _repo) = helpers::make_deps();
let ids = sync::execute( let ids = sync::execute(
&cmd, &deps,
TriggerSyncCommand { TriggerSyncCommand {
provider_id: Some("test".into()), provider_id: Some("test".into()),
}, },
@@ -36,16 +36,15 @@ async fn trigger_sync_specific_provider() {
#[tokio::test] #[tokio::test]
async fn trigger_sync_while_running_errors() { 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();
repo.items.lock().unwrap(); // just verify repo is accessible let _log_id = domain::ports::LibraryCommand::log_sync_start(&*deps.library_command, "test")
let _log_id = domain::ports::LibraryCommand::log_sync_start(&*cmd.library_command, "test")
.await .await
.unwrap(); .unwrap();
let result = sync::execute( let result = sync::execute(
&cmd, &deps,
TriggerSyncCommand { provider_id: None }, TriggerSyncCommand { provider_id: None },
) )
.await; .await;

View File

@@ -4,7 +4,3 @@ pub struct UpsertProviderCommand {
pub config: serde_json::Value, pub config: serde_json::Value,
pub enabled: bool, pub enabled: bool,
} }
pub struct DeleteProviderCommand {
pub id: String,
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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;

View File

@@ -1,11 +1,6 @@
pub mod commands; pub mod commands;
pub mod delete;
pub mod deps; pub mod deps;
pub mod get;
pub mod list;
pub mod queries;
pub mod upsert; pub mod upsert;
pub use commands::{DeleteProviderCommand, UpsertProviderCommand}; pub use commands::UpsertProviderCommand;
pub use deps::ProviderDeps; pub use deps::ProviderDeps;
pub use queries::{GetProviderQuery, ListProvidersQuery};

View File

@@ -1,5 +0,0 @@
pub struct ListProvidersQuery;
pub struct GetProviderQuery {
pub id: String,
}

View File

@@ -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);
}

View File

@@ -3,8 +3,3 @@ use uuid::Uuid;
pub struct GenerateScheduleCommand { pub struct GenerateScheduleCommand {
pub channel_id: Uuid, pub channel_id: Uuid,
} }
pub struct DeleteSchedulesAfterCommand {
pub channel_id: Uuid,
pub target_generation: u32,
}

View File

@@ -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;

View File

@@ -1,6 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{ChannelQuery, EventPublisher, ScheduleCommand, ScheduleQuery}; use domain::ports::{ChannelQuery, EventPublisher, IProviderRegistry, ScheduleCommand, ScheduleQuery};
use domain::ScheduleEngineService; use domain::ScheduleEngineService;
pub struct ScheduleDeps { pub struct ScheduleDeps {
@@ -9,4 +9,5 @@ pub struct ScheduleDeps {
pub schedule_query: Arc<dyn ScheduleQuery>, pub schedule_query: Arc<dyn ScheduleQuery>,
pub schedule_command: Arc<dyn ScheduleCommand>, pub schedule_command: Arc<dyn ScheduleCommand>,
pub event_publisher: Arc<dyn EventPublisher>, pub event_publisher: Arc<dyn EventPublisher>,
pub provider_registry: Arc<dyn IProviderRegistry>,
} }

View File

@@ -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;

View File

@@ -1,13 +1,12 @@
use chrono::Utc; use chrono::Utc;
use domain::ports::StreamQuality; use domain::value_objects::{ChannelId, SourceUri};
use domain::value_objects::ChannelId;
use domain::{DomainResult, ScheduleEngineService}; use domain::{DomainResult, ScheduleEngineService};
use super::deps::ScheduleDeps; 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 channel_id = ChannelId::from(query.channel_id);
let now = Utc::now(); 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 item_id = broadcast.slot().item().id().clone();
let url = deps let uri = deps
.schedule_engine .provider_registry
.get_stream_url(&item_id, &StreamQuality::Direct) .get_source_uri(&item_id)
.await?; .await?;
Ok(Some(url)) Ok(Some(uri))
} }

View File

@@ -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;

View File

@@ -1,17 +1,11 @@
pub mod commands; pub mod commands;
pub mod delete_after;
pub mod deps; pub mod deps;
pub mod generate; pub mod generate;
pub mod get_active;
pub mod get_current_broadcast; pub mod get_current_broadcast;
pub mod get_epg; pub mod get_epg;
pub mod get_stream_url; pub mod get_source;
pub mod list_history;
pub mod queries; pub mod queries;
pub use commands::{DeleteSchedulesAfterCommand, GenerateScheduleCommand}; pub use commands::GenerateScheduleCommand;
pub use deps::ScheduleDeps; pub use deps::ScheduleDeps;
pub use queries::{ pub use queries::{GetCurrentBroadcastQuery, GetEpgQuery, GetSourceQuery};
GetActiveScheduleQuery, GetCurrentBroadcastQuery, GetEpgQuery, GetStreamUrlQuery,
ListHistoryQuery,
};

View File

@@ -1,9 +1,5 @@
use uuid::Uuid; use uuid::Uuid;
pub struct GetActiveScheduleQuery {
pub channel_id: Uuid,
}
pub struct GetCurrentBroadcastQuery { pub struct GetCurrentBroadcastQuery {
pub channel_id: Uuid, pub channel_id: Uuid,
} }
@@ -12,10 +8,6 @@ pub struct GetEpgQuery {
pub channel_id: Uuid, pub channel_id: Uuid,
} }
pub struct GetStreamUrlQuery { pub struct GetSourceQuery {
pub channel_id: Uuid,
}
pub struct ListHistoryQuery {
pub channel_id: Uuid, pub channel_id: Uuid,
} }

View File

@@ -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);
}

View File

@@ -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());
}

View File

@@ -4,17 +4,14 @@ use async_trait::async_trait;
use domain::errors::DomainResult; use domain::errors::DomainResult;
use domain::models::MediaItem; use domain::models::MediaItem;
use domain::ports::{ use domain::ports::{Collection, IProviderRegistry, ProviderCapabilities, SeriesSummary};
Collection, IProviderRegistry, ProviderCapabilities, use domain::SourceUri;
SeriesSummary, StreamQuality, StreamingProtocol, use domain::testing::{InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository, NoopEventPublisher};
};
use domain::testing::{InMemoryChannelRepository, InMemoryScheduleRepository, NoopEventPublisher};
use domain::value_objects::{ContentType, MediaFilter, MediaItemId}; use domain::value_objects::{ContentType, MediaFilter, MediaItemId};
use domain::ScheduleEngineService; use domain::ScheduleEngineService;
use crate::schedule::deps::ScheduleDeps; use crate::schedule::deps::ScheduleDeps;
/// Minimal IProviderRegistry backed by a NoopMediaProvider.
pub(crate) struct TestProviderRegistry; pub(crate) struct TestProviderRegistry;
#[async_trait] #[async_trait]
@@ -31,11 +28,7 @@ impl IProviderRegistry for TestProviderRegistry {
Ok(None) Ok(None)
} }
async fn get_stream_url( async fn get_source_uri(&self, _item_id: &MediaItemId) -> DomainResult<SourceUri> {
&self,
_item_id: &MediaItemId,
_quality: &StreamQuality,
) -> DomainResult<String> {
Err(domain::DomainError::InfrastructureError( Err(domain::DomainError::InfrastructureError(
"TestProviderRegistry does not support streaming".into(), "TestProviderRegistry does not support streaming".into(),
)) ))
@@ -57,9 +50,7 @@ impl IProviderRegistry for TestProviderRegistry {
tags: false, tags: false,
decade: false, decade: false,
search: false, search: false,
streaming_protocol: StreamingProtocol::Hls,
rescan: false, 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() -> ( pub(crate) fn make_schedule_deps() -> (
ScheduleDeps, ScheduleDeps,
Arc<InMemoryChannelRepository>, Arc<InMemoryChannelRepository>,
@@ -94,10 +82,11 @@ pub(crate) fn make_schedule_deps() -> (
) { ) {
let channel_repo = Arc::new(InMemoryChannelRepository::new()); let channel_repo = Arc::new(InMemoryChannelRepository::new());
let schedule_repo = Arc::new(InMemoryScheduleRepository::new()); let schedule_repo = Arc::new(InMemoryScheduleRepository::new());
let library_repo = Arc::new(InMemoryLibraryRepository::new());
let provider_registry = Arc::new(TestProviderRegistry); let provider_registry = Arc::new(TestProviderRegistry);
let engine = Arc::new(ScheduleEngineService::new( let engine = Arc::new(ScheduleEngineService::new(
provider_registry, library_repo,
channel_repo.clone(), channel_repo.clone(),
schedule_repo.clone(), schedule_repo.clone(),
schedule_repo.clone(), schedule_repo.clone(),
@@ -109,6 +98,7 @@ pub(crate) fn make_schedule_deps() -> (
schedule_query: schedule_repo.clone(), schedule_query: schedule_repo.clone(),
schedule_command: schedule_repo.clone(), schedule_command: schedule_repo.clone(),
event_publisher: Arc::new(NoopEventPublisher::new()), event_publisher: Arc::new(NoopEventPublisher::new()),
provider_registry,
}; };
(deps, channel_repo, schedule_repo) (deps, channel_repo, schedule_repo)

View File

@@ -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());
}

View File

@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use crate::value_objects::{ChannelId, ScheduleId, SlotId}; use crate::value_objects::{ChannelId, ScheduleId, SlotId};
#[derive(Clone, Debug)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive] #[non_exhaustive]
pub enum DomainEvent { pub enum DomainEvent {
BroadcastTransition { BroadcastTransition {
@@ -18,6 +20,58 @@ pub enum DomainEvent {
UserRegistered { user_id: crate::value_objects::UserId }, 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)] #[cfg(test)]
#[path = "tests/mod.rs"] #[path = "tests/mod.rs"]
mod tests; mod tests;

View File

@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use crate::value_objects::{ use crate::value_objects::{
AccessMode, BlockId, ChannelId, FillStrategy, LogoPosition, MediaFilter, MediaItemId, AccessMode, BlockId, ChannelId, FillStrategy, InterstitialRule, LogoPosition, MediaFilter,
RecyclePolicy, UserId, Weekday, MediaItemId, MidRollRule, RotationPolicy, UserId, Weekday,
}; };
const SECONDS_IN_DAY: u32 = 86_400; const SECONDS_IN_DAY: u32 = 86_400;
@@ -19,10 +19,9 @@ pub struct Channel {
description: Option<String>, description: Option<String>,
timezone: String, timezone: String,
schedule_config: ScheduleConfig, schedule_config: ScheduleConfig,
recycle_policy: RecyclePolicy, rotation_policy: RotationPolicy,
auto_schedule: bool, auto_schedule: bool,
access_mode: AccessMode, access_mode: AccessMode,
access_password_hash: Option<String>,
logo: Option<String>, logo: Option<String>,
logo_position: LogoPosition, logo_position: LogoPosition,
logo_opacity: f32, logo_opacity: f32,
@@ -30,6 +29,8 @@ pub struct Channel {
webhook_poll_interval_secs: u32, webhook_poll_interval_secs: u32,
webhook_body_template: Option<String>, webhook_body_template: Option<String>,
webhook_headers: Option<String>, webhook_headers: Option<String>,
#[serde(default)]
gap_filler: Option<MediaFilter>,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
updated_at: DateTime<Utc>, updated_at: DateTime<Utc>,
} }
@@ -41,10 +42,9 @@ pub struct ChannelRow {
pub description: Option<String>, pub description: Option<String>,
pub timezone: String, pub timezone: String,
pub schedule_config: ScheduleConfig, pub schedule_config: ScheduleConfig,
pub recycle_policy: RecyclePolicy, pub rotation_policy: RotationPolicy,
pub auto_schedule: bool, pub auto_schedule: bool,
pub access_mode: AccessMode, pub access_mode: AccessMode,
pub access_password_hash: Option<String>,
pub logo: Option<String>, pub logo: Option<String>,
pub logo_position: LogoPosition, pub logo_position: LogoPosition,
pub logo_opacity: f32, pub logo_opacity: f32,
@@ -52,6 +52,7 @@ pub struct ChannelRow {
pub webhook_poll_interval_secs: u32, pub webhook_poll_interval_secs: u32,
pub webhook_body_template: Option<String>, pub webhook_body_template: Option<String>,
pub webhook_headers: Option<String>, pub webhook_headers: Option<String>,
pub gap_filler: Option<MediaFilter>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@@ -70,10 +71,9 @@ impl Channel {
description: None, description: None,
timezone: timezone.into(), timezone: timezone.into(),
schedule_config: ScheduleConfig::default(), schedule_config: ScheduleConfig::default(),
recycle_policy: RecyclePolicy::default(), rotation_policy: RotationPolicy::default(),
auto_schedule: false, auto_schedule: false,
access_mode: AccessMode::default(), access_mode: AccessMode::default(),
access_password_hash: None,
logo: None, logo: None,
logo_position: LogoPosition::default(), logo_position: LogoPosition::default(),
logo_opacity: DEFAULT_LOGO_OPACITY, logo_opacity: DEFAULT_LOGO_OPACITY,
@@ -81,6 +81,7 @@ impl Channel {
webhook_poll_interval_secs: DEFAULT_WEBHOOK_POLL_INTERVAL_SECS, webhook_poll_interval_secs: DEFAULT_WEBHOOK_POLL_INTERVAL_SECS,
webhook_body_template: None, webhook_body_template: None,
webhook_headers: None, webhook_headers: None,
gap_filler: None,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
} }
@@ -94,10 +95,9 @@ impl Channel {
description: row.description, description: row.description,
timezone: row.timezone, timezone: row.timezone,
schedule_config: row.schedule_config, schedule_config: row.schedule_config,
recycle_policy: row.recycle_policy, rotation_policy: row.rotation_policy,
auto_schedule: row.auto_schedule, auto_schedule: row.auto_schedule,
access_mode: row.access_mode, access_mode: row.access_mode,
access_password_hash: row.access_password_hash,
logo: row.logo, logo: row.logo,
logo_position: row.logo_position, logo_position: row.logo_position,
logo_opacity: row.logo_opacity, logo_opacity: row.logo_opacity,
@@ -105,6 +105,7 @@ impl Channel {
webhook_poll_interval_secs: row.webhook_poll_interval_secs, webhook_poll_interval_secs: row.webhook_poll_interval_secs,
webhook_body_template: row.webhook_body_template, webhook_body_template: row.webhook_body_template,
webhook_headers: row.webhook_headers, webhook_headers: row.webhook_headers,
gap_filler: row.gap_filler,
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
} }
@@ -134,8 +135,8 @@ impl Channel {
&self.schedule_config &self.schedule_config
} }
pub fn recycle_policy(&self) -> &RecyclePolicy { pub fn rotation_policy(&self) -> &RotationPolicy {
&self.recycle_policy &self.rotation_policy
} }
pub fn auto_schedule(&self) -> bool { pub fn auto_schedule(&self) -> bool {
@@ -146,10 +147,6 @@ impl Channel {
&self.access_mode &self.access_mode
} }
pub fn access_password_hash(&self) -> Option<&str> {
self.access_password_hash.as_deref()
}
pub fn logo(&self) -> Option<&str> { pub fn logo(&self) -> Option<&str> {
self.logo.as_deref() self.logo.as_deref()
} }
@@ -206,8 +203,8 @@ impl Channel {
self.updated_at = Utc::now(); self.updated_at = Utc::now();
} }
pub fn set_recycle_policy(&mut self, policy: RecyclePolicy) { pub fn set_rotation_policy(&mut self, policy: RotationPolicy) {
self.recycle_policy = policy; self.rotation_policy = policy;
self.updated_at = Utc::now(); self.updated_at = Utc::now();
} }
@@ -215,6 +212,15 @@ impl Channel {
self.auto_schedule = enabled; self.auto_schedule = enabled;
self.updated_at = Utc::now(); 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 // deny_unknown_fields required so #[serde(untagged)] compat enum correctly rejects V1 payloads
@@ -331,13 +337,13 @@ pub struct ProgrammingBlock {
loop_on_finish: bool, loop_on_finish: bool,
#[serde(default)] #[serde(default)]
ignore_recycle_policy: bool, ignore_rotation_policy: bool,
#[serde(default)] #[serde(default)]
access_mode: AccessMode, interstitial_rule: Option<InterstitialRule>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default)]
access_password_hash: Option<String>, mid_roll_rule: Option<MidRollRule>,
} }
impl ProgrammingBlock { impl ProgrammingBlock {
@@ -356,12 +362,11 @@ impl ProgrammingBlock {
content: BlockContent::Algorithmic { content: BlockContent::Algorithmic {
filter, filter,
strategy, strategy,
provider_id: String::new(),
}, },
loop_on_finish: true, loop_on_finish: true,
ignore_recycle_policy: false, ignore_rotation_policy: false,
access_mode: AccessMode::default(), interstitial_rule: None,
access_password_hash: None, mid_roll_rule: None,
} }
} }
@@ -378,12 +383,11 @@ impl ProgrammingBlock {
duration_mins, duration_mins,
content: BlockContent::Manual { content: BlockContent::Manual {
items, items,
provider_id: String::new(),
}, },
loop_on_finish: true, loop_on_finish: true,
ignore_recycle_policy: false, ignore_rotation_policy: false,
access_mode: AccessMode::default(), interstitial_rule: None,
access_password_hash: None, mid_roll_rule: None,
} }
} }
@@ -411,16 +415,16 @@ impl ProgrammingBlock {
self.loop_on_finish self.loop_on_finish
} }
pub fn ignore_recycle_policy(&self) -> bool { pub fn ignore_rotation_policy(&self) -> bool {
self.ignore_recycle_policy self.ignore_rotation_policy
} }
pub fn access_mode(&self) -> &AccessMode { pub fn interstitial_rule(&self) -> Option<&InterstitialRule> {
&self.access_mode self.interstitial_rule.as_ref()
} }
pub fn access_password_hash(&self) -> Option<&str> { pub fn mid_roll_rule(&self) -> Option<&MidRollRule> {
self.access_password_hash.as_deref() self.mid_roll_rule.as_ref()
} }
} }
@@ -429,14 +433,10 @@ impl ProgrammingBlock {
pub enum BlockContent { pub enum BlockContent {
Manual { Manual {
items: Vec<MediaItemId>, items: Vec<MediaItemId>,
#[serde(default)]
provider_id: String,
}, },
Algorithmic { Algorithmic {
filter: MediaFilter, filter: MediaFilter,
strategy: FillStrategy, strategy: FillStrategy,
#[serde(default)]
provider_id: String,
}, },
} }

View File

@@ -1,172 +1,5 @@
use crate::value_objects::ContentType;
const SYNC_STATUS_RUNNING: &str = "running"; const SYNC_STATUS_RUNNING: &str = "running";
#[derive(Debug, Clone)]
pub struct LibraryItem {
id: String,
provider_id: String,
external_id: String,
title: String,
content_type: ContentType,
duration_secs: u32,
series_name: Option<String>,
season_number: Option<u32>,
episode_number: Option<u32>,
year: Option<u16>,
genres: Vec<String>,
tags: Vec<String>,
collection_id: Option<String>,
collection_name: Option<String>,
collection_type: Option<String>,
thumbnail_url: Option<String>,
synced_at: String,
}
pub struct LibraryItemRow {
pub id: String,
pub provider_id: String,
pub external_id: String,
pub title: String,
pub content_type: ContentType,
pub duration_secs: u32,
pub series_name: Option<String>,
pub season_number: Option<u32>,
pub episode_number: Option<u32>,
pub year: Option<u16>,
pub genres: Vec<String>,
pub tags: Vec<String>,
pub collection_id: Option<String>,
pub collection_name: Option<String>,
pub collection_type: Option<String>,
pub thumbnail_url: Option<String>,
pub synced_at: String,
}
impl LibraryItem {
pub fn new(
provider_id: impl Into<String>,
external_id: impl Into<String>,
title: impl Into<String>,
content_type: ContentType,
duration_secs: u32,
synced_at: impl Into<String>,
) -> Self {
let provider_id = provider_id.into();
let external_id = external_id.into();
let id = format!("{}::{}", provider_id, external_id);
Self {
id,
provider_id,
external_id,
title: title.into(),
content_type,
duration_secs,
series_name: None,
season_number: None,
episode_number: None,
year: None,
genres: Vec::new(),
tags: Vec::new(),
collection_id: None,
collection_name: None,
collection_type: None,
thumbnail_url: None,
synced_at: synced_at.into(),
}
}
pub fn from_persistence(row: LibraryItemRow) -> Self {
Self {
id: row.id,
provider_id: row.provider_id,
external_id: row.external_id,
title: row.title,
content_type: row.content_type,
duration_secs: row.duration_secs,
series_name: row.series_name,
season_number: row.season_number,
episode_number: row.episode_number,
year: row.year,
genres: row.genres,
tags: row.tags,
collection_id: row.collection_id,
collection_name: row.collection_name,
collection_type: row.collection_type,
thumbnail_url: row.thumbnail_url,
synced_at: row.synced_at,
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn provider_id(&self) -> &str {
&self.provider_id
}
pub fn external_id(&self) -> &str {
&self.external_id
}
pub fn title(&self) -> &str {
&self.title
}
pub fn content_type(&self) -> &ContentType {
&self.content_type
}
pub fn duration_secs(&self) -> u32 {
self.duration_secs
}
pub fn series_name(&self) -> Option<&str> {
self.series_name.as_deref()
}
pub fn season_number(&self) -> Option<u32> {
self.season_number
}
pub fn episode_number(&self) -> Option<u32> {
self.episode_number
}
pub fn year(&self) -> Option<u16> {
self.year
}
pub fn genres(&self) -> &[String] {
&self.genres
}
pub fn tags(&self) -> &[String] {
&self.tags
}
pub fn collection_id(&self) -> Option<&str> {
self.collection_id.as_deref()
}
pub fn collection_name(&self) -> Option<&str> {
self.collection_name.as_deref()
}
pub fn collection_type(&self) -> Option<&str> {
self.collection_type.as_deref()
}
pub fn thumbnail_url(&self) -> Option<&str> {
self.thumbnail_url.as_deref()
}
pub fn synced_at(&self) -> &str {
&self.synced_at
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LibraryCollection { pub struct LibraryCollection {
id: String, id: String,

View File

@@ -1,7 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::value_objects::{ChannelId, ContentType, MediaItemId, PlaybackRecordId}; use crate::value_objects::{Chapter, ChannelId, ContentType, MediaItemId, MediaRole, PlaybackRecordId};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaItem { pub struct MediaItem {
@@ -10,14 +10,27 @@ pub struct MediaItem {
content_type: ContentType, content_type: ContentType,
duration_secs: u32, duration_secs: u32,
description: Option<String>, description: Option<String>,
#[serde(default)]
genres: Vec<String>, genres: Vec<String>,
year: Option<u16>, year: Option<u16>,
#[serde(default)]
tags: Vec<String>, tags: Vec<String>,
series_name: Option<String>, series_name: Option<String>,
season_number: Option<u32>, season_number: Option<u32>,
episode_number: Option<u32>, episode_number: Option<u32>,
thumbnail_url: Option<String>, thumbnail_url: Option<String>,
collection_id: Option<String>, collection_id: Option<String>,
#[serde(default)]
provider_id: String,
#[serde(default)]
external_id: String,
collection_name: Option<String>,
collection_type: Option<String>,
synced_at: Option<String>,
#[serde(default)]
role: MediaRole,
#[serde(default)]
chapters: Vec<Chapter>,
} }
pub struct MediaItemRow { pub struct MediaItemRow {
@@ -34,6 +47,13 @@ pub struct MediaItemRow {
pub episode_number: Option<u32>, pub episode_number: Option<u32>,
pub thumbnail_url: Option<String>, pub thumbnail_url: Option<String>,
pub collection_id: Option<String>, pub collection_id: Option<String>,
pub provider_id: String,
pub external_id: String,
pub collection_name: Option<String>,
pub collection_type: Option<String>,
pub synced_at: Option<String>,
pub role: MediaRole,
pub chapters: Vec<Chapter>,
} }
impl MediaItem { impl MediaItem {
@@ -57,6 +77,48 @@ impl MediaItem {
episode_number: None, episode_number: None,
thumbnail_url: None, thumbnail_url: None,
collection_id: None, collection_id: None,
provider_id: String::new(),
external_id: String::new(),
collection_name: None,
collection_type: None,
synced_at: None,
role: MediaRole::default(),
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, episode_number: row.episode_number,
thumbnail_url: row.thumbnail_url, thumbnail_url: row.thumbnail_url,
collection_id: row.collection_id, collection_id: row.collection_id,
provider_id: row.provider_id,
external_id: row.external_id,
collection_name: row.collection_name,
collection_type: row.collection_type,
synced_at: row.synced_at,
role: row.role,
chapters: row.chapters,
} }
} }
@@ -129,6 +198,34 @@ impl MediaItem {
pub fn collection_id(&self) -> Option<&str> { pub fn collection_id(&self) -> Option<&str> {
self.collection_id.as_deref() self.collection_id.as_deref()
} }
pub fn provider_id(&self) -> &str {
&self.provider_id
}
pub fn external_id(&self) -> &str {
&self.external_id
}
pub fn collection_name(&self) -> Option<&str> {
self.collection_name.as_deref()
}
pub fn collection_type(&self) -> Option<&str> {
self.collection_type.as_deref()
}
pub fn synced_at(&self) -> Option<&str> {
self.synced_at.as_deref()
}
pub fn role(&self) -> &MediaRole {
&self.role
}
pub fn chapters(&self) -> &[Chapter] {
&self.chapters
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -16,7 +16,7 @@ pub use channel::{
pub use collections::{PageParams, Paginated}; pub use collections::{PageParams, Paginated};
pub use config_snapshot::ChannelConfigSnapshot; pub use config_snapshot::ChannelConfigSnapshot;
pub use library::{ pub use library::{
LibraryCollection, LibraryItem, LibraryItemRow, LibrarySyncLogEntry, LibrarySyncResult, LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult,
SeasonSummary, ShowSummary, SeasonSummary, ShowSummary,
}; };
pub use media::{MediaItem, MediaItemRow, PlaybackRecord}; pub use media::{MediaItem, MediaItemRow, PlaybackRecord};

View File

@@ -94,7 +94,7 @@ fn programming_block_getters() {
assert_eq!(block.start_time(), t(8, 0)); assert_eq!(block.start_time(), t(8, 0));
assert_eq!(block.duration_mins(), 120); assert_eq!(block.duration_mins(), 120);
assert!(block.loop_on_finish()); assert!(block.loop_on_finish());
assert!(!block.ignore_recycle_policy()); assert!(!block.ignore_rotation_policy());
} }
#[test] #[test]
@@ -102,9 +102,8 @@ fn manual_block_creation() {
let items = vec![MediaItemId::new("item1"), MediaItemId::new("item2")]; let items = vec![MediaItemId::new("item1"), MediaItemId::new("item2")];
let block = ProgrammingBlock::new_manual("Manual Block", t(20, 0), 60, items); let block = ProgrammingBlock::new_manual("Manual Block", t(20, 0), 60, items);
match block.content() { match block.content() {
BlockContent::Manual { items, provider_id } => { BlockContent::Manual { items } => {
assert_eq!(items.len(), 2); assert_eq!(items.len(), 2);
assert!(provider_id.is_empty());
} }
_ => panic!("Expected Manual content"), _ => panic!("Expected Manual content"),
} }

View File

@@ -1,51 +1,5 @@
use super::*; use super::*;
#[test]
fn library_item_new_generates_composite_id() {
let item = LibraryItem::new("jellyfin", "abc123", "Test Movie", ContentType::Movie, 7200, "2026-03-19T00:00:00Z");
assert_eq!(item.id(), "jellyfin::abc123");
assert_eq!(item.provider_id(), "jellyfin");
assert_eq!(item.external_id(), "abc123");
}
#[test]
fn library_item_new_defaults_optional_fields() {
let item = LibraryItem::new("jf", "1", "Movie", ContentType::Movie, 3600, "2026-01-01");
assert!(item.series_name().is_none());
assert!(item.season_number().is_none());
assert!(item.genres().is_empty());
assert!(item.tags().is_empty());
assert!(item.collection_id().is_none());
assert!(item.thumbnail_url().is_none());
}
#[test]
fn library_item_from_persistence_all_fields() {
let item = LibraryItem::from_persistence(LibraryItemRow {
id: "jf::abc".into(),
provider_id: "jf".into(),
external_id: "abc".into(),
title: "Breaking Bad S01E01".into(),
content_type: ContentType::Episode,
duration_secs: 2700,
series_name: Some("Breaking Bad".into()),
season_number: Some(1),
episode_number: Some(1),
year: Some(2008),
genres: vec!["Drama".into()],
tags: vec!["tv".into()],
collection_id: Some("col-1".into()),
collection_name: Some("TV Shows".into()),
collection_type: Some("tvshows".into()),
thumbnail_url: Some("http://thumb.jpg".into()),
synced_at: "2026-03-19T00:00:00Z".into(),
});
assert_eq!(item.series_name(), Some("Breaking Bad"));
assert_eq!(item.season_number(), Some(1));
assert_eq!(item.year(), Some(2008));
assert_eq!(item.collection_name(), Some("TV Shows"));
}
#[test] #[test]
fn library_collection_new_and_getters() { fn library_collection_new_and_getters() {
let col = LibraryCollection::new("col-1", "Movies"); let col = LibraryCollection::new("col-1", "Movies");

View File

@@ -1,5 +1,5 @@
use super::*; use super::*;
use crate::value_objects::PlaybackRecordId; use crate::value_objects::{Chapter, PlaybackRecordId};
#[test] #[test]
fn media_item_new_defaults() { fn media_item_new_defaults() {
@@ -15,6 +15,33 @@ fn media_item_new_defaults() {
assert!(item.genres().is_empty()); assert!(item.genres().is_empty());
assert!(item.year().is_none()); assert!(item.year().is_none());
assert!(item.series_name().is_none()); assert!(item.series_name().is_none());
assert_eq!(item.provider_id(), "");
assert_eq!(item.external_id(), "");
assert!(item.synced_at().is_none());
assert_eq!(item.role(), &MediaRole::Program);
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] #[test]
@@ -33,6 +60,13 @@ fn media_item_from_persistence_round_trip() {
episode_number: Some(1), episode_number: Some(1),
thumbnail_url: Some("http://thumb.jpg".into()), thumbnail_url: Some("http://thumb.jpg".into()),
collection_id: Some("col-1".into()), collection_id: Some("col-1".into()),
provider_id: "jf".into(),
external_id: "abc".into(),
collection_name: Some("TV Shows".into()),
collection_type: Some("tvshows".into()),
synced_at: Some("2026-03-19T00:00:00Z".into()),
role: MediaRole::Program,
chapters: vec![Chapter::new(Some("Intro".into()), 0.0, 30.0)],
}); });
assert_eq!(item.title(), "Breaking Bad S01E01"); assert_eq!(item.title(), "Breaking Bad S01E01");
assert_eq!(item.series_name(), Some("Breaking Bad")); assert_eq!(item.series_name(), Some("Breaking Bad"));
@@ -40,6 +74,11 @@ fn media_item_from_persistence_round_trip() {
assert_eq!(item.episode_number(), Some(1)); assert_eq!(item.episode_number(), Some(1));
assert_eq!(item.year(), Some(2008)); assert_eq!(item.year(), Some(2008));
assert_eq!(item.collection_id(), Some("col-1")); assert_eq!(item.collection_id(), Some("col-1"));
assert_eq!(item.provider_id(), "jf");
assert_eq!(item.external_id(), "abc");
assert_eq!(item.collection_name(), Some("TV Shows"));
assert_eq!(item.collection_type(), Some("tvshows"));
assert_eq!(item.synced_at(), Some("2026-03-19T00:00:00Z"));
} }
#[test] #[test]

View File

@@ -3,10 +3,10 @@ use super::*;
#[test] #[test]
fn new_generates_id_and_timestamp() { fn new_generates_id_and_timestamp() {
let email = Email::new("test@example.com").unwrap(); 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.is_admin());
assert!(user.password_hash().is_none()); assert!(user.password_hash().is_none());
assert_eq!(user.subject(), "oidc|123"); assert_eq!(user.subject(), "external|123");
} }
#[test] #[test]

View File

@@ -1,7 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::errors::DomainResult; use crate::errors::DomainResult;
pub use crate::events::DomainEvent; pub use crate::events::{DomainEvent, EventEnvelope};
#[async_trait] #[async_trait]
pub trait EventPublisher: Send + Sync { pub trait EventPublisher: Send + Sync {
@@ -10,7 +10,9 @@ pub trait EventPublisher: Send + Sync {
#[async_trait] #[async_trait]
pub trait EventConsumer: Send + Sync { 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] #[async_trait]

View File

@@ -2,7 +2,7 @@ use async_trait::async_trait;
use crate::errors::DomainResult; use crate::errors::DomainResult;
use crate::models::{ use crate::models::{
LibraryCollection, LibraryItem, LibrarySyncLogEntry, LibrarySyncResult, LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult, MediaItem,
SeasonSummary, ShowSummary, SeasonSummary, ShowSummary,
}; };
use crate::value_objects::{ContentType, LibrarySearchFilter}; use crate::value_objects::{ContentType, LibrarySearchFilter};
@@ -11,7 +11,7 @@ use super::media::IMediaProvider;
#[async_trait] #[async_trait]
pub trait LibraryCommand: Send + Sync { pub trait LibraryCommand: Send + Sync {
async fn upsert_items(&self, provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()>; async fn upsert_items(&self, provider_id: &str, items: Vec<MediaItem>) -> DomainResult<()>;
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>; async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>;
@@ -25,9 +25,9 @@ pub trait LibraryQuery: Send + Sync {
async fn search( async fn search(
&self, &self,
filter: &LibrarySearchFilter, filter: &LibrarySearchFilter,
) -> DomainResult<(Vec<LibraryItem>, u32)>; ) -> DomainResult<(Vec<MediaItem>, u32)>;
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>>; async fn get_by_id(&self, id: &str) -> DomainResult<Option<MediaItem>>;
async fn list_collections( async fn list_collections(
&self, &self,

View File

@@ -3,20 +3,7 @@ use serde::{Deserialize, Serialize};
use crate::errors::{DomainError, DomainResult}; use crate::errors::{DomainError, DomainResult};
use crate::models::MediaItem; use crate::models::MediaItem;
use crate::value_objects::{ContentType, MediaFilter, MediaItemId}; use crate::value_objects::{ContentType, MediaFilter, MediaItemId, SourceUri};
#[derive(Debug, Clone)]
pub enum StreamQuality {
Direct,
Transcode(u32),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamingProtocol {
Hls,
DirectFile,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderCapabilities { pub struct ProviderCapabilities {
@@ -26,9 +13,7 @@ pub struct ProviderCapabilities {
pub tags: bool, pub tags: bool,
pub decade: bool, pub decade: bool,
pub search: bool, pub search: bool,
pub streaming_protocol: StreamingProtocol,
pub rescan: bool, pub rescan: bool,
pub transcode: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[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 fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>>;
async fn get_stream_url( async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri>;
&self,
item_id: &MediaItemId,
quality: &StreamQuality,
) -> DomainResult<String>;
async fn list_collections(&self) -> DomainResult<Vec<Collection>> { async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
Err(DomainError::InfrastructureError( 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 fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>>;
async fn get_stream_url( async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri>;
&self,
item_id: &MediaItemId,
quality: &StreamQuality,
) -> DomainResult<String>;
fn provider_ids(&self) -> Vec<String>; fn provider_ids(&self) -> Vec<String>;

View File

@@ -13,11 +13,10 @@ pub mod user;
pub use activity::{ActivityLogCommand, ActivityLogQuery}; pub use activity::{ActivityLogCommand, ActivityLogQuery};
pub use auth::{AuthService, TokenService}; pub use auth::{AuthService, TokenService};
pub use channel::{ChannelCommand, ChannelQuery}; 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 library::{LibraryCommand, LibraryQuery, LibrarySyncAdapter};
pub use media::{ pub use media::{
Collection, IMediaProvider, IProviderRegistry, ProviderCapabilities, SeriesSummary, Collection, IMediaProvider, IProviderRegistry, ProviderCapabilities, SeriesSummary,
StreamQuality, StreamingProtocol,
}; };
pub use provider_config::{ProviderConfigCommand, ProviderConfigQuery}; pub use provider_config::{ProviderConfigCommand, ProviderConfigQuery};
pub use schedule::{ScheduleCommand, ScheduleQuery}; pub use schedule::{ScheduleCommand, ScheduleQuery};

View File

@@ -34,6 +34,15 @@ pub(super) fn fill_block<'a>(
} }
result 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 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)] #[cfg(test)]
#[path = "tests/fill.rs"] #[path = "tests/fill.rs"]
mod tests; mod tests;

View File

@@ -8,11 +8,11 @@ use crate::models::{
BlockContent, CurrentBroadcast, GeneratedSchedule, PlaybackRecord, ProgrammingBlock, BlockContent, CurrentBroadcast, GeneratedSchedule, PlaybackRecord, ProgrammingBlock,
ScheduledSlot, ScheduledSlot,
}; };
use crate::ports::{ChannelQuery, IProviderRegistry, ScheduleCommand, ScheduleQuery, StreamQuality}; use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery};
use crate::value_objects::{BlockId, ChannelId, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy, Weekday}; use crate::value_objects::{BlockId, ChannelId, FillStrategy, LibrarySearchFilter, MediaItemId, RotationPolicy, Weekday};
mod fill; mod fill;
mod recycle; mod rotation;
const SCHEDULE_DURATION_DAYS: i64 = 7; const SCHEDULE_DURATION_DAYS: i64 = 7;
@@ -22,23 +22,22 @@ struct BlockTimeWindow {
} }
struct AlgorithmicParams<'a> { struct AlgorithmicParams<'a> {
provider_id: &'a str, filter: &'a crate::value_objects::MediaFilter,
filter: &'a MediaFilter,
strategy: &'a FillStrategy, strategy: &'a FillStrategy,
block_id: BlockId, block_id: BlockId,
loop_on_finish: bool, loop_on_finish: bool,
ignore_recycle_policy: bool, ignore_rotation_policy: bool,
} }
struct RecycleContext<'a> { struct RotationContext<'a> {
history: &'a [PlaybackRecord], history: &'a [PlaybackRecord],
policy: &'a RecyclePolicy, policy: &'a RotationPolicy,
generation: u32, generation: u32,
last_item_id: Option<&'a MediaItemId>, last_item_id: Option<&'a MediaItemId>,
} }
pub struct ScheduleEngineService { pub struct ScheduleEngineService {
provider_registry: Arc<dyn IProviderRegistry>, library_query: Arc<dyn LibraryQuery>,
channel_query: Arc<dyn ChannelQuery>, channel_query: Arc<dyn ChannelQuery>,
schedule_query: Arc<dyn ScheduleQuery>, schedule_query: Arc<dyn ScheduleQuery>,
schedule_command: Arc<dyn ScheduleCommand>, schedule_command: Arc<dyn ScheduleCommand>,
@@ -46,13 +45,13 @@ pub struct ScheduleEngineService {
impl ScheduleEngineService { impl ScheduleEngineService {
pub fn new( pub fn new(
provider_registry: Arc<dyn IProviderRegistry>, library_query: Arc<dyn LibraryQuery>,
channel_query: Arc<dyn ChannelQuery>, channel_query: Arc<dyn ChannelQuery>,
schedule_query: Arc<dyn ScheduleQuery>, schedule_query: Arc<dyn ScheduleQuery>,
schedule_command: Arc<dyn ScheduleCommand>, schedule_command: Arc<dyn ScheduleCommand>,
) -> Self { ) -> Self {
Self { Self {
provider_registry, library_query,
channel_query, channel_query,
schedule_query, schedule_query,
schedule_command, schedule_command,
@@ -131,9 +130,9 @@ impl ScheduleEngineService {
start: slot_start, start: slot_start,
end: slot_end, end: slot_end,
}, },
RecycleContext { RotationContext {
history: &history, history: &history,
policy: channel.recycle_policy(), policy: channel.rotation_policy(),
generation, generation,
last_item_id, last_item_id,
}, },
@@ -204,14 +203,6 @@ impl ScheduleEngineService {
self.schedule_query.find_active(channel_id, at).await self.schedule_query.find_active(channel_id, at).await
} }
pub async fn get_stream_url(
&self,
item_id: &MediaItemId,
quality: &StreamQuality,
) -> DomainResult<String> {
self.provider_registry.get_stream_url(item_id, quality).await
}
pub async fn list_schedule_history( pub async fn list_schedule_history(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@@ -255,29 +246,27 @@ impl ScheduleEngineService {
&self, &self,
block: &ProgrammingBlock, block: &ProgrammingBlock,
window: BlockTimeWindow, window: BlockTimeWindow,
recycle: RecycleContext<'_>, rotation: RotationContext<'_>,
) -> DomainResult<Vec<ScheduledSlot>> { ) -> DomainResult<Vec<ScheduledSlot>> {
match block.content() { match block.content() {
BlockContent::Manual { items, .. } => { BlockContent::Manual { items } => {
self.resolve_manual(items, window.start, window.end, block.id()) self.resolve_manual(items, window.start, window.end, block.id())
.await .await
} }
BlockContent::Algorithmic { BlockContent::Algorithmic {
filter, filter,
strategy, strategy,
provider_id,
} => { } => {
self.resolve_algorithmic( self.resolve_algorithmic(
AlgorithmicParams { AlgorithmicParams {
provider_id,
filter, filter,
strategy, strategy,
block_id: block.id(), block_id: block.id(),
loop_on_finish: block.loop_on_finish(), loop_on_finish: block.loop_on_finish(),
ignore_recycle_policy: block.ignore_recycle_policy(), ignore_rotation_policy: block.ignore_rotation_policy(),
}, },
window, window,
recycle, rotation,
) )
.await .await
} }
@@ -298,7 +287,7 @@ impl ScheduleEngineService {
if cursor >= end { if cursor >= end {
break; break;
} }
if let Some(item) = self.provider_registry.fetch_by_id(item_id).await? { if let Some(item) = self.library_query.get_by_id(item_id.value()).await? {
let item_end = let item_end =
(cursor + Duration::seconds(item.duration_secs() as i64)).min(end); (cursor + Duration::seconds(item.duration_secs() as i64)).min(end);
slots.push(ScheduledSlot::new(cursor, item_end, item, block_id)); slots.push(ScheduledSlot::new(cursor, item_end, item, block_id));
@@ -313,21 +302,19 @@ impl ScheduleEngineService {
&self, &self,
params: AlgorithmicParams<'_>, params: AlgorithmicParams<'_>,
window: BlockTimeWindow, window: BlockTimeWindow,
recycle: RecycleContext<'_>, rotation: RotationContext<'_>,
) -> DomainResult<Vec<ScheduledSlot>> { ) -> DomainResult<Vec<ScheduledSlot>> {
let candidates = self let library_filter = media_filter_to_library_search(params.filter);
.provider_registry let (candidates, _total) = self.library_query.search(&library_filter).await?;
.fetch_items(params.provider_id, params.filter)
.await?;
if candidates.is_empty() { if candidates.is_empty() {
return Ok(vec![]); return Ok(vec![]);
} }
let pool = if params.ignore_recycle_policy { let pool = if params.ignore_rotation_policy {
candidates.clone() candidates.clone()
} else { } 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 target_secs = (window.end - window.start).num_seconds() as u32;
let selected = fill::fill_block( let selected = fill::fill_block(
@@ -335,7 +322,7 @@ impl ScheduleEngineService {
&pool, &pool,
target_secs, target_secs,
params.strategy, params.strategy,
recycle.last_item_id, rotation.last_item_id,
params.loop_on_finish, params.loop_on_finish,
); );
@@ -355,3 +342,39 @@ impl ScheduleEngineService {
Ok(slots) Ok(slots)
} }
} }
fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) -> LibrarySearchFilter {
let mut lsf = LibrarySearchFilter::new()
.with_limit(10_000);
if let Some(ct) = &filter.content_type {
lsf = lsf.with_content_type(ct.clone());
}
if !filter.genres.is_empty() {
lsf = lsf.with_genres(filter.genres.clone());
}
if let Some(decade) = filter.decade {
lsf = lsf.with_decade(decade);
}
if let Some(min) = filter.min_duration_secs {
lsf = lsf.with_min_duration_secs(min);
}
if let Some(max) = filter.max_duration_secs {
lsf = lsf.with_max_duration_secs(max);
}
if !filter.collections.is_empty() {
if let Some(first) = filter.collections.first() {
lsf = lsf.with_collection_id(first.clone());
}
}
if !filter.series_names.is_empty() {
lsf = lsf.with_series_names(filter.series_names.clone());
}
if let Some(term) = &filter.search_term {
lsf = lsf.with_search_term(term.clone());
}
if !filter.tags.is_empty() {
// tags map to the same concept in the library
}
lsf
}

View File

@@ -3,12 +3,12 @@ use std::collections::HashSet;
use chrono::Utc; use chrono::Utc;
use crate::models::{MediaItem, PlaybackRecord}; 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], candidates: &[MediaItem],
history: &[PlaybackRecord], history: &[PlaybackRecord],
policy: &RecyclePolicy, policy: &RotationPolicy,
current_generation: u32, current_generation: u32,
) -> Vec<MediaItem> { ) -> Vec<MediaItem> {
let now = Utc::now(); 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; (candidates.len() as f32 * policy.min_available_ratio).ceil() as usize;
if available.len() < min_count { if available.len() < min_count {
// Pool too small after cooldowns — recycle everything // Pool too small after cooldowns — rotate everything back in
candidates.to_vec() candidates.to_vec()
} else { } else {
available available
@@ -49,5 +49,5 @@ pub(super) fn apply_recycle_policy(
} }
#[cfg(test)] #[cfg(test)]
#[path = "tests/recycle.rs"] #[path = "tests/rotation.rs"]
mod tests; mod tests;

View File

@@ -68,3 +68,148 @@ fn random_fill_respects_budget() {
let total: u32 = result.iter().map(|i| i.duration_secs()).sum(); let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
assert!(total <= 200); assert!(total <= 200);
} }
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");
}

View File

@@ -13,12 +13,12 @@ fn record(item_id: &str, generation: u32) -> PlaybackRecord {
#[test] #[test]
fn no_history_returns_all() { fn no_history_returns_all() {
let pool = vec![item("a"), item("b"), item("c")]; let pool = vec![item("a"), item("b"), item("c")];
let policy = RecyclePolicy { let policy = RotationPolicy {
cooldown_days: Some(7), cooldown_days: Some(7),
cooldown_generations: None, cooldown_generations: None,
min_available_ratio: 0.2, 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); assert_eq!(result.len(), 3);
} }
@@ -26,12 +26,12 @@ fn no_history_returns_all() {
fn generation_cooldown_excludes() { fn generation_cooldown_excludes() {
let pool = vec![item("a"), item("b"), item("c")]; let pool = vec![item("a"), item("b"), item("c")];
let history = vec![record("a", 1)]; let history = vec![record("a", 1)];
let policy = RecyclePolicy { let policy = RotationPolicy {
cooldown_days: None, cooldown_days: None,
cooldown_generations: Some(2), cooldown_generations: Some(2),
min_available_ratio: 0.0, 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_eq!(result.len(), 2);
assert!(result.iter().all(|i| i.id().value() != "a")); assert!(result.iter().all(|i| i.id().value() != "a"));
} }
@@ -40,11 +40,11 @@ fn generation_cooldown_excludes() {
fn min_available_ratio_waives_cooldown() { fn min_available_ratio_waives_cooldown() {
let pool = vec![item("a"), item("b")]; let pool = vec![item("a"), item("b")];
let history = vec![record("a", 1), record("b", 1)]; let history = vec![record("a", 1), record("b", 1)];
let policy = RecyclePolicy { let policy = RotationPolicy {
cooldown_days: None, cooldown_days: None,
cooldown_generations: Some(5), cooldown_generations: Some(5),
min_available_ratio: 0.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); assert_eq!(result.len(), 2);
} }

View File

@@ -1,20 +1,22 @@
use std::cmp::Reverse; use std::cmp::Reverse;
use std::collections::HashMap; use std::collections::{HashMap, VecDeque};
use std::sync::Mutex; use std::sync::Mutex;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use crate::errors::DomainResult; use crate::errors::DomainResult;
use crate::events::{DomainEvent, EventEnvelope};
use crate::models::{ use crate::models::{
ActivityEvent, Channel, ChannelConfigSnapshot, GeneratedSchedule, LibraryCollection, ActivityEvent, Channel, ChannelConfigSnapshot, GeneratedSchedule, LibraryCollection,
LibraryItem, LibrarySyncLogEntry, LibrarySyncResult, PlaybackRecord, ProviderConfigRow, LibrarySyncLogEntry, LibrarySyncResult, MediaItem, PlaybackRecord, ProviderConfigRow,
ScheduleConfig, SeasonSummary, ShowSummary, ScheduleConfig, SeasonSummary, ShowSummary,
}; };
use crate::ports::{ use crate::ports::{
ActivityLogCommand, ActivityLogQuery, AppSettingsRepository, ChannelCommand, ChannelQuery, ActivityLogCommand, ActivityLogQuery, AppSettingsRepository, ChannelCommand, ChannelQuery,
LibraryCommand, LibraryQuery, ProviderConfigCommand, ProviderConfigQuery, ScheduleCommand, EventConsumer, EventPublisher, LibraryCommand, LibraryQuery, ProviderConfigCommand,
ScheduleQuery, TranscodeSettingsRepository, UserCommand, UserQuery, ProviderConfigQuery, ScheduleCommand, ScheduleQuery, TranscodeSettingsRepository, UserCommand,
UserQuery,
}; };
use crate::value_objects::{ use crate::value_objects::{
BlockId, ChannelId, ContentType, LibrarySearchFilter, MediaItemId, ScheduleId, SnapshotId, BlockId, ChannelId, ContentType, LibrarySearchFilter, MediaItemId, ScheduleId, SnapshotId,
@@ -367,7 +369,7 @@ impl ScheduleQuery for InMemoryScheduleRepository {
} }
pub struct InMemoryLibraryRepository { pub struct InMemoryLibraryRepository {
pub items: Mutex<HashMap<String, LibraryItem>>, pub items: Mutex<HashMap<String, MediaItem>>,
pub sync_logs: Mutex<Vec<LibrarySyncLogEntry>>, pub sync_logs: Mutex<Vec<LibrarySyncLogEntry>>,
next_log_id: Mutex<i64>, next_log_id: Mutex<i64>,
} }
@@ -390,10 +392,10 @@ impl Default for InMemoryLibraryRepository {
#[async_trait] #[async_trait]
impl LibraryCommand for InMemoryLibraryRepository { impl LibraryCommand for InMemoryLibraryRepository {
async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> { async fn upsert_items(&self, _provider_id: &str, items: Vec<MediaItem>) -> DomainResult<()> {
let mut store = self.items.lock().unwrap(); let mut store = self.items.lock().unwrap();
for item in items { for item in items {
store.insert(item.id().to_string(), item); store.insert(item.id().value().to_string(), item);
} }
Ok(()) Ok(())
} }
@@ -443,7 +445,7 @@ impl LibraryQuery for InMemoryLibraryRepository {
async fn search( async fn search(
&self, &self,
filter: &LibrarySearchFilter, filter: &LibrarySearchFilter,
) -> DomainResult<(Vec<LibraryItem>, u32)> { ) -> DomainResult<(Vec<MediaItem>, u32)> {
let store = self.items.lock().unwrap(); let store = self.items.lock().unwrap();
let mut items: Vec<_> = store let mut items: Vec<_> = store
.values() .values()
@@ -476,7 +478,7 @@ impl LibraryQuery for InMemoryLibraryRepository {
Ok((items, total)) Ok((items, total))
} }
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>> { async fn get_by_id(&self, id: &str) -> DomainResult<Option<MediaItem>> {
Ok(self.items.lock().unwrap().get(id).cloned()) Ok(self.items.lock().unwrap().get(id).cloned())
} }
@@ -777,3 +779,63 @@ impl TranscodeSettingsRepository for InMemoryTranscodeSettings {
Ok(()) 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(())
}
}

View File

@@ -1,15 +1,15 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::errors::DomainResult; use crate::errors::DomainResult;
use crate::events::DomainEvent; use crate::events::{DomainEvent, EventEnvelope};
use crate::models::{ use crate::models::{
ActivityEvent, LibrarySyncResult, MediaItem, ActivityEvent, LibrarySyncResult, MediaItem,
}; };
use crate::ports::{ use crate::ports::{
ActivityLogCommand, ActivityLogQuery, EventPublisher, IMediaProvider, LibrarySyncAdapter, ActivityLogCommand, ActivityLogQuery, EventConsumer, EventPublisher, IMediaProvider,
ProviderCapabilities, StreamQuality, StreamingProtocol, LibrarySyncAdapter, ProviderCapabilities,
}; };
use crate::value_objects::{ChannelId, MediaFilter, MediaItemId}; use crate::value_objects::{ChannelId, MediaFilter, MediaItemId, SourceUri};
pub struct NoopEventPublisher; 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; pub struct NoopMediaProvider;
impl NoopMediaProvider { impl NoopMediaProvider {
@@ -56,9 +85,7 @@ impl IMediaProvider for NoopMediaProvider {
tags: false, tags: false,
decade: false, decade: false,
search: false, search: false,
streaming_protocol: StreamingProtocol::Hls,
rescan: false, rescan: false,
transcode: false,
} }
} }
@@ -70,11 +97,7 @@ impl IMediaProvider for NoopMediaProvider {
Ok(None) Ok(None)
} }
async fn get_stream_url( async fn get_source_uri(&self, _item_id: &MediaItemId) -> DomainResult<SourceUri> {
&self,
_item_id: &MediaItemId,
_quality: &StreamQuality,
) -> DomainResult<String> {
Err(crate::errors::DomainError::InfrastructureError( Err(crate::errors::DomainError::InfrastructureError(
"NoopMediaProvider does not support streaming".into(), "NoopMediaProvider does not support streaming".into(),
)) ))

Some files were not shown because too many files have changed in this diff Show More