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:
@@ -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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user