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