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

@@ -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<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(),
}
}
}
@@ -520,11 +530,23 @@ 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 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)

View File

@@ -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<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)))
}

View File

@@ -80,6 +80,7 @@ fn library_router() -> Router<AppState> {
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))