domain testing: InMemory repos + Noops behind test-helpers

This commit is contained in:
2026-07-12 01:41:23 +02:00
parent 7ddf94c75f
commit 848d4752e2
4 changed files with 935 additions and 0 deletions

View File

@@ -3,6 +3,8 @@ pub mod events;
pub mod models; pub mod models;
pub mod ports; pub mod ports;
pub mod services; pub mod services;
#[cfg(feature = "test-helpers")]
pub mod testing;
pub mod value_objects; pub mod value_objects;
pub use errors::{DomainError, DomainResult}; pub use errors::{DomainError, DomainResult};

View File

@@ -0,0 +1,785 @@
//! InMemory implementations of all domain port traits.
//!
//! Each struct uses `Mutex<HashMap<Id, Entity>>` internally. One struct
//! implements both the Command and Query traits for a given aggregate.
use std::collections::HashMap;
use std::sync::Mutex;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::errors::DomainResult;
use crate::models::{
ActivityEvent, Channel, ChannelConfigSnapshot, GeneratedSchedule, LibraryCollection,
LibraryItem, LibrarySyncLogEntry, LibrarySyncResult, PlaybackRecord, ProviderConfigRow,
ScheduleConfig, SeasonSummary, ShowSummary,
};
use crate::ports::{
ActivityLogCommand, ActivityLogQuery, AppSettingsRepository, ChannelCommand, ChannelQuery,
LibraryCommand, LibraryQuery, ProviderConfigCommand, ProviderConfigQuery, ScheduleCommand,
ScheduleQuery, TranscodeSettingsRepository, UserCommand, UserQuery,
};
use crate::value_objects::{
BlockId, ChannelId, ContentType, LibrarySearchFilter, MediaItemId, ScheduleId, UserId,
};
// ============================================================================
// InMemoryUserRepository
// ============================================================================
pub struct InMemoryUserRepository {
pub store: Mutex<HashMap<Uuid, crate::models::User>>,
}
impl InMemoryUserRepository {
pub fn new() -> Self {
Self {
store: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl UserCommand for InMemoryUserRepository {
async fn save(&self, user: &crate::models::User) -> DomainResult<()> {
self.store
.lock()
.unwrap()
.insert(user.id().value(), user.clone());
Ok(())
}
async fn delete(&self, id: UserId) -> DomainResult<()> {
self.store.lock().unwrap().remove(&id.value());
Ok(())
}
}
#[async_trait]
impl UserQuery for InMemoryUserRepository {
async fn find_by_id(&self, id: UserId) -> DomainResult<Option<crate::models::User>> {
Ok(self.store.lock().unwrap().get(&id.value()).cloned())
}
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<crate::models::User>> {
let store = self.store.lock().unwrap();
Ok(store.values().find(|u| u.subject() == subject).cloned())
}
async fn find_by_email(&self, email: &str) -> DomainResult<Option<crate::models::User>> {
let store = self.store.lock().unwrap();
Ok(store
.values()
.find(|u| u.email().as_ref() == email)
.cloned())
}
async fn count_users(&self) -> DomainResult<u64> {
Ok(self.store.lock().unwrap().len() as u64)
}
}
// ============================================================================
// InMemoryChannelRepository
// ============================================================================
pub struct InMemoryChannelRepository {
pub channels: Mutex<HashMap<Uuid, Channel>>,
pub snapshots: Mutex<Vec<ChannelConfigSnapshot>>,
}
impl InMemoryChannelRepository {
pub fn new() -> Self {
Self {
channels: Mutex::new(HashMap::new()),
snapshots: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl ChannelCommand for InMemoryChannelRepository {
async fn save(&self, channel: &Channel) -> DomainResult<()> {
self.channels
.lock()
.unwrap()
.insert(channel.id().value(), channel.clone());
Ok(())
}
async fn delete(&self, id: ChannelId) -> DomainResult<()> {
self.channels.lock().unwrap().remove(&id.value());
Ok(())
}
async fn save_config_snapshot(
&self,
channel_id: ChannelId,
config: &ScheduleConfig,
label: Option<String>,
) -> DomainResult<ChannelConfigSnapshot> {
let snaps = self.snapshots.lock().unwrap();
let version = snaps
.iter()
.filter(|s| s.channel_id() == channel_id)
.map(|s| s.version_num())
.max()
.unwrap_or(0)
+ 1;
drop(snaps);
let mut snap = ChannelConfigSnapshot::new(channel_id, config.clone(), version);
if let Some(lbl) = label {
snap = ChannelConfigSnapshot::from_persistence(
snap.id(),
snap.channel_id(),
snap.config().clone(),
snap.version_num(),
Some(lbl),
snap.created_at(),
);
}
self.snapshots.lock().unwrap().push(snap.clone());
Ok(snap)
}
async fn patch_config_snapshot_label(
&self,
channel_id: ChannelId,
snapshot_id: Uuid,
label: Option<String>,
) -> DomainResult<Option<ChannelConfigSnapshot>> {
let mut snaps = self.snapshots.lock().unwrap();
if let Some(pos) = snaps
.iter()
.position(|s| s.channel_id() == channel_id && s.id() == snapshot_id)
{
let old = &snaps[pos];
let updated = ChannelConfigSnapshot::from_persistence(
old.id(),
old.channel_id(),
old.config().clone(),
old.version_num(),
label,
old.created_at(),
);
snaps[pos] = updated.clone();
Ok(Some(updated))
} else {
Ok(None)
}
}
}
#[async_trait]
impl ChannelQuery for InMemoryChannelRepository {
async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>> {
Ok(self.channels.lock().unwrap().get(&id.value()).cloned())
}
async fn find_by_owner(&self, owner_id: UserId) -> DomainResult<Vec<Channel>> {
let store = self.channels.lock().unwrap();
Ok(store
.values()
.filter(|c| c.owner_id() == owner_id)
.cloned()
.collect())
}
async fn find_all(&self) -> DomainResult<Vec<Channel>> {
Ok(self.channels.lock().unwrap().values().cloned().collect())
}
async fn find_auto_schedule_enabled(&self) -> DomainResult<Vec<Channel>> {
let store = self.channels.lock().unwrap();
Ok(store
.values()
.filter(|c| c.auto_schedule())
.cloned()
.collect())
}
async fn list_config_snapshots(
&self,
channel_id: ChannelId,
) -> DomainResult<Vec<ChannelConfigSnapshot>> {
let snaps = self.snapshots.lock().unwrap();
let mut result: Vec<_> = snaps
.iter()
.filter(|s| s.channel_id() == channel_id)
.cloned()
.collect();
result.sort_by(|a, b| b.version_num().cmp(&a.version_num()));
Ok(result)
}
async fn get_config_snapshot(
&self,
channel_id: ChannelId,
snapshot_id: Uuid,
) -> DomainResult<Option<ChannelConfigSnapshot>> {
let snaps = self.snapshots.lock().unwrap();
Ok(snaps
.iter()
.find(|s| s.channel_id() == channel_id && s.id() == snapshot_id)
.cloned())
}
}
// ============================================================================
// InMemoryScheduleRepository
// ============================================================================
pub struct InMemoryScheduleRepository {
pub schedules: Mutex<HashMap<Uuid, GeneratedSchedule>>,
pub playback_records: Mutex<Vec<PlaybackRecord>>,
}
impl InMemoryScheduleRepository {
pub fn new() -> Self {
Self {
schedules: Mutex::new(HashMap::new()),
playback_records: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl ScheduleCommand for InMemoryScheduleRepository {
async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> {
self.schedules
.lock()
.unwrap()
.insert(schedule.id().value(), schedule.clone());
Ok(())
}
async fn save_playback_record(&self, record: &PlaybackRecord) -> DomainResult<()> {
self.playback_records.lock().unwrap().push(record.clone());
Ok(())
}
async fn delete_schedules_after(
&self,
channel_id: ChannelId,
target_generation: u32,
) -> DomainResult<()> {
self.schedules.lock().unwrap().retain(|_, s| {
!(s.channel_id() == channel_id && s.generation() > target_generation)
});
self.playback_records.lock().unwrap().retain(|r| {
!(r.channel_id() == channel_id && r.generation() > target_generation)
});
Ok(())
}
}
#[async_trait]
impl ScheduleQuery for InMemoryScheduleRepository {
async fn find_active(
&self,
channel_id: ChannelId,
at: DateTime<Utc>,
) -> DomainResult<Option<GeneratedSchedule>> {
let store = self.schedules.lock().unwrap();
Ok(store
.values()
.find(|s| s.channel_id() == channel_id && s.is_active_at(at))
.cloned())
}
async fn find_latest(
&self,
channel_id: ChannelId,
) -> DomainResult<Option<GeneratedSchedule>> {
let store = self.schedules.lock().unwrap();
Ok(store
.values()
.filter(|s| s.channel_id() == channel_id)
.max_by_key(|s| s.generation())
.cloned())
}
async fn find_playback_history(
&self,
channel_id: ChannelId,
) -> DomainResult<Vec<PlaybackRecord>> {
let records = self.playback_records.lock().unwrap();
Ok(records
.iter()
.filter(|r| r.channel_id() == channel_id)
.cloned()
.collect())
}
async fn find_last_slot_per_block(
&self,
channel_id: ChannelId,
) -> DomainResult<HashMap<BlockId, MediaItemId>> {
let store = self.schedules.lock().unwrap();
let mut result: HashMap<BlockId, (DateTime<Utc>, MediaItemId)> = HashMap::new();
for sched in store.values().filter(|s| s.channel_id() == channel_id) {
for slot in sched.slots() {
let block_id = slot.source_block_id();
let should_insert = result
.get(&block_id)
.map_or(true, |(prev_time, _)| slot.start_at() > *prev_time);
if should_insert {
result.insert(block_id, (slot.start_at(), slot.item().id().clone()));
}
}
}
Ok(result
.into_iter()
.map(|(k, (_, v))| (k, v))
.collect())
}
async fn list_schedule_history(
&self,
channel_id: ChannelId,
) -> DomainResult<Vec<GeneratedSchedule>> {
let store = self.schedules.lock().unwrap();
let mut result: Vec<_> = store
.values()
.filter(|s| s.channel_id() == channel_id)
.cloned()
.collect();
result.sort_by(|a, b| b.generation().cmp(&a.generation()));
Ok(result)
}
async fn get_schedule_by_id(
&self,
channel_id: ChannelId,
schedule_id: ScheduleId,
) -> DomainResult<Option<GeneratedSchedule>> {
let store = self.schedules.lock().unwrap();
Ok(store
.values()
.find(|s| s.channel_id() == channel_id && s.id() == schedule_id)
.cloned())
}
}
// ============================================================================
// InMemoryLibraryRepository
// ============================================================================
pub struct InMemoryLibraryRepository {
pub items: Mutex<HashMap<String, LibraryItem>>,
pub sync_logs: Mutex<Vec<LibrarySyncLogEntry>>,
next_log_id: Mutex<i64>,
}
impl InMemoryLibraryRepository {
pub fn new() -> Self {
Self {
items: Mutex::new(HashMap::new()),
sync_logs: Mutex::new(Vec::new()),
next_log_id: Mutex::new(1),
}
}
}
#[async_trait]
impl LibraryCommand for InMemoryLibraryRepository {
async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> {
let mut store = self.items.lock().unwrap();
for item in items {
store.insert(item.id().to_string(), item);
}
Ok(())
}
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> {
self.items
.lock()
.unwrap()
.retain(|_, item| item.provider_id() != provider_id);
Ok(())
}
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64> {
let mut id_counter = self.next_log_id.lock().unwrap();
let id = *id_counter;
*id_counter += 1;
let entry = LibrarySyncLogEntry::new(id, provider_id, Utc::now().to_rfc3339());
self.sync_logs.lock().unwrap().push(entry);
Ok(id)
}
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) {
let status = if result.error().is_some() {
"error"
} else {
"success"
};
*entry = LibrarySyncLogEntry::from_persistence(
entry.id(),
entry.provider_id().to_string(),
entry.started_at().to_string(),
Some(Utc::now().to_rfc3339()),
result.items_found(),
status.to_string(),
result.error().map(String::from),
);
}
Ok(())
}
}
#[async_trait]
impl LibraryQuery for InMemoryLibraryRepository {
async fn search(
&self,
filter: &LibrarySearchFilter,
) -> DomainResult<(Vec<LibraryItem>, u32)> {
let store = self.items.lock().unwrap();
let mut items: Vec<_> = store
.values()
.filter(|item| {
if let Some(pid) = filter.provider_id() {
if item.provider_id() != pid {
return false;
}
}
if let Some(ct) = filter.content_type() {
if item.content_type() != ct {
return false;
}
}
if let Some(term) = filter.search_term() {
if !item.title().to_lowercase().contains(&term.to_lowercase()) {
return false;
}
}
if !filter.genres().is_empty()
&& !filter
.genres()
.iter()
.any(|g| item.genres().contains(g))
{
return false;
}
true
})
.cloned()
.collect();
let total = items.len() as u32;
let offset = filter.offset() as usize;
let limit = filter.limit() as usize;
items = items.into_iter().skip(offset).take(limit).collect();
Ok((items, total))
}
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>> {
Ok(self.items.lock().unwrap().get(id).cloned())
}
async fn list_collections(
&self,
provider_id: Option<&str>,
) -> DomainResult<Vec<LibraryCollection>> {
let store = self.items.lock().unwrap();
let mut seen = HashMap::new();
for item in store.values() {
if let Some(pid) = provider_id {
if item.provider_id() != pid {
continue;
}
}
if let (Some(cid), Some(cname)) = (item.collection_id(), item.collection_name()) {
seen.entry(cid.to_string())
.or_insert_with(|| LibraryCollection::new(cid, cname));
}
}
Ok(seen.into_values().collect())
}
async fn list_series(&self, provider_id: Option<&str>) -> DomainResult<Vec<String>> {
let store = self.items.lock().unwrap();
let mut names: Vec<String> = store
.values()
.filter(|item| {
if let Some(pid) = provider_id {
item.provider_id() == pid
} else {
true
}
})
.filter_map(|item| item.series_name().map(String::from))
.collect();
names.sort();
names.dedup();
Ok(names)
}
async fn list_genres(
&self,
_content_type: Option<&ContentType>,
provider_id: Option<&str>,
) -> DomainResult<Vec<String>> {
let store = self.items.lock().unwrap();
let mut genres: Vec<String> = store
.values()
.filter(|item| {
if let Some(pid) = provider_id {
item.provider_id() == pid
} else {
true
}
})
.flat_map(|item| item.genres().iter().cloned())
.collect();
genres.sort();
genres.dedup();
Ok(genres)
}
async fn latest_sync_status(&self) -> DomainResult<Vec<LibrarySyncLogEntry>> {
let logs = self.sync_logs.lock().unwrap();
let mut latest: HashMap<String, LibrarySyncLogEntry> = HashMap::new();
for entry in logs.iter() {
let key = entry.provider_id().to_string();
if !latest.contains_key(&key)
|| entry.id() > latest[&key].id()
{
latest.insert(key, entry.clone());
}
}
Ok(latest.into_values().collect())
}
async fn is_sync_running(&self, provider_id: &str) -> DomainResult<bool> {
let logs = self.sync_logs.lock().unwrap();
Ok(logs
.iter()
.any(|e| e.provider_id() == provider_id && e.status() == "running"))
}
async fn list_shows(
&self,
provider_id: Option<&str>,
search_term: Option<&str>,
_genres: &[String],
) -> DomainResult<Vec<ShowSummary>> {
let store = self.items.lock().unwrap();
let mut shows: HashMap<String, (u32, std::collections::HashSet<u32>)> = HashMap::new();
for item in store.values() {
if let Some(pid) = provider_id {
if item.provider_id() != pid {
continue;
}
}
if let Some(series) = item.series_name() {
if let Some(term) = search_term {
if !series.to_lowercase().contains(&term.to_lowercase()) {
continue;
}
}
let entry = shows
.entry(series.to_string())
.or_insert_with(|| (0, std::collections::HashSet::new()));
entry.0 += 1;
if let Some(sn) = item.season_number() {
entry.1.insert(sn);
}
}
}
Ok(shows
.into_iter()
.map(|(name, (ep_count, seasons))| {
ShowSummary::new(name, ep_count, seasons.len() as u32)
})
.collect())
}
async fn list_seasons(
&self,
series_name: &str,
provider_id: Option<&str>,
) -> DomainResult<Vec<SeasonSummary>> {
let store = self.items.lock().unwrap();
let mut seasons: HashMap<u32, u32> = HashMap::new();
for item in store.values() {
if let Some(pid) = provider_id {
if item.provider_id() != pid {
continue;
}
}
if item.series_name() == Some(series_name) {
if let Some(sn) = item.season_number() {
*seasons.entry(sn).or_insert(0) += 1;
}
}
}
let mut result: Vec<_> = seasons
.into_iter()
.map(|(sn, count)| SeasonSummary::new(sn, count))
.collect();
result.sort_by_key(|s| s.season_number());
Ok(result)
}
}
// ============================================================================
// InMemoryActivityLog
// ============================================================================
pub struct InMemoryActivityLog {
pub events: Mutex<Vec<ActivityEvent>>,
}
impl InMemoryActivityLog {
pub fn new() -> Self {
Self {
events: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl ActivityLogCommand for InMemoryActivityLog {
async fn log(
&self,
event_type: &str,
detail: &str,
channel_id: Option<ChannelId>,
) -> DomainResult<()> {
let event = ActivityEvent::new(
event_type,
detail,
channel_id.map(|c| c.value()),
);
self.events.lock().unwrap().push(event);
Ok(())
}
}
#[async_trait]
impl ActivityLogQuery for InMemoryActivityLog {
async fn recent(&self, limit: u32) -> DomainResult<Vec<ActivityEvent>> {
let events = self.events.lock().unwrap();
let mut sorted: Vec<_> = events.clone();
sorted.sort_by(|a, b| b.timestamp().cmp(&a.timestamp()));
Ok(sorted.into_iter().take(limit as usize).collect())
}
}
// ============================================================================
// InMemoryAppSettings
// ============================================================================
pub struct InMemoryAppSettings {
pub settings: Mutex<HashMap<String, String>>,
}
impl InMemoryAppSettings {
pub fn new() -> Self {
Self {
settings: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl AppSettingsRepository for InMemoryAppSettings {
async fn get(&self, key: &str) -> DomainResult<Option<String>> {
Ok(self.settings.lock().unwrap().get(key).cloned())
}
async fn set(&self, key: &str, value: &str) -> DomainResult<()> {
self.settings
.lock()
.unwrap()
.insert(key.to_string(), value.to_string());
Ok(())
}
async fn get_all(&self) -> DomainResult<Vec<(String, String)>> {
Ok(self
.settings
.lock()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect())
}
}
// ============================================================================
// InMemoryProviderConfig
// ============================================================================
pub struct InMemoryProviderConfig {
pub configs: Mutex<HashMap<String, ProviderConfigRow>>,
}
impl InMemoryProviderConfig {
pub fn new() -> Self {
Self {
configs: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl ProviderConfigCommand for InMemoryProviderConfig {
async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()> {
self.configs
.lock()
.unwrap()
.insert(row.id().to_string(), row.clone());
Ok(())
}
async fn delete(&self, id: &str) -> DomainResult<()> {
self.configs.lock().unwrap().remove(id);
Ok(())
}
}
#[async_trait]
impl ProviderConfigQuery for InMemoryProviderConfig {
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>> {
Ok(self.configs.lock().unwrap().values().cloned().collect())
}
async fn get_by_id(&self, id: &str) -> DomainResult<Option<ProviderConfigRow>> {
Ok(self.configs.lock().unwrap().get(id).cloned())
}
}
// ============================================================================
// InMemoryTranscodeSettings
// ============================================================================
pub struct InMemoryTranscodeSettings {
pub cleanup_ttl: Mutex<Option<u32>>,
}
impl InMemoryTranscodeSettings {
pub fn new() -> Self {
Self {
cleanup_ttl: Mutex::new(None),
}
}
}
#[async_trait]
impl TranscodeSettingsRepository for InMemoryTranscodeSettings {
async fn load_cleanup_ttl(&self) -> DomainResult<Option<u32>> {
Ok(*self.cleanup_ttl.lock().unwrap())
}
async fn save_cleanup_ttl(&self, hours: u32) -> DomainResult<()> {
*self.cleanup_ttl.lock().unwrap() = Some(hours);
Ok(())
}
}

View File

@@ -0,0 +1,11 @@
//! Test doubles for domain ports.
//!
//! Gated behind `#[cfg(feature = "test-helpers")]`.
//! Provides InMemory implementations (for integration tests) and
//! Noop implementations (for unit tests that don't care about persistence).
mod in_memory;
mod noops;
pub use in_memory::*;
pub use noops::*;

View File

@@ -0,0 +1,137 @@
//! Noop implementations of domain ports.
//!
//! Return `Ok(())` for writes, `Ok(None)`/`Ok(vec![])` for reads.
//! Useful for unit tests that don't care about persistence/side-effects.
use async_trait::async_trait;
use crate::errors::DomainResult;
use crate::events::DomainEvent;
use crate::models::{
ActivityEvent, LibrarySyncResult, MediaItem,
};
use crate::ports::{
ActivityLogCommand, ActivityLogQuery, EventPublisher, IMediaProvider, LibrarySyncAdapter,
ProviderCapabilities, StreamQuality, StreamingProtocol,
};
use crate::value_objects::{ChannelId, MediaFilter, MediaItemId};
// ============================================================================
// NoopEventPublisher
// ============================================================================
pub struct NoopEventPublisher;
impl NoopEventPublisher {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl EventPublisher for NoopEventPublisher {
async fn publish(&self, _event: DomainEvent) -> DomainResult<()> {
Ok(())
}
}
// ============================================================================
// NoopMediaProvider
// ============================================================================
pub struct NoopMediaProvider;
impl NoopMediaProvider {
pub fn new() -> Self {
Self
}
}
#[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::Hls,
rescan: false,
transcode: false,
}
}
async fn fetch_items(&self, _filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
Ok(vec![])
}
async fn fetch_by_id(&self, _item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
Ok(None)
}
async fn get_stream_url(
&self,
_item_id: &MediaItemId,
_quality: &StreamQuality,
) -> DomainResult<String> {
Err(crate::errors::DomainError::InfrastructureError(
"NoopMediaProvider does not support streaming".into(),
))
}
}
// ============================================================================
// NoopActivityLog
// ============================================================================
pub struct NoopActivityLog;
impl NoopActivityLog {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl ActivityLogCommand for NoopActivityLog {
async fn log(
&self,
_event_type: &str,
_detail: &str,
_channel_id: Option<ChannelId>,
) -> DomainResult<()> {
Ok(())
}
}
#[async_trait]
impl ActivityLogQuery for NoopActivityLog {
async fn recent(&self, _limit: u32) -> DomainResult<Vec<ActivityEvent>> {
Ok(vec![])
}
}
// ============================================================================
// NoopLibrarySync
// ============================================================================
pub struct NoopLibrarySync;
impl NoopLibrarySync {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl LibrarySyncAdapter for NoopLibrarySync {
async fn sync_provider(
&self,
_provider: &dyn IMediaProvider,
provider_id: &str,
) -> LibrarySyncResult {
LibrarySyncResult::new(provider_id, 0, 0)
}
}