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

@@ -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<dyn domain::ports::LibraryCommand>,
role_config: adapter_common::role_detector::RoleDetectionConfig,
}
impl SimpleSyncAdapter {
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_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<domain::MediaItem> = items
let mut library_items: Vec<domain::MediaItem> = 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)