merge feat/library-sync: chapter extraction + MediaRole auto-detection (#5, #7)

This commit is contained in:
2026-07-12 14:14:33 +02:00
19 changed files with 496 additions and 21 deletions

3
Cargo.lock generated
View File

@@ -25,6 +25,7 @@ dependencies = [
"serde",
"serde_json",
"sqlx",
"tokio",
"tracing",
"uuid",
]
@@ -1795,6 +1796,7 @@ name = "presentation"
version = "0.1.0"
dependencies = [
"adapter-auth",
"adapter-common",
"adapter-event-publisher",
"adapter-jellyfin",
"adapter-local-files",
@@ -3372,6 +3374,7 @@ name = "worker"
version = "0.1.0"
dependencies = [
"adapter-auth",
"adapter-common",
"adapter-event-publisher",
"adapter-jellyfin",
"adapter-local-files",

View File

@@ -11,3 +11,4 @@ uuid = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,180 @@
use domain::{Chapter, ContentType, SourceUri};
use serde::Deserialize;
const CHAPTER_PROBE_MIN_DURATION_SECS: u32 = 2700;
#[derive(Deserialize)]
struct FfprobeOutput {
#[serde(default)]
chapters: Vec<FfprobeChapter>,
}
#[derive(Deserialize)]
struct FfprobeChapter {
#[serde(default)]
start_time: String,
#[serde(default)]
end_time: String,
#[serde(default)]
tags: Option<FfprobeChapterTags>,
}
#[derive(Deserialize)]
struct FfprobeChapterTags {
title: Option<String>,
}
pub fn parse_chapters_json(json: &str) -> Vec<Chapter> {
let output: FfprobeOutput = match serde_json::from_str(json) {
Ok(o) => o,
Err(_) => return Vec::new(),
};
output
.chapters
.into_iter()
.map(|c| {
let title = c.tags.and_then(|t| t.title);
let start_secs = c.start_time.parse::<f64>().unwrap_or(0.0);
let end_secs = c.end_time.parse::<f64>().unwrap_or(0.0);
Chapter::new(title, start_secs, end_secs)
})
.collect()
}
pub fn should_probe_chapters(content_type: &ContentType, duration_secs: u32) -> bool {
matches!(content_type, ContentType::Movie) || duration_secs > CHAPTER_PROBE_MIN_DURATION_SECS
}
pub async fn extract_chapters(source_uri: &SourceUri) -> Vec<Chapter> {
let path = match source_uri {
SourceUri::FilePath { path } => path.clone(),
SourceUri::NetworkUrl { url } => url.clone(),
};
let result = tokio::process::Command::new("ffprobe")
.args([
"-v",
"quiet",
"-print_format",
"json",
"-show_chapters",
&path,
])
.output()
.await;
match result {
Ok(output) if output.status.success() => {
let json = String::from_utf8_lossy(&output.stdout);
parse_chapters_json(&json)
}
Ok(output) => {
tracing::warn!(
path = %path,
stderr = %String::from_utf8_lossy(&output.stderr),
"ffprobe exited with non-zero status"
);
Vec::new()
}
Err(e) => {
tracing::warn!(error = %e, "ffprobe not available or failed to execute");
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ffprobe_json_with_chapters() {
let json = r#"{
"chapters": [
{
"id": 0,
"time_base": "1/1000",
"start": 0,
"start_time": "0.000000",
"end": 300000,
"end_time": "300.000000",
"tags": { "title": "Opening" }
},
{
"id": 1,
"time_base": "1/1000",
"start": 300000,
"start_time": "300.000000",
"end": 1800000,
"end_time": "1800.000000",
"tags": { "title": "Main Feature" }
},
{
"id": 2,
"time_base": "1/1000",
"start": 1800000,
"start_time": "1800.000000",
"end": 2100000,
"end_time": "2100.000000"
}
]
}"#;
let chapters = parse_chapters_json(json);
assert_eq!(chapters.len(), 3);
assert_eq!(chapters[0].title(), Some("Opening"));
assert!((chapters[0].start_secs() - 0.0).abs() < f64::EPSILON);
assert!((chapters[0].end_secs() - 300.0).abs() < f64::EPSILON);
assert_eq!(chapters[1].title(), Some("Main Feature"));
assert!((chapters[1].start_secs() - 300.0).abs() < f64::EPSILON);
assert!((chapters[1].end_secs() - 1800.0).abs() < f64::EPSILON);
assert_eq!(chapters[2].title(), None);
assert!((chapters[2].start_secs() - 1800.0).abs() < f64::EPSILON);
assert!((chapters[2].end_secs() - 2100.0).abs() < f64::EPSILON);
}
#[test]
fn parse_ffprobe_json_no_chapters() {
let json = r#"{ "chapters": [] }"#;
let chapters = parse_chapters_json(json);
assert!(chapters.is_empty());
}
#[test]
fn parse_ffprobe_json_missing_chapters_key() {
let json = r#"{}"#;
let chapters = parse_chapters_json(json);
assert!(chapters.is_empty());
}
#[test]
fn parse_ffprobe_json_invalid() {
let chapters = parse_chapters_json("not json");
assert!(chapters.is_empty());
}
#[test]
fn short_items_skip_probe() {
assert!(!should_probe_chapters(&ContentType::Episode, 1800));
assert!(!should_probe_chapters(&ContentType::Short, 300));
}
#[test]
fn movie_always_probed() {
assert!(should_probe_chapters(&ContentType::Movie, 600));
assert!(should_probe_chapters(&ContentType::Movie, 7200));
}
#[test]
fn long_episode_probed() {
assert!(should_probe_chapters(&ContentType::Episode, 3600));
}
#[test]
fn episode_under_threshold_skipped() {
assert!(!should_probe_chapters(&ContentType::Episode, 2700));
}
}

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)

View File

@@ -20,7 +20,7 @@ pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
pub use iptv::IptvParams;
pub use library::{
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam,
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry, UpdateRoleRequest,
};
pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
pub use schedule::{

View File

@@ -22,6 +22,7 @@ pub struct LibraryItemResponse {
pub collection_type: Option<String>,
pub thumbnail_url: Option<String>,
pub synced_at: Option<String>,
pub role: String,
}
impl From<domain::MediaItem> for LibraryItemResponse {
@@ -44,6 +45,7 @@ impl From<domain::MediaItem> for LibraryItemResponse {
collection_type: i.collection_type().map(|s| s.to_string()),
thumbnail_url: i.thumbnail_url().map(|s| s.to_string()),
synced_at: i.synced_at().map(|s| s.to_string()),
role: enum_to_string(i.role()),
}
}
}
@@ -166,3 +168,8 @@ pub struct GenresParams {
pub content_type: Option<String>,
pub provider: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateRoleRequest {
pub role: String,
}

View File

@@ -226,6 +226,14 @@ impl MediaItem {
pub fn chapters(&self) -> &[Chapter] {
&self.chapters
}
pub fn set_chapters(&mut self, chapters: Vec<Chapter>) {
self.chapters = chapters;
}
pub fn set_role(&mut self, role: MediaRole) {
self.role = role;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -5,7 +5,7 @@ use crate::models::{
LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult, MediaItem,
SeasonSummary, ShowSummary,
};
use crate::value_objects::{ContentType, LibrarySearchFilter};
use crate::value_objects::{ContentType, LibrarySearchFilter, MediaRole};
use super::media::IMediaProvider;
@@ -18,6 +18,8 @@ pub trait LibraryCommand: Send + Sync {
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64>;
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>;
async fn update_role(&self, item_id: &str, role: MediaRole) -> DomainResult<()>;
}
#[async_trait]

View File

@@ -418,6 +418,18 @@ impl LibraryCommand for InMemoryLibraryRepository {
Ok(id)
}
async fn update_role(&self, item_id: &str, role: crate::value_objects::MediaRole) -> DomainResult<()> {
let mut store = self.items.lock().unwrap();
if let Some(item) = store.get_mut(item_id) {
item.set_role(role);
Ok(())
} else {
Err(crate::errors::DomainError::NotFound(format!(
"Library item {item_id} not found"
)))
}
}
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()> {
let mut logs = self.sync_logs.lock().unwrap();
if let Some(entry) = logs.iter_mut().find(|e| e.id() == log_id) {

View File

@@ -19,6 +19,7 @@ domain = { workspace = true }
application = { workspace = true }
api-types = { workspace = true }
infra-wiring = { workspace = true }
adapter-common = { workspace = true }
adapter-auth = { workspace = true }
adapter-event-publisher = { workspace = true }

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

View File

@@ -17,6 +17,7 @@ local-files = ["dep:adapter-local-files"]
domain = { workspace = true }
application = { workspace = true }
infra-wiring = { workspace = true }
adapter-common = { workspace = true }
adapter-sqlite = { workspace = true, optional = true }
adapter-auth = { workspace = true }
adapter-jellyfin = { workspace = true, optional = true }

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)

View File

@@ -0,0 +1 @@
ALTER TABLE library_items ADD COLUMN role TEXT NOT NULL DEFAULT 'program';

View File

@@ -0,0 +1 @@
ALTER TABLE library_items ADD COLUMN role TEXT NOT NULL DEFAULT 'program';