add domain concepts: FillStrategy variants, Chapter, InterstitialRule, MidRollRule, gap_filler

- FillStrategy: +Alternating (round-robin series), +Weighted (fresh-first), +Marathon (always ep1, loops)
- Chapter value object on MediaItem (JSON column in library_items)
- InterstitialRule + MidRollRule on ProgrammingBlock (data model only)
- gap_filler: Option<MediaFilter> on Channel (persisted as JSON column)
- migration: 20260712000002_add_chapters_and_gap_filler.sql
This commit is contained in:
2026-07-12 07:41:57 +02:00
parent 39f5f99bfd
commit 60524c56f7
16 changed files with 547 additions and 10 deletions

View File

@@ -36,5 +36,6 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
collection_type: None,
synced_at: None,
role: MediaRole::default(),
chapters: Vec::new(),
}))
}

View File

@@ -47,6 +47,7 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
collection_type: None,
synced_at: None,
role: MediaRole::default(),
chapters: Vec::new(),
})
}

View File

@@ -10,7 +10,7 @@ use adapter_common::{
use domain::{
ports::channel::{ChannelCommand, ChannelQuery},
AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow,
DomainError, DomainResult, LogoPosition, ScheduleConfig, SnapshotId, UserId,
DomainError, DomainResult, LogoPosition, MediaFilter, ScheduleConfig, SnapshotId, UserId,
};
pub struct SqliteChannelRepository {
@@ -23,7 +23,7 @@ impl SqliteChannelRepository {
}
}
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy AS rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, created_at, updated_at";
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy AS rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, gap_filler, created_at, updated_at";
#[derive(Debug, sqlx::FromRow)]
struct ChannelRow {
@@ -43,6 +43,7 @@ struct ChannelRow {
webhook_poll_interval_secs: i64,
webhook_body_template: Option<String>,
webhook_headers: Option<String>,
gap_filler: Option<String>,
created_at: String,
updated_at: String,
}
@@ -56,6 +57,11 @@ impl ChannelRow {
let access_mode: AccessMode = parse_enum_or_default(self.access_mode);
let logo_position: LogoPosition = parse_enum_or_default(self.logo_position);
let gap_filler: Option<MediaFilter> = self
.gap_filler
.as_deref()
.and_then(|s| serde_json::from_str(s).ok());
Ok(Channel::from_persistence(DomainChannelRow {
id,
owner_id,
@@ -73,6 +79,7 @@ impl ChannelRow {
webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32,
webhook_body_template: self.webhook_body_template,
webhook_headers: self.webhook_headers,
gap_filler,
created_at: parse_dt(&self.created_at)?,
updated_at: parse_dt(&self.updated_at)?,
}))
@@ -112,14 +119,18 @@ impl ChannelCommand for SqliteChannelRepository {
let access_mode = serialize_enum_as_string(channel.access_mode(), "public");
let logo_position = serialize_enum_as_string(channel.logo_position(), "top_right");
let gap_filler_json = channel
.gap_filler()
.map(|f| serde_json::to_string(f).unwrap_or_default());
sqlx::query(
r#"
INSERT INTO channels
(id, owner_id, name, description, timezone, schedule_config, recycle_policy,
auto_schedule, access_mode, logo, logo_position,
logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template,
webhook_headers, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
webhook_headers, gap_filler, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
@@ -135,6 +146,7 @@ impl ChannelCommand for SqliteChannelRepository {
webhook_poll_interval_secs = excluded.webhook_poll_interval_secs,
webhook_body_template = excluded.webhook_body_template,
webhook_headers = excluded.webhook_headers,
gap_filler = excluded.gap_filler,
updated_at = excluded.updated_at
"#,
)
@@ -154,6 +166,7 @@ impl ChannelCommand for SqliteChannelRepository {
.bind(channel.webhook_poll_interval_secs() as i64)
.bind(channel.webhook_body_template())
.bind(channel.webhook_headers())
.bind(&gap_filler_json)
.bind(channel.created_at().to_rfc3339())
.bind(channel.updated_at().to_rfc3339())
.execute(&self.pool)

View File

@@ -39,6 +39,7 @@ struct LibraryItemRow {
collection_type: Option<String>,
thumbnail_url: Option<String>,
synced_at: String,
chapters: Option<String>,
}
impl LibraryItemRow {
@@ -63,6 +64,11 @@ impl LibraryItemRow {
thumbnail_url: self.thumbnail_url,
synced_at: Some(self.synced_at),
role: MediaRole::default(),
chapters: self
.chapters
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default(),
})
}
}
@@ -104,12 +110,18 @@ impl LibraryCommand for SqliteLibraryRepository {
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
for item in items {
let chapters_json = if item.chapters().is_empty() {
None
} else {
Some(serde_json::to_string(item.chapters()).unwrap_or_default())
};
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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
)
.bind(item.id().value())
.bind(item.provider_id())
@@ -128,6 +140,7 @@ impl LibraryCommand for SqliteLibraryRepository {
.bind(item.collection_type())
.bind(item.thumbnail_url())
.bind(item.synced_at().unwrap_or(""))
.bind(&chapters_json)
.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;