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