MediaRole auto-detection + manual role API + sync wiring (#7)

role_detector: classify items as Interstitial by collection name/tag patterns.
Wire chapter extraction + role detection into sync adapter (worker + presentation).
SQLite: persist/read role column, migration.
PUT /library/items/{id}/role endpoint for manual override.
This commit is contained in:
2026-07-12 14:00:28 +02:00
parent 607d311375
commit f21d70c559
15 changed files with 304 additions and 21 deletions

View File

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

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

View File

@@ -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<String>,
synced_at: String,
chapters: Option<String>,
role: Option<String>,
}
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)