diff --git a/crates/adapters/adapter-common/src/lib.rs b/crates/adapters/adapter-common/src/lib.rs index 41b2513..9acdc95 100644 --- a/crates/adapters/adapter-common/src/lib.rs +++ b/crates/adapters/adapter-common/src/lib.rs @@ -1,3 +1,6 @@ +pub mod ffprobe; +pub mod role_detector; + use chrono::{DateTime, Utc}; use domain::{DomainError, RotationPolicy, ScheduleConfig, ScheduleConfigCompat}; use serde::de::DeserializeOwned; diff --git a/crates/adapters/adapter-common/src/role_detector.rs b/crates/adapters/adapter-common/src/role_detector.rs new file mode 100644 index 0000000..cfd9f84 --- /dev/null +++ b/crates/adapters/adapter-common/src/role_detector.rs @@ -0,0 +1,158 @@ +use domain::{MediaItem, MediaRole}; + +#[derive(Debug, Clone)] +pub struct RoleDetectionConfig { + pub interstitial_collection_patterns: Vec, + pub interstitial_tag_patterns: Vec, +} + +impl Default for RoleDetectionConfig { + fn default() -> Self { + Self { + interstitial_collection_patterns: vec![ + "bumper".into(), + "bumpers".into(), + "ad".into(), + "ads".into(), + "interstitial".into(), + "interstitials".into(), + "promo".into(), + "promos".into(), + "ident".into(), + "idents".into(), + ], + interstitial_tag_patterns: vec![ + "bumper".into(), + "interstitial".into(), + "ad".into(), + "promo".into(), + "ident".into(), + ], + } + } +} + +pub fn detect_role(item: &MediaItem, config: &RoleDetectionConfig) -> MediaRole { + if matches_collection_pattern(item, &config.interstitial_collection_patterns) { + return MediaRole::Interstitial; + } + + if matches_tag_pattern(item, &config.interstitial_tag_patterns) { + return MediaRole::Interstitial; + } + + MediaRole::Program +} + +fn matches_collection_pattern(item: &MediaItem, patterns: &[String]) -> bool { + let collection_name = match item.collection_name() { + Some(name) => name.to_lowercase(), + None => return false, + }; + + patterns + .iter() + .any(|pattern| collection_name == pattern.to_lowercase()) +} + +fn matches_tag_pattern(item: &MediaItem, patterns: &[String]) -> bool { + item.tags().iter().any(|tag| { + let lower_tag = tag.to_lowercase(); + patterns + .iter() + .any(|pattern| lower_tag == pattern.to_lowercase()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::{ContentType, MediaItemId, MediaItemRow}; + + fn make_item( + collection_name: Option<&str>, + tags: Vec<&str>, + ) -> MediaItem { + MediaItem::from_persistence(MediaItemRow { + id: MediaItemId::new("test::1"), + provider_id: "test".into(), + external_id: "1".into(), + title: "Test Item".into(), + content_type: ContentType::Movie, + duration_secs: 3600, + description: None, + series_name: None, + season_number: None, + episode_number: None, + year: None, + genres: vec![], + tags: tags.into_iter().map(String::from).collect(), + collection_id: None, + collection_name: collection_name.map(String::from), + collection_type: None, + thumbnail_url: None, + synced_at: None, + role: MediaRole::default(), + chapters: vec![], + }) + } + + #[test] + fn item_from_bumpers_collection_gets_interstitial() { + let item = make_item(Some("Bumpers"), vec![]); + let config = RoleDetectionConfig::default(); + assert_eq!(detect_role(&item, &config), MediaRole::Interstitial); + } + + #[test] + fn item_with_bumper_tag_gets_interstitial() { + let item = make_item(None, vec!["bumper"]); + let config = RoleDetectionConfig::default(); + assert_eq!(detect_role(&item, &config), MediaRole::Interstitial); + } + + #[test] + fn item_from_ads_collection_gets_interstitial() { + let item = make_item(Some("Ads"), vec![]); + let config = RoleDetectionConfig::default(); + assert_eq!(detect_role(&item, &config), MediaRole::Interstitial); + } + + #[test] + fn normal_item_from_movies_gets_program() { + let item = make_item(Some("Movies"), vec![]); + let config = RoleDetectionConfig::default(); + assert_eq!(detect_role(&item, &config), MediaRole::Program); + } + + #[test] + fn item_with_no_collection_or_tags_gets_program() { + let item = make_item(None, vec![]); + let config = RoleDetectionConfig::default(); + assert_eq!(detect_role(&item, &config), MediaRole::Program); + } + + #[test] + fn case_insensitive_collection_match() { + let item = make_item(Some("INTERSTITIALS"), vec![]); + let config = RoleDetectionConfig::default(); + assert_eq!(detect_role(&item, &config), MediaRole::Interstitial); + } + + #[test] + fn case_insensitive_tag_match() { + let item = make_item(None, vec!["PROMO"]); + let config = RoleDetectionConfig::default(); + assert_eq!(detect_role(&item, &config), MediaRole::Interstitial); + } + + #[test] + fn custom_config_patterns() { + let item = make_item(Some("Station IDs"), vec![]); + let config = RoleDetectionConfig { + interstitial_collection_patterns: vec!["station ids".into()], + interstitial_tag_patterns: vec![], + }; + assert_eq!(detect_role(&item, &config), MediaRole::Interstitial); + } +} diff --git a/crates/adapters/sqlite/src/library.rs b/crates/adapters/sqlite/src/library.rs index 2ef69b9..7b4e5c9 100644 --- a/crates/adapters/sqlite/src/library.rs +++ b/crates/adapters/sqlite/src/library.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; 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_enum_or_default, parse_genres_blob, serialize_enum_as_string}; use domain::{ ports::library::{LibraryCommand, LibraryQuery}, ContentType, DomainError, DomainResult, LibraryCollection, @@ -40,10 +40,15 @@ struct LibraryItemRow { thumbnail_url: Option, synced_at: String, chapters: Option, + role: Option, } impl LibraryItemRow { fn into_media_item(self) -> MediaItem { + let role: MediaRole = self + .role + .map(parse_enum_or_default) + .unwrap_or_default(); MediaItem::from_persistence(DomainMediaItemRow { id: domain::MediaItemId::new(&self.id), provider_id: self.provider_id, @@ -63,7 +68,7 @@ impl LibraryItemRow { collection_type: self.collection_type, thumbnail_url: self.thumbnail_url, synced_at: Some(self.synced_at), - role: MediaRole::default(), + role, chapters: self .chapters .as_deref() @@ -116,12 +121,14 @@ impl LibraryCommand for SqliteLibraryRepository { Some(serde_json::to_string(item.chapters()).unwrap_or_default()) }; + let role_str = serialize_enum_as_string(item.role(), "program"); + sqlx::query( "INSERT OR REPLACE INTO library_items (id, provider_id, external_id, title, content_type, duration_secs, series_name, season_number, episode_number, year, genres, tags, - collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters, role) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ) .bind(item.id().value()) .bind(item.provider_id()) @@ -141,6 +148,7 @@ impl LibraryCommand for SqliteLibraryRepository { .bind(item.thumbnail_url()) .bind(item.synced_at().unwrap_or("")) .bind(&chapters_json) + .bind(&role_str) .execute(&mut *tx) .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; @@ -151,6 +159,22 @@ impl LibraryCommand for SqliteLibraryRepository { .map_err(|e| DomainError::InfrastructureError(e.to_string())) } + async fn update_role(&self, item_id: &str, role: MediaRole) -> DomainResult<()> { + let role_str = serialize_enum_as_string(&role, "program"); + let rows = sqlx::query("UPDATE library_items SET role = ? WHERE id = ?") + .bind(&role_str) + .bind(item_id) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + if rows.rows_affected() == 0 { + return Err(DomainError::NotFound(format!( + "Library item {item_id} not found" + ))); + } + Ok(()) + } + async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> { sqlx::query("DELETE FROM library_items WHERE provider_id = ?") .bind(provider_id) diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs index 876e5f0..68161f0 100644 --- a/crates/api-types/src/lib.rs +++ b/crates/api-types/src/lib.rs @@ -20,7 +20,7 @@ pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo}; pub use iptv::IptvParams; pub use library::{ CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam, - SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry, + SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry, UpdateRoleRequest, }; pub use providers::{ProviderConfigRequest, ProviderConfigResponse}; pub use schedule::{ diff --git a/crates/api-types/src/library.rs b/crates/api-types/src/library.rs index e81491a..0894529 100644 --- a/crates/api-types/src/library.rs +++ b/crates/api-types/src/library.rs @@ -22,6 +22,7 @@ pub struct LibraryItemResponse { pub collection_type: Option, pub thumbnail_url: Option, pub synced_at: Option, + pub role: String, } impl From for LibraryItemResponse { @@ -44,6 +45,7 @@ impl From for LibraryItemResponse { collection_type: i.collection_type().map(|s| s.to_string()), thumbnail_url: i.thumbnail_url().map(|s| s.to_string()), synced_at: i.synced_at().map(|s| s.to_string()), + role: enum_to_string(i.role()), } } } @@ -166,3 +168,8 @@ pub struct GenresParams { pub content_type: Option, pub provider: Option, } + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateRoleRequest { + pub role: String, +} diff --git a/crates/domain/src/ports/library.rs b/crates/domain/src/ports/library.rs index 70275ac..4b943fc 100644 --- a/crates/domain/src/ports/library.rs +++ b/crates/domain/src/ports/library.rs @@ -5,7 +5,7 @@ use crate::models::{ LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult, MediaItem, SeasonSummary, ShowSummary, }; -use crate::value_objects::{ContentType, LibrarySearchFilter}; +use crate::value_objects::{ContentType, LibrarySearchFilter, MediaRole}; use super::media::IMediaProvider; @@ -18,6 +18,8 @@ pub trait LibraryCommand: Send + Sync { async fn log_sync_start(&self, provider_id: &str) -> DomainResult; async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>; + + async fn update_role(&self, item_id: &str, role: MediaRole) -> DomainResult<()>; } #[async_trait] diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index a4bbe1c..6e8cccc 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -418,6 +418,18 @@ impl LibraryCommand for InMemoryLibraryRepository { Ok(id) } + async fn update_role(&self, item_id: &str, role: crate::value_objects::MediaRole) -> DomainResult<()> { + let mut store = self.items.lock().unwrap(); + if let Some(item) = store.get_mut(item_id) { + item.set_role(role); + Ok(()) + } else { + Err(crate::errors::DomainError::NotFound(format!( + "Library item {item_id} not found" + ))) + } + } + async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()> { let mut logs = self.sync_logs.lock().unwrap(); if let Some(entry) = logs.iter_mut().find(|e| e.id() == log_id) { diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 3a44d0a..55f5584 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -19,6 +19,7 @@ domain = { workspace = true } application = { workspace = true } api-types = { workspace = true } infra-wiring = { workspace = true } +adapter-common = { workspace = true } adapter-auth = { workspace = true } adapter-event-publisher = { workspace = true } diff --git a/crates/presentation/src/factory.rs b/crates/presentation/src/factory.rs index 6d296a2..9268559 100644 --- a/crates/presentation/src/factory.rs +++ b/crates/presentation/src/factory.rs @@ -435,10 +435,16 @@ impl IProviderRegistry for SimpleProviderRegistry { } } -fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> domain::MediaItem { +fn provider_item_to_library_item( + item: domain::MediaItem, + provider_id: &str, + role_config: &adapter_common::role_detector::RoleDetectionConfig, +) -> domain::MediaItem { let external_id = item.id().value().to_string(); let now = chrono::Utc::now().to_rfc3339(); + let role = adapter_common::role_detector::detect_role(&item, role_config); + domain::MediaItem::from_persistence(domain::MediaItemRow { id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)), provider_id: provider_id.to_string(), @@ -454,22 +460,26 @@ fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> genres: item.genres().to_vec(), tags: item.tags().to_vec(), collection_id: item.collection_id().map(|s| s.to_string()), - collection_name: None, - collection_type: None, + collection_name: item.collection_name().map(|s| s.to_string()), + collection_type: item.collection_type().map(|s| s.to_string()), thumbnail_url: item.thumbnail_url().map(|s| s.to_string()), synced_at: Some(now), - role: domain::MediaRole::default(), + role, chapters: item.chapters().to_vec(), }) } struct SimpleSyncAdapter { library_command: Arc, + role_config: adapter_common::role_detector::RoleDetectionConfig, } impl SimpleSyncAdapter { fn new(library_command: Arc) -> Self { - Self { library_command } + Self { + library_command, + role_config: adapter_common::role_detector::RoleDetectionConfig::default(), + } } } @@ -520,11 +530,23 @@ impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter { return result; } - let library_items: Vec = items + let mut library_items: Vec = items .into_iter() - .map(|item| provider_item_to_library_item(item, provider_id)) + .map(|item| provider_item_to_library_item(item, provider_id, &self.role_config)) .collect(); + for item in &mut library_items { + if adapter_common::ffprobe::should_probe_chapters( + item.content_type(), + item.duration_secs(), + ) { + if let Ok(uri) = provider.get_source_uri(item.id()).await { + let chapters = adapter_common::ffprobe::extract_chapters(&uri).await; + item.set_chapters(chapters); + } + } + } + if let Err(e) = self .library_command .upsert_items(provider_id, library_items) diff --git a/crates/presentation/src/handlers/library.rs b/crates/presentation/src/handlers/library.rs index 1f79fd1..62427a7 100644 --- a/crates/presentation/src/handlers/library.rs +++ b/crates/presentation/src/handlers/library.rs @@ -4,9 +4,10 @@ use axum::extract::{Path, Query, State}; use api_types::{ CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, PaginatedResponse, ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry, + UpdateRoleRequest, }; use application::library::{SearchItemsQuery, TriggerSyncCommand}; -use domain::DomainError; +use domain::{DomainError, MediaRole}; use crate::errors::AppError; use crate::extractors::{AdminUser, CurrentUser}; @@ -131,3 +132,32 @@ pub async fn trigger_sync( application::library::sync::execute(&state.library_command_deps, cmd).await?; Ok(axum::http::StatusCode::ACCEPTED) } + +pub async fn update_role( + State(state): State, + AdminUser(_user): AdminUser, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + let role: MediaRole = serde_json::from_value(serde_json::Value::String(body.role.clone())) + .map_err(|_| { + AppError(DomainError::ValidationError(format!( + "Invalid role '{}'. Must be 'program' or 'interstitial'", + body.role + ))) + })?; + + state + .library_command_deps + .library_command + .update_role(&id, role) + .await?; + + let item = state + .library_query + .get_by_id(&id) + .await? + .ok_or_else(|| AppError(DomainError::NotFound(format!("Library item {id} not found"))))?; + + Ok(Json(LibraryItemResponse::from(item))) +} diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 9de0f44..1d49e58 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -80,6 +80,7 @@ fn library_router() -> Router { Router::new() .route("/items", get(handlers::library::search_items)) .route("/items/{id}", get(handlers::library::get_item)) + .route("/items/{id}/role", put(handlers::library::update_role)) .route("/collections", get(handlers::library::list_collections)) .route("/shows", get(handlers::library::list_shows)) .route("/seasons", get(handlers::library::list_seasons)) diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 818d1c3..ab93aa6 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -17,6 +17,7 @@ local-files = ["dep:adapter-local-files"] domain = { workspace = true } application = { workspace = true } infra-wiring = { workspace = true } +adapter-common = { workspace = true } adapter-sqlite = { workspace = true, optional = true } adapter-auth = { workspace = true } adapter-jellyfin = { workspace = true, optional = true } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index e629708..2483c33 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -341,10 +341,16 @@ impl IProviderRegistry for SimpleProviderRegistry { } } -fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> domain::MediaItem { +fn provider_item_to_library_item( + item: domain::MediaItem, + provider_id: &str, + role_config: &adapter_common::role_detector::RoleDetectionConfig, +) -> domain::MediaItem { let external_id = item.id().value().to_string(); let now = chrono::Utc::now().to_rfc3339(); + let role = adapter_common::role_detector::detect_role(&item, role_config); + domain::MediaItem::from_persistence(domain::MediaItemRow { id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)), provider_id: provider_id.to_string(), @@ -360,22 +366,26 @@ fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> genres: item.genres().to_vec(), tags: item.tags().to_vec(), collection_id: item.collection_id().map(|s| s.to_string()), - collection_name: None, - collection_type: None, + collection_name: item.collection_name().map(|s| s.to_string()), + collection_type: item.collection_type().map(|s| s.to_string()), thumbnail_url: item.thumbnail_url().map(|s| s.to_string()), synced_at: Some(now), - role: domain::MediaRole::default(), + role, chapters: item.chapters().to_vec(), }) } struct SimpleSyncAdapter { library_command: Arc, + role_config: adapter_common::role_detector::RoleDetectionConfig, } impl SimpleSyncAdapter { fn new(library_command: Arc) -> Self { - Self { library_command } + Self { + library_command, + role_config: adapter_common::role_detector::RoleDetectionConfig::default(), + } } } @@ -386,6 +396,7 @@ impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter { provider: &dyn IMediaProvider, provider_id: &str, ) -> domain::LibrarySyncResult { + use adapter_common::ffprobe; use std::time::Instant; let start = Instant::now(); @@ -426,11 +437,20 @@ impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter { return result; } - let library_items: Vec = items + let mut library_items: Vec = items .into_iter() - .map(|item| provider_item_to_library_item(item, provider_id)) + .map(|item| provider_item_to_library_item(item, provider_id, &self.role_config)) .collect(); + for item in &mut library_items { + if ffprobe::should_probe_chapters(item.content_type(), item.duration_secs()) { + if let Ok(uri) = provider.get_source_uri(item.id()).await { + let chapters = ffprobe::extract_chapters(&uri).await; + item.set_chapters(chapters); + } + } + } + if let Err(e) = self .library_command .upsert_items(provider_id, library_items) diff --git a/k-tv-backend/migrations_sqlite/20260712000003_add_role_to_library_items.sql b/k-tv-backend/migrations_sqlite/20260712000003_add_role_to_library_items.sql new file mode 100644 index 0000000..9c6eb85 --- /dev/null +++ b/k-tv-backend/migrations_sqlite/20260712000003_add_role_to_library_items.sql @@ -0,0 +1 @@ +ALTER TABLE library_items ADD COLUMN role TEXT NOT NULL DEFAULT 'program'; diff --git a/migrations_sqlite/20260712000003_add_role_to_library_items.sql b/migrations_sqlite/20260712000003_add_role_to_library_items.sql new file mode 100644 index 0000000..9c6eb85 --- /dev/null +++ b/migrations_sqlite/20260712000003_add_role_to_library_items.sql @@ -0,0 +1 @@ +ALTER TABLE library_items ADD COLUMN role TEXT NOT NULL DEFAULT 'program';