This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -1795,6 +1796,7 @@ name = "presentation"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"adapter-auth",
|
"adapter-auth",
|
||||||
|
"adapter-common",
|
||||||
"adapter-event-publisher",
|
"adapter-event-publisher",
|
||||||
"adapter-jellyfin",
|
"adapter-jellyfin",
|
||||||
"adapter-local-files",
|
"adapter-local-files",
|
||||||
@@ -3372,6 +3374,7 @@ name = "worker"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"adapter-auth",
|
"adapter-auth",
|
||||||
|
"adapter-common",
|
||||||
"adapter-event-publisher",
|
"adapter-event-publisher",
|
||||||
"adapter-jellyfin",
|
"adapter-jellyfin",
|
||||||
"adapter-local-files",
|
"adapter-local-files",
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ uuid = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|||||||
180
crates/adapters/adapter-common/src/ffprobe.rs
Normal file
180
crates/adapters/adapter-common/src/ffprobe.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
use domain::{Chapter, ContentType, SourceUri};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
const CHAPTER_PROBE_MIN_DURATION_SECS: u32 = 2700;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FfprobeOutput {
|
||||||
|
#[serde(default)]
|
||||||
|
chapters: Vec<FfprobeChapter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FfprobeChapter {
|
||||||
|
#[serde(default)]
|
||||||
|
start_time: String,
|
||||||
|
#[serde(default)]
|
||||||
|
end_time: String,
|
||||||
|
#[serde(default)]
|
||||||
|
tags: Option<FfprobeChapterTags>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FfprobeChapterTags {
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_chapters_json(json: &str) -> Vec<Chapter> {
|
||||||
|
let output: FfprobeOutput = match serde_json::from_str(json) {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
output
|
||||||
|
.chapters
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| {
|
||||||
|
let title = c.tags.and_then(|t| t.title);
|
||||||
|
let start_secs = c.start_time.parse::<f64>().unwrap_or(0.0);
|
||||||
|
let end_secs = c.end_time.parse::<f64>().unwrap_or(0.0);
|
||||||
|
Chapter::new(title, start_secs, end_secs)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_probe_chapters(content_type: &ContentType, duration_secs: u32) -> bool {
|
||||||
|
matches!(content_type, ContentType::Movie) || duration_secs > CHAPTER_PROBE_MIN_DURATION_SECS
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn extract_chapters(source_uri: &SourceUri) -> Vec<Chapter> {
|
||||||
|
let path = match source_uri {
|
||||||
|
SourceUri::FilePath { path } => path.clone(),
|
||||||
|
SourceUri::NetworkUrl { url } => url.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = tokio::process::Command::new("ffprobe")
|
||||||
|
.args([
|
||||||
|
"-v",
|
||||||
|
"quiet",
|
||||||
|
"-print_format",
|
||||||
|
"json",
|
||||||
|
"-show_chapters",
|
||||||
|
&path,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(output) if output.status.success() => {
|
||||||
|
let json = String::from_utf8_lossy(&output.stdout);
|
||||||
|
parse_chapters_json(&json)
|
||||||
|
}
|
||||||
|
Ok(output) => {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %path,
|
||||||
|
stderr = %String::from_utf8_lossy(&output.stderr),
|
||||||
|
"ffprobe exited with non-zero status"
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "ffprobe not available or failed to execute");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ffprobe_json_with_chapters() {
|
||||||
|
let json = r#"{
|
||||||
|
"chapters": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"time_base": "1/1000",
|
||||||
|
"start": 0,
|
||||||
|
"start_time": "0.000000",
|
||||||
|
"end": 300000,
|
||||||
|
"end_time": "300.000000",
|
||||||
|
"tags": { "title": "Opening" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"time_base": "1/1000",
|
||||||
|
"start": 300000,
|
||||||
|
"start_time": "300.000000",
|
||||||
|
"end": 1800000,
|
||||||
|
"end_time": "1800.000000",
|
||||||
|
"tags": { "title": "Main Feature" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"time_base": "1/1000",
|
||||||
|
"start": 1800000,
|
||||||
|
"start_time": "1800.000000",
|
||||||
|
"end": 2100000,
|
||||||
|
"end_time": "2100.000000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let chapters = parse_chapters_json(json);
|
||||||
|
assert_eq!(chapters.len(), 3);
|
||||||
|
|
||||||
|
assert_eq!(chapters[0].title(), Some("Opening"));
|
||||||
|
assert!((chapters[0].start_secs() - 0.0).abs() < f64::EPSILON);
|
||||||
|
assert!((chapters[0].end_secs() - 300.0).abs() < f64::EPSILON);
|
||||||
|
|
||||||
|
assert_eq!(chapters[1].title(), Some("Main Feature"));
|
||||||
|
assert!((chapters[1].start_secs() - 300.0).abs() < f64::EPSILON);
|
||||||
|
assert!((chapters[1].end_secs() - 1800.0).abs() < f64::EPSILON);
|
||||||
|
|
||||||
|
assert_eq!(chapters[2].title(), None);
|
||||||
|
assert!((chapters[2].start_secs() - 1800.0).abs() < f64::EPSILON);
|
||||||
|
assert!((chapters[2].end_secs() - 2100.0).abs() < f64::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ffprobe_json_no_chapters() {
|
||||||
|
let json = r#"{ "chapters": [] }"#;
|
||||||
|
let chapters = parse_chapters_json(json);
|
||||||
|
assert!(chapters.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ffprobe_json_missing_chapters_key() {
|
||||||
|
let json = r#"{}"#;
|
||||||
|
let chapters = parse_chapters_json(json);
|
||||||
|
assert!(chapters.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ffprobe_json_invalid() {
|
||||||
|
let chapters = parse_chapters_json("not json");
|
||||||
|
assert!(chapters.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_items_skip_probe() {
|
||||||
|
assert!(!should_probe_chapters(&ContentType::Episode, 1800));
|
||||||
|
assert!(!should_probe_chapters(&ContentType::Short, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn movie_always_probed() {
|
||||||
|
assert!(should_probe_chapters(&ContentType::Movie, 600));
|
||||||
|
assert!(should_probe_chapters(&ContentType::Movie, 7200));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_episode_probed() {
|
||||||
|
assert!(should_probe_chapters(&ContentType::Episode, 3600));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn episode_under_threshold_skipped() {
|
||||||
|
assert!(!should_probe_chapters(&ContentType::Episode, 2700));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
pub mod ffprobe;
|
||||||
|
pub mod role_detector;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use domain::{DomainError, RotationPolicy, ScheduleConfig, ScheduleConfigCompat};
|
use domain::{DomainError, RotationPolicy, ScheduleConfig, ScheduleConfigCompat};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
|
|||||||
158
crates/adapters/adapter-common/src/role_detector.rs
Normal file
158
crates/adapters/adapter-common/src/role_detector.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
use domain::{MediaItem, MediaRole};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RoleDetectionConfig {
|
||||||
|
pub interstitial_collection_patterns: Vec<String>,
|
||||||
|
pub interstitial_tag_patterns: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sqlx::SqlitePool;
|
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::{
|
use domain::{
|
||||||
ports::library::{LibraryCommand, LibraryQuery},
|
ports::library::{LibraryCommand, LibraryQuery},
|
||||||
ContentType, DomainError, DomainResult, LibraryCollection,
|
ContentType, DomainError, DomainResult, LibraryCollection,
|
||||||
@@ -40,10 +40,15 @@ struct LibraryItemRow {
|
|||||||
thumbnail_url: Option<String>,
|
thumbnail_url: Option<String>,
|
||||||
synced_at: String,
|
synced_at: String,
|
||||||
chapters: Option<String>,
|
chapters: Option<String>,
|
||||||
|
role: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LibraryItemRow {
|
impl LibraryItemRow {
|
||||||
fn into_media_item(self) -> MediaItem {
|
fn into_media_item(self) -> MediaItem {
|
||||||
|
let role: MediaRole = self
|
||||||
|
.role
|
||||||
|
.map(parse_enum_or_default)
|
||||||
|
.unwrap_or_default();
|
||||||
MediaItem::from_persistence(DomainMediaItemRow {
|
MediaItem::from_persistence(DomainMediaItemRow {
|
||||||
id: domain::MediaItemId::new(&self.id),
|
id: domain::MediaItemId::new(&self.id),
|
||||||
provider_id: self.provider_id,
|
provider_id: self.provider_id,
|
||||||
@@ -63,7 +68,7 @@ impl LibraryItemRow {
|
|||||||
collection_type: self.collection_type,
|
collection_type: self.collection_type,
|
||||||
thumbnail_url: self.thumbnail_url,
|
thumbnail_url: self.thumbnail_url,
|
||||||
synced_at: Some(self.synced_at),
|
synced_at: Some(self.synced_at),
|
||||||
role: MediaRole::default(),
|
role,
|
||||||
chapters: self
|
chapters: self
|
||||||
.chapters
|
.chapters
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -116,12 +121,14 @@ impl LibraryCommand for SqliteLibraryRepository {
|
|||||||
Some(serde_json::to_string(item.chapters()).unwrap_or_default())
|
Some(serde_json::to_string(item.chapters()).unwrap_or_default())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let role_str = serialize_enum_as_string(item.role(), "program");
|
||||||
|
|
||||||
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, chapters)
|
collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters, role)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
)
|
)
|
||||||
.bind(item.id().value())
|
.bind(item.id().value())
|
||||||
.bind(item.provider_id())
|
.bind(item.provider_id())
|
||||||
@@ -141,6 +148,7 @@ impl LibraryCommand for SqliteLibraryRepository {
|
|||||||
.bind(item.thumbnail_url())
|
.bind(item.thumbnail_url())
|
||||||
.bind(item.synced_at().unwrap_or(""))
|
.bind(item.synced_at().unwrap_or(""))
|
||||||
.bind(&chapters_json)
|
.bind(&chapters_json)
|
||||||
|
.bind(&role_str)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
@@ -151,6 +159,22 @@ impl LibraryCommand for SqliteLibraryRepository {
|
|||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
.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<()> {
|
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> {
|
||||||
sqlx::query("DELETE FROM library_items WHERE provider_id = ?")
|
sqlx::query("DELETE FROM library_items WHERE provider_id = ?")
|
||||||
.bind(provider_id)
|
.bind(provider_id)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
|
|||||||
pub use iptv::IptvParams;
|
pub use iptv::IptvParams;
|
||||||
pub use library::{
|
pub use library::{
|
||||||
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam,
|
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam,
|
||||||
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
|
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry, UpdateRoleRequest,
|
||||||
};
|
};
|
||||||
pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
|
pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
|
||||||
pub use schedule::{
|
pub use schedule::{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub struct LibraryItemResponse {
|
|||||||
pub collection_type: Option<String>,
|
pub collection_type: Option<String>,
|
||||||
pub thumbnail_url: Option<String>,
|
pub thumbnail_url: Option<String>,
|
||||||
pub synced_at: Option<String>,
|
pub synced_at: Option<String>,
|
||||||
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<domain::MediaItem> for LibraryItemResponse {
|
impl From<domain::MediaItem> for LibraryItemResponse {
|
||||||
@@ -44,6 +45,7 @@ impl From<domain::MediaItem> for LibraryItemResponse {
|
|||||||
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().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<String>,
|
pub content_type: Option<String>,
|
||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateRoleRequest {
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|||||||
@@ -226,6 +226,14 @@ impl MediaItem {
|
|||||||
pub fn chapters(&self) -> &[Chapter] {
|
pub fn chapters(&self) -> &[Chapter] {
|
||||||
&self.chapters
|
&self.chapters
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_chapters(&mut self, chapters: Vec<Chapter>) {
|
||||||
|
self.chapters = chapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_role(&mut self, role: MediaRole) {
|
||||||
|
self.role = role;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::models::{
|
|||||||
LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult, MediaItem,
|
LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult, MediaItem,
|
||||||
SeasonSummary, ShowSummary,
|
SeasonSummary, ShowSummary,
|
||||||
};
|
};
|
||||||
use crate::value_objects::{ContentType, LibrarySearchFilter};
|
use crate::value_objects::{ContentType, LibrarySearchFilter, MediaRole};
|
||||||
|
|
||||||
use super::media::IMediaProvider;
|
use super::media::IMediaProvider;
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ pub trait LibraryCommand: Send + Sync {
|
|||||||
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64>;
|
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64>;
|
||||||
|
|
||||||
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> 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]
|
#[async_trait]
|
||||||
|
|||||||
@@ -418,6 +418,18 @@ impl LibraryCommand for InMemoryLibraryRepository {
|
|||||||
Ok(id)
|
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<()> {
|
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()> {
|
||||||
let mut logs = self.sync_logs.lock().unwrap();
|
let mut logs = self.sync_logs.lock().unwrap();
|
||||||
if let Some(entry) = logs.iter_mut().find(|e| e.id() == log_id) {
|
if let Some(entry) = logs.iter_mut().find(|e| e.id() == log_id) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ domain = { workspace = true }
|
|||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
api-types = { workspace = true }
|
api-types = { workspace = true }
|
||||||
infra-wiring = { workspace = true }
|
infra-wiring = { workspace = true }
|
||||||
|
adapter-common = { workspace = true }
|
||||||
adapter-auth = { workspace = true }
|
adapter-auth = { workspace = true }
|
||||||
adapter-event-publisher = { workspace = true }
|
adapter-event-publisher = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@@ -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 external_id = item.id().value().to_string();
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let role = adapter_common::role_detector::detect_role(&item, role_config);
|
||||||
|
|
||||||
domain::MediaItem::from_persistence(domain::MediaItemRow {
|
domain::MediaItem::from_persistence(domain::MediaItemRow {
|
||||||
id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)),
|
id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)),
|
||||||
provider_id: provider_id.to_string(),
|
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(),
|
genres: item.genres().to_vec(),
|
||||||
tags: item.tags().to_vec(),
|
tags: item.tags().to_vec(),
|
||||||
collection_id: item.collection_id().map(|s| s.to_string()),
|
collection_id: item.collection_id().map(|s| s.to_string()),
|
||||||
collection_name: None,
|
collection_name: item.collection_name().map(|s| s.to_string()),
|
||||||
collection_type: None,
|
collection_type: item.collection_type().map(|s| s.to_string()),
|
||||||
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
|
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
|
||||||
synced_at: Some(now),
|
synced_at: Some(now),
|
||||||
role: domain::MediaRole::default(),
|
role,
|
||||||
chapters: item.chapters().to_vec(),
|
chapters: item.chapters().to_vec(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SimpleSyncAdapter {
|
struct SimpleSyncAdapter {
|
||||||
library_command: Arc<dyn domain::ports::LibraryCommand>,
|
library_command: Arc<dyn domain::ports::LibraryCommand>,
|
||||||
|
role_config: adapter_common::role_detector::RoleDetectionConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SimpleSyncAdapter {
|
impl SimpleSyncAdapter {
|
||||||
fn new(library_command: Arc<dyn domain::ports::LibraryCommand>) -> Self {
|
fn new(library_command: Arc<dyn domain::ports::LibraryCommand>) -> 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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
let library_items: Vec<domain::MediaItem> = items
|
let mut library_items: Vec<domain::MediaItem> = items
|
||||||
.into_iter()
|
.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();
|
.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
|
if let Err(e) = self
|
||||||
.library_command
|
.library_command
|
||||||
.upsert_items(provider_id, library_items)
|
.upsert_items(provider_id, library_items)
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ use axum::extract::{Path, Query, State};
|
|||||||
use api_types::{
|
use api_types::{
|
||||||
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, PaginatedResponse,
|
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, PaginatedResponse,
|
||||||
ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
|
ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
|
||||||
|
UpdateRoleRequest,
|
||||||
};
|
};
|
||||||
use application::library::{SearchItemsQuery, TriggerSyncCommand};
|
use application::library::{SearchItemsQuery, TriggerSyncCommand};
|
||||||
use domain::DomainError;
|
use domain::{DomainError, MediaRole};
|
||||||
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::extractors::{AdminUser, CurrentUser};
|
use crate::extractors::{AdminUser, CurrentUser};
|
||||||
@@ -131,3 +132,32 @@ pub async fn trigger_sync(
|
|||||||
application::library::sync::execute(&state.library_command_deps, cmd).await?;
|
application::library::sync::execute(&state.library_command_deps, cmd).await?;
|
||||||
Ok(axum::http::StatusCode::ACCEPTED)
|
Ok(axum::http::StatusCode::ACCEPTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_role(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AdminUser(_user): AdminUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<UpdateRoleRequest>,
|
||||||
|
) -> Result<Json<LibraryItemResponse>, 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)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ fn library_router() -> Router<AppState> {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/items", get(handlers::library::search_items))
|
.route("/items", get(handlers::library::search_items))
|
||||||
.route("/items/{id}", get(handlers::library::get_item))
|
.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("/collections", get(handlers::library::list_collections))
|
||||||
.route("/shows", get(handlers::library::list_shows))
|
.route("/shows", get(handlers::library::list_shows))
|
||||||
.route("/seasons", get(handlers::library::list_seasons))
|
.route("/seasons", get(handlers::library::list_seasons))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ local-files = ["dep:adapter-local-files"]
|
|||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
infra-wiring = { workspace = true }
|
infra-wiring = { workspace = true }
|
||||||
|
adapter-common = { workspace = true }
|
||||||
adapter-sqlite = { workspace = true, optional = true }
|
adapter-sqlite = { workspace = true, optional = true }
|
||||||
adapter-auth = { workspace = true }
|
adapter-auth = { workspace = true }
|
||||||
adapter-jellyfin = { workspace = true, optional = true }
|
adapter-jellyfin = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -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 external_id = item.id().value().to_string();
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let role = adapter_common::role_detector::detect_role(&item, role_config);
|
||||||
|
|
||||||
domain::MediaItem::from_persistence(domain::MediaItemRow {
|
domain::MediaItem::from_persistence(domain::MediaItemRow {
|
||||||
id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)),
|
id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)),
|
||||||
provider_id: provider_id.to_string(),
|
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(),
|
genres: item.genres().to_vec(),
|
||||||
tags: item.tags().to_vec(),
|
tags: item.tags().to_vec(),
|
||||||
collection_id: item.collection_id().map(|s| s.to_string()),
|
collection_id: item.collection_id().map(|s| s.to_string()),
|
||||||
collection_name: None,
|
collection_name: item.collection_name().map(|s| s.to_string()),
|
||||||
collection_type: None,
|
collection_type: item.collection_type().map(|s| s.to_string()),
|
||||||
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
|
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
|
||||||
synced_at: Some(now),
|
synced_at: Some(now),
|
||||||
role: domain::MediaRole::default(),
|
role,
|
||||||
chapters: item.chapters().to_vec(),
|
chapters: item.chapters().to_vec(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SimpleSyncAdapter {
|
struct SimpleSyncAdapter {
|
||||||
library_command: Arc<dyn domain::ports::LibraryCommand>,
|
library_command: Arc<dyn domain::ports::LibraryCommand>,
|
||||||
|
role_config: adapter_common::role_detector::RoleDetectionConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SimpleSyncAdapter {
|
impl SimpleSyncAdapter {
|
||||||
fn new(library_command: Arc<dyn domain::ports::LibraryCommand>) -> Self {
|
fn new(library_command: Arc<dyn domain::ports::LibraryCommand>) -> 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: &dyn IMediaProvider,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> domain::LibrarySyncResult {
|
) -> domain::LibrarySyncResult {
|
||||||
|
use adapter_common::ffprobe;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -426,11 +437,20 @@ impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
let library_items: Vec<domain::MediaItem> = items
|
let mut library_items: Vec<domain::MediaItem> = items
|
||||||
.into_iter()
|
.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();
|
.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
|
if let Err(e) = self
|
||||||
.library_command
|
.library_command
|
||||||
.upsert_items(provider_id, library_items)
|
.upsert_items(provider_id, library_items)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE library_items ADD COLUMN role TEXT NOT NULL DEFAULT 'program';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE library_items ADD COLUMN role TEXT NOT NULL DEFAULT 'program';
|
||||||
Reference in New Issue
Block a user