extract background tasks into worker binary

Move auto_scheduler, library_sync, broadcast_poller, webhook_consumer
from presentation/background/ into crates/worker/src/jobs/.
Presentation is now a pure HTTP server.
This commit is contained in:
2026-07-12 07:28:17 +02:00
parent 826e824b58
commit abcf69ce7e
13 changed files with 521 additions and 48 deletions

35
crates/worker/Cargo.toml Normal file
View File

@@ -0,0 +1,35 @@
[package]
name = "worker"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "k-tv-worker"
path = "src/main.rs"
[features]
default = ["sqlite", "jellyfin"]
sqlite = ["dep:adapter-sqlite", "infra-wiring/sqlite"]
jellyfin = ["dep:adapter-jellyfin"]
local-files = ["dep:adapter-local-files"]
[dependencies]
domain = { workspace = true }
application = { workspace = true }
infra-wiring = { workspace = true }
adapter-sqlite = { workspace = true, optional = true }
adapter-auth = { workspace = true }
adapter-jellyfin = { workspace = true, optional = true }
adapter-local-files = { workspace = true, optional = true }
adapter-event-publisher = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
reqwest = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
handlebars = "6"
dotenvy = "0.15"
anyhow = "1"

View File

@@ -0,0 +1,79 @@
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use application::schedule::{GenerateScheduleCommand, ScheduleDeps};
const TICK_INTERVAL_SECS: u64 = 3600;
const EXPIRY_THRESHOLD_HOURS: i64 = 24;
pub async fn run(deps: Arc<ScheduleDeps>) {
loop {
tokio::time::sleep(Duration::from_secs(TICK_INTERVAL_SECS)).await;
tick(&deps).await;
}
}
async fn tick(deps: &ScheduleDeps) {
let channels = match deps.channel_query.find_all().await {
Ok(c) => c,
Err(e) => {
tracing::warn!("auto-scheduler: failed to fetch channels: {}", e);
return;
}
};
let now = Utc::now();
for channel in channels {
if !channel.auto_schedule() {
continue;
}
let latest = match deps.schedule_query.find_latest(channel.id()).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(
"auto-scheduler: failed to fetch latest schedule for channel {}: {}",
channel.id().value(),
e
);
continue;
}
};
let should_generate = match &latest {
Some(s) => {
let remaining = s.valid_until() - now;
remaining < chrono::Duration::hours(EXPIRY_THRESHOLD_HOURS)
}
None => true,
};
if !should_generate {
continue;
}
let cmd = GenerateScheduleCommand {
channel_id: channel.id().value(),
};
match application::schedule::generate::execute(deps, cmd).await {
Ok(schedule) => {
tracing::info!(
"auto-scheduler: generated schedule for channel {} (gen {})",
channel.id().value(),
schedule.generation(),
);
}
Err(e) => {
tracing::warn!(
"auto-scheduler: failed to generate schedule for channel {}: {}",
channel.id().value(),
e
);
}
}
}
}

View File

@@ -0,0 +1,114 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use chrono::Utc;
use uuid::Uuid;
use domain::events::DomainEvent;
use domain::ports::events::EventPublisher;
use domain::value_objects::{ChannelId, SlotId};
use application::schedule::ScheduleDeps;
const POLL_INTERVAL_SECS: u64 = 1;
struct ChannelPollState {
last_slot_id: Option<Uuid>,
last_checked: Instant,
}
pub async fn run(deps: Arc<ScheduleDeps>, event_publisher: Arc<dyn EventPublisher>) {
let mut state: HashMap<Uuid, ChannelPollState> = HashMap::new();
loop {
tokio::time::sleep(Duration::from_secs(POLL_INTERVAL_SECS)).await;
tick(&deps, &event_publisher, &mut state).await;
}
}
async fn tick(
deps: &ScheduleDeps,
event_publisher: &Arc<dyn EventPublisher>,
state: &mut HashMap<Uuid, ChannelPollState>,
) {
let channels = match deps.channel_query.find_all().await {
Ok(c) => c,
Err(e) => {
tracing::error!("broadcast poller: failed to load channels: {}", e);
return;
}
};
let live_ids: std::collections::HashSet<Uuid> =
channels.iter().map(|c| c.id().value()).collect();
state.retain(|id, _| live_ids.contains(id));
let now = Utc::now();
for channel in channels {
if channel.webhook_url().is_none() {
state.remove(&channel.id().value());
continue;
}
let poll_interval = Duration::from_secs(channel.webhook_poll_interval_secs() as u64);
let channel_uuid = channel.id().value();
let entry = state.entry(channel_uuid).or_insert(ChannelPollState {
last_slot_id: None,
last_checked: Instant::now() - poll_interval,
});
if entry.last_checked.elapsed() < poll_interval {
continue;
}
entry.last_checked = Instant::now();
let current_slot_id = match deps
.schedule_query
.find_active(channel.id(), now)
.await
{
Ok(Some(schedule)) => schedule
.slots()
.iter()
.find(|s| s.start_at() <= now && now < s.end_at())
.map(|s| s.id().value()),
Ok(None) => None,
Err(e) => {
tracing::error!(
"broadcast poller: error checking schedule for channel {}: {}",
channel_uuid,
e
);
continue;
}
};
if current_slot_id == entry.last_slot_id {
continue;
}
match &current_slot_id {
Some(slot_id) => {
let _ = event_publisher
.publish(DomainEvent::BroadcastTransition {
channel_id: ChannelId::from(channel_uuid),
slot_id: SlotId::from(*slot_id),
})
.await;
}
None => {
let _ = event_publisher
.publish(DomainEvent::NoSignal {
channel_id: ChannelId::from(channel_uuid),
})
.await;
}
}
entry.last_slot_id = current_slot_id;
}
}

View File

@@ -0,0 +1,116 @@
use std::sync::Arc;
use std::time::Duration;
use domain::ports::{AppSettingsRepository, IProviderRegistry, LibrarySyncAdapter};
use tokio::sync::watch;
const STARTUP_DELAY_SECS: u64 = 10;
const DEFAULT_INTERVAL_HOURS: u64 = 6;
const SYNC_INTERVAL_SETTING_KEY: &str = "library_sync_interval_hours";
pub async fn run(
sync_adapter: Arc<dyn LibrarySyncAdapter>,
provider_registry: Arc<dyn IProviderRegistry>,
settings_repo: Arc<dyn AppSettingsRepository>,
mut trigger_rx: watch::Receiver<()>,
) {
tokio::time::sleep(Duration::from_secs(STARTUP_DELAY_SECS)).await;
loop {
do_sync(&sync_adapter, &provider_registry).await;
let interval_hours = load_interval_hours(&settings_repo).await;
let sleep = tokio::time::sleep(Duration::from_secs(interval_hours * 3600));
tokio::select! {
_ = sleep => {}
_ = trigger_rx.changed() => {
tracing::info!("library-sync: triggered manually");
}
}
}
}
async fn load_interval_hours(repo: &Arc<dyn AppSettingsRepository>) -> u64 {
repo.get(SYNC_INTERVAL_SETTING_KEY)
.await
.ok()
.flatten()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_INTERVAL_HOURS)
}
async fn do_sync(
sync_adapter: &Arc<dyn LibrarySyncAdapter>,
registry: &Arc<dyn IProviderRegistry>,
) {
let provider_ids = registry.provider_ids();
for provider_id in provider_ids {
tracing::info!("library-sync: syncing provider '{}'", provider_id);
let wrapper = RegistryProviderAdapter {
registry: registry.clone(),
provider_id: provider_id.clone(),
};
let result = sync_adapter.sync_provider(&wrapper, &provider_id).await;
if let Some(err) = result.error() {
tracing::warn!("library-sync: provider '{}' failed: {}", provider_id, err);
} else {
tracing::info!(
"library-sync: provider '{}' done — {} items in {}ms",
provider_id,
result.items_found(),
result.duration_ms()
);
}
}
}
struct RegistryProviderAdapter {
registry: Arc<dyn IProviderRegistry>,
provider_id: String,
}
#[async_trait::async_trait]
impl domain::ports::IMediaProvider for RegistryProviderAdapter {
fn capabilities(&self) -> domain::ports::ProviderCapabilities {
self.registry
.capabilities(&self.provider_id)
.unwrap_or(domain::ports::ProviderCapabilities {
collections: false,
series: false,
genres: false,
tags: false,
decade: false,
search: false,
streaming_protocol: domain::ports::StreamingProtocol::DirectFile,
rescan: false,
transcode: false,
})
}
async fn fetch_items(
&self,
filter: &domain::MediaFilter,
) -> domain::DomainResult<Vec<domain::MediaItem>> {
self.registry.fetch_items(&self.provider_id, filter).await
}
async fn fetch_by_id(
&self,
item_id: &domain::MediaItemId,
) -> domain::DomainResult<Option<domain::MediaItem>> {
self.registry.fetch_by_id(item_id).await
}
async fn get_stream_url(
&self,
item_id: &domain::MediaItemId,
quality: &domain::ports::StreamQuality,
) -> domain::DomainResult<String> {
self.registry.get_stream_url(item_id, quality).await
}
}

View File

@@ -0,0 +1,4 @@
pub mod auto_scheduler;
pub mod broadcast_poller;
pub mod library_sync;
pub mod webhook_consumer;

View File

@@ -0,0 +1,207 @@
use std::sync::Arc;
use chrono::Utc;
use handlebars::Handlebars;
use serde_json::{Value, json};
use uuid::Uuid;
use domain::events::DomainEvent;
use domain::ports::{ChannelQuery, EventConsumer};
const DEFAULT_CONTENT_TYPE: &str = "application/json";
const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
pub async fn run(
consumer: Arc<dyn EventConsumer>,
channel_query: Arc<dyn ChannelQuery>,
client: reqwest::Client,
) {
loop {
match consumer.poll_next().await {
Ok(Some(envelope)) => {
let event_id = envelope.id();
let event = envelope.event();
let channel_id = event_channel_id(event);
let payload = build_payload(event);
let channel_id_vo = domain::ChannelId::from(channel_id);
match channel_query.find_by_id(channel_id_vo).await {
Ok(Some(channel)) => {
if let Some(url) = channel.webhook_url() {
let url = url.to_string();
let client = client.clone();
let template = channel.webhook_body_template().map(|s| s.to_string());
let headers = channel.webhook_headers().map(|s| s.to_string());
tokio::spawn(async move {
post_webhook(
&client,
&url,
payload,
template.as_deref(),
headers.as_deref(),
)
.await;
});
}
}
Ok(None) => {}
Err(e) => {
tracing::warn!(
"webhook consumer: failed to look up channel {}: {}",
channel_id,
e
);
}
}
if let Err(e) = consumer.ack(event_id).await {
tracing::warn!("webhook consumer: ack failed for event {}: {}", event_id, e);
}
}
Ok(None) => {
tokio::time::sleep(POLL_INTERVAL).await;
}
Err(e) => {
tracing::warn!("webhook consumer: poll error: {}", e);
tokio::time::sleep(POLL_INTERVAL).await;
}
}
}
}
fn event_channel_id(event: &DomainEvent) -> Uuid {
match event {
DomainEvent::BroadcastTransition { channel_id, .. } => channel_id.value(),
DomainEvent::NoSignal { channel_id } => channel_id.value(),
DomainEvent::ScheduleGenerated { channel_id, .. } => channel_id.value(),
DomainEvent::ChannelCreated { channel_id } => channel_id.value(),
DomainEvent::ChannelUpdated { channel_id } => channel_id.value(),
DomainEvent::ChannelDeleted { channel_id } => channel_id.value(),
_ => Uuid::nil(),
}
}
fn build_payload(event: &DomainEvent) -> Value {
let now = Utc::now().to_rfc3339();
let channel_id = event_channel_id(event);
match event {
DomainEvent::BroadcastTransition { slot_id, .. } => {
json!({
"event": "broadcast_transition",
"timestamp": now,
"channel_id": channel_id,
"data": {
"slot_id": slot_id.value(),
}
})
}
DomainEvent::NoSignal { .. } => {
json!({
"event": "no_signal",
"timestamp": now,
"channel_id": channel_id,
"data": {}
})
}
DomainEvent::ScheduleGenerated { schedule_id, .. } => {
json!({
"event": "schedule_generated",
"timestamp": now,
"channel_id": channel_id,
"data": {
"schedule_id": schedule_id.value(),
}
})
}
DomainEvent::ChannelCreated { .. } => {
json!({
"event": "channel_created",
"timestamp": now,
"channel_id": channel_id,
"data": {}
})
}
DomainEvent::ChannelUpdated { .. } => {
json!({
"event": "channel_updated",
"timestamp": now,
"channel_id": channel_id,
"data": {}
})
}
DomainEvent::ChannelDeleted { .. } => {
json!({
"event": "channel_deleted",
"timestamp": now,
"channel_id": channel_id,
"data": {}
})
}
_ => {
json!({
"event": "unknown",
"timestamp": now,
"channel_id": channel_id,
"data": {}
})
}
}
}
async fn post_webhook(
client: &reqwest::Client,
url: &str,
payload: Value,
template: Option<&str>,
headers_json: Option<&str>,
) {
let body = if let Some(tmpl) = template {
let hbs = Handlebars::new();
match hbs.render_template(tmpl, &payload) {
Ok(rendered) => rendered,
Err(e) => {
tracing::warn!("webhook template render failed for {}: {}", url, e);
return;
}
}
} else {
match serde_json::to_string(&payload) {
Ok(s) => s,
Err(e) => {
tracing::warn!("webhook payload serialize failed: {}", e);
return;
}
}
};
let mut req = client.post(url).body(body);
let mut has_content_type = false;
if let Some(h) = headers_json {
if let Ok(map) = serde_json::from_str::<serde_json::Map<String, Value>>(h) {
for (k, v) in &map {
if k.eq_ignore_ascii_case("content-type") {
has_content_type = true;
}
if let Some(v_str) = v.as_str() {
req = req.header(k.as_str(), v_str);
}
}
}
}
if !has_content_type {
req = req.header("Content-Type", DEFAULT_CONTENT_TYPE);
}
match req.send().await {
Ok(resp) => {
if !resp.status().is_success() {
tracing::warn!("webhook POST to {} returned status {}", url, resp.status());
}
}
Err(e) => {
tracing::warn!("webhook POST to {} failed: {}", url, e);
}
}
}

459
crates/worker/src/main.rs Normal file
View File

@@ -0,0 +1,459 @@
use std::sync::Arc;
use application::schedule::ScheduleDeps;
use domain::ports::{IMediaProvider, IProviderRegistry, ProviderCapabilities, StreamingProtocol};
use domain::{DomainError, ScheduleEngineService};
use infra_wiring::{Config, ConfigSource, DbPool};
mod jobs;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let config = Config::from_env().map_err(|e| anyhow::anyhow!("Config error: {}", e))?;
tracing::info!("Starting k-tv worker");
let pool = DbPool::connect(&config.database_url).await?;
pool.run_migrations().await?;
let wire = wire_repositories(&pool)?;
let sqlite_pool = match &pool {
#[cfg(feature = "sqlite")]
DbPool::Sqlite(p) => p.clone(),
};
let event_publisher: Arc<dyn domain::ports::EventPublisher> =
Arc::new(adapter_event_publisher::SqliteEventPublisher::new(
sqlite_pool.clone(),
));
let event_consumer: Arc<dyn domain::ports::EventConsumer> =
Arc::new(adapter_event_publisher::SqliteEventConsumer::new(sqlite_pool));
let provider_registry = build_provider_registry(&config).await;
let library_sync: Arc<dyn domain::ports::LibrarySyncAdapter> =
build_library_sync(wire.library_command.clone());
let schedule_engine = Arc::new(ScheduleEngineService::new(
wire.library_query.clone(),
wire.channel_query.clone(),
wire.schedule_query.clone(),
wire.schedule_command.clone(),
));
let (_sync_tx, sync_rx) = tokio::sync::watch::channel(());
let schedule_deps = Arc::new(ScheduleDeps {
schedule_engine,
channel_query: wire.channel_query.clone(),
schedule_query: wire.schedule_query.clone(),
schedule_command: wire.schedule_command.clone(),
event_publisher: event_publisher.clone(),
provider_registry: provider_registry.clone(),
});
tokio::spawn(jobs::auto_scheduler::run(schedule_deps.clone()));
tokio::spawn(jobs::broadcast_poller::run(
schedule_deps.clone(),
event_publisher.clone(),
));
tokio::spawn(jobs::webhook_consumer::run(
event_consumer,
wire.channel_query.clone(),
reqwest::Client::new(),
));
tokio::spawn(jobs::library_sync::run(
library_sync,
provider_registry,
wire.settings.clone(),
sync_rx,
));
tracing::info!("All jobs spawned, waiting for shutdown signal");
tokio::signal::ctrl_c().await?;
tracing::info!("Shutting down worker");
Ok(())
}
struct WireOutput {
channel_query: Arc<dyn domain::ports::ChannelQuery>,
schedule_command: Arc<dyn domain::ports::ScheduleCommand>,
schedule_query: Arc<dyn domain::ports::ScheduleQuery>,
library_command: Arc<dyn domain::ports::LibraryCommand>,
library_query: Arc<dyn domain::ports::LibraryQuery>,
settings: Arc<dyn domain::ports::AppSettingsRepository>,
}
fn wire_repositories(pool: &DbPool) -> anyhow::Result<WireOutput> {
match pool {
#[cfg(feature = "sqlite")]
DbPool::Sqlite(sqlite_pool) => {
let w = adapter_sqlite::wire(sqlite_pool.clone());
Ok(WireOutput {
channel_query: w.channel_query,
schedule_command: w.schedule_command,
schedule_query: w.schedule_query,
library_command: w.library_command,
library_query: w.library_query,
settings: w.settings,
})
}
}
}
async fn build_provider_registry(config: &Config) -> Arc<dyn IProviderRegistry> {
let mut providers: Vec<(String, Arc<dyn IMediaProvider>)> = Vec::new();
match config.config_source {
ConfigSource::Env => {
#[cfg(feature = "jellyfin")]
if let (Some(url), Some(api_key), Some(user_id)) = (
&config.jellyfin_url,
&config.jellyfin_api_key,
&config.jellyfin_user_id,
) {
tracing::info!("Media provider: Jellyfin at {}", url);
providers.push((
"jellyfin".to_string(),
Arc::new(adapter_jellyfin::JellyfinMediaProvider::new(
adapter_jellyfin::JellyfinConfig {
base_url: url.clone(),
api_key: api_key.clone(),
user_id: user_id.clone(),
},
)),
));
}
}
ConfigSource::Db => {
tracing::info!("CONFIG_SOURCE=db: provider configs loaded from database at runtime");
}
}
if providers.is_empty() {
tracing::warn!("No media provider configured — using NoopMediaProvider");
providers.push(("noop".to_string(), Arc::new(NoopMediaProvider)));
}
Arc::new(SimpleProviderRegistry::new(providers))
}
fn build_library_sync(
library_command: Arc<dyn domain::ports::LibraryCommand>,
) -> Arc<dyn domain::ports::LibrarySyncAdapter> {
Arc::new(SimpleSyncAdapter::new(library_command))
}
struct NoopMediaProvider;
#[async_trait::async_trait]
impl IMediaProvider for NoopMediaProvider {
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
collections: false,
series: false,
genres: false,
tags: false,
decade: false,
search: false,
streaming_protocol: StreamingProtocol::DirectFile,
rescan: false,
transcode: false,
}
}
async fn fetch_items(
&self,
_: &domain::MediaFilter,
) -> domain::DomainResult<Vec<domain::MediaItem>> {
Err(DomainError::InfrastructureError(
"No media provider configured. Set JELLYFIN_BASE_URL or LOCAL_FILES_DIR.".into(),
))
}
async fn fetch_by_id(
&self,
_: &domain::MediaItemId,
) -> domain::DomainResult<Option<domain::MediaItem>> {
Err(DomainError::InfrastructureError(
"No media provider configured.".into(),
))
}
async fn get_stream_url(
&self,
_: &domain::MediaItemId,
_: &domain::ports::StreamQuality,
) -> domain::DomainResult<String> {
Err(DomainError::InfrastructureError(
"No media provider configured.".into(),
))
}
}
struct SimpleProviderRegistry {
providers: Vec<(String, Arc<dyn IMediaProvider>)>,
}
impl SimpleProviderRegistry {
fn new(providers: Vec<(String, Arc<dyn IMediaProvider>)>) -> Self {
Self { providers }
}
fn get(&self, id: &str) -> Option<&Arc<dyn IMediaProvider>> {
self.providers.iter().find(|(k, _)| k == id).map(|(_, v)| v)
}
fn primary(&self) -> Option<&Arc<dyn IMediaProvider>> {
self.providers.first().map(|(_, v)| v)
}
fn extract_provider_id(item_id: &str) -> Option<&str> {
item_id.find("::").map(|pos| &item_id[..pos])
}
}
#[async_trait::async_trait]
impl IProviderRegistry for SimpleProviderRegistry {
async fn fetch_items(
&self,
provider_id: &str,
filter: &domain::MediaFilter,
) -> domain::DomainResult<Vec<domain::MediaItem>> {
let id = if provider_id.is_empty() {
self.providers.first().map(|(k, _)| k.as_str()).unwrap_or("")
} else {
provider_id
};
let provider = self
.get(id)
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
provider.fetch_items(filter).await
}
async fn fetch_by_id(
&self,
item_id: &domain::MediaItemId,
) -> domain::DomainResult<Option<domain::MediaItem>> {
let id_str = item_id.value();
if let Some(pid) = Self::extract_provider_id(id_str) {
if let Some(provider) = self.get(pid) {
return provider.fetch_by_id(item_id).await;
}
}
if let Some(provider) = self.primary() {
provider.fetch_by_id(item_id).await
} else {
Ok(None)
}
}
async fn get_stream_url(
&self,
item_id: &domain::MediaItemId,
quality: &domain::ports::StreamQuality,
) -> domain::DomainResult<String> {
let id_str = item_id.value();
if let Some(pid) = Self::extract_provider_id(id_str) {
if let Some(provider) = self.get(pid) {
return provider.get_stream_url(item_id, quality).await;
}
}
if let Some(provider) = self.primary() {
provider.get_stream_url(item_id, quality).await
} else {
Err(DomainError::InfrastructureError(
"No provider available".into(),
))
}
}
fn provider_ids(&self) -> Vec<String> {
self.providers.iter().map(|(k, _)| k.clone()).collect()
}
fn primary_id(&self) -> &str {
self.providers
.first()
.map(|(k, _)| k.as_str())
.unwrap_or("")
}
fn capabilities(&self, provider_id: &str) -> Option<ProviderCapabilities> {
self.get(provider_id).map(|p| p.capabilities())
}
async fn list_collections(
&self,
provider_id: &str,
) -> domain::DomainResult<Vec<domain::ports::Collection>> {
let id = if provider_id.is_empty() {
self.primary_id()
} else {
provider_id
};
let provider = self
.get(id)
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
provider.list_collections().await
}
async fn list_series(
&self,
provider_id: &str,
collection_id: Option<&str>,
) -> domain::DomainResult<Vec<domain::ports::SeriesSummary>> {
let id = if provider_id.is_empty() {
self.primary_id()
} else {
provider_id
};
let provider = self
.get(id)
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
provider.list_series(collection_id).await
}
async fn list_genres(
&self,
provider_id: &str,
content_type: Option<&domain::ContentType>,
) -> domain::DomainResult<Vec<String>> {
let id = if provider_id.is_empty() {
self.primary_id()
} else {
provider_id
};
let provider = self
.get(id)
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
provider.list_genres(content_type).await
}
}
fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> domain::MediaItem {
let external_id = item.id().value().to_string();
let now = chrono::Utc::now().to_rfc3339();
domain::MediaItem::from_persistence(domain::MediaItemRow {
id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)),
provider_id: provider_id.to_string(),
external_id,
title: item.title().to_string(),
content_type: item.content_type().clone(),
duration_secs: item.duration_secs(),
description: item.description().map(|s| s.to_string()),
series_name: item.series_name().map(|s| s.to_string()),
season_number: item.season_number(),
episode_number: item.episode_number(),
year: item.year(),
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,
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
synced_at: Some(now),
role: domain::MediaRole::default(),
})
}
struct SimpleSyncAdapter {
library_command: Arc<dyn domain::ports::LibraryCommand>,
}
impl SimpleSyncAdapter {
fn new(library_command: Arc<dyn domain::ports::LibraryCommand>) -> Self {
Self { library_command }
}
}
#[async_trait::async_trait]
impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter {
async fn sync_provider(
&self,
provider: &dyn IMediaProvider,
provider_id: &str,
) -> domain::LibrarySyncResult {
use std::time::Instant;
let start = Instant::now();
let log_id = match self.library_command.log_sync_start(provider_id).await {
Ok(id) => id,
Err(e) => {
return domain::LibrarySyncResult::with_error(
provider_id,
0,
format!("Failed to log sync start: {e}"),
);
}
};
let filter = domain::MediaFilter::default();
let items = match provider.fetch_items(&filter).await {
Ok(items) => items,
Err(e) => {
let result = domain::LibrarySyncResult::with_error(
provider_id,
start.elapsed().as_millis() as u64,
format!("Failed to fetch items: {e}"),
);
let _ = self.library_command.log_sync_finish(log_id, &result).await;
return result;
}
};
let items_found = items.len() as u32;
if let Err(e) = self.library_command.clear_provider(provider_id).await {
let result = domain::LibrarySyncResult::with_error(
provider_id,
start.elapsed().as_millis() as u64,
format!("Failed to clear provider items: {e}"),
);
let _ = self.library_command.log_sync_finish(log_id, &result).await;
return result;
}
let library_items: Vec<domain::MediaItem> = items
.into_iter()
.map(|item| provider_item_to_library_item(item, provider_id))
.collect();
if let Err(e) = self
.library_command
.upsert_items(provider_id, library_items)
.await
{
let result = domain::LibrarySyncResult::with_error(
provider_id,
start.elapsed().as_millis() as u64,
format!("Failed to upsert items: {e}"),
);
let _ = self.library_command.log_sync_finish(log_id, &result).await;
return result;
}
let result = domain::LibrarySyncResult::new(
provider_id,
items_found,
start.elapsed().as_millis() as u64,
);
let _ = self.library_command.log_sync_finish(log_id, &result).await;
result
}
}