complète le trait MusicSource
This commit is contained in:
@@ -5,14 +5,16 @@
|
|||||||
//! - Accessing the embedded WebP image
|
//! - Accessing the embedded WebP image
|
||||||
//! - Optionally saving it to a file
|
//! - Optionally saving it to a file
|
||||||
|
|
||||||
use pmoparadise::RadioParadiseSource;
|
use pmoparadise::{RadioParadiseSource, RadioParadiseClient};
|
||||||
use pmosource::MusicSource;
|
use pmosource::MusicSource;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
#[tokio::main]
|
||||||
// Create the source
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let source = RadioParadiseSource;
|
// Create the client and source
|
||||||
|
let client = RadioParadiseClient::new().await?;
|
||||||
|
let source = RadioParadiseSource::new_default(client, "http://localhost:8080");
|
||||||
|
|
||||||
// Display source information
|
// Display source information
|
||||||
println!("Music Source Information");
|
println!("Music Source Information");
|
||||||
|
|||||||
@@ -500,6 +500,228 @@ impl MusicSource for RadioParadiseSource {
|
|||||||
// Radio Paradise doesn't support search
|
// Radio Paradise doesn't support search
|
||||||
Err(MusicSourceError::SearchNotSupported)
|
Err(MusicSourceError::SearchNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============= Extended Features Implementation =============
|
||||||
|
|
||||||
|
fn capabilities(&self) -> pmosource::SourceCapabilities {
|
||||||
|
pmosource::SourceCapabilities {
|
||||||
|
supports_fifo: true,
|
||||||
|
supports_search: false,
|
||||||
|
supports_favorites: false,
|
||||||
|
supports_playlists: false,
|
||||||
|
supports_user_content: false,
|
||||||
|
supports_high_res_audio: true,
|
||||||
|
max_sample_rate: Some(96_000), // Radio Paradise FLAC is typically 44.1 or 48 kHz, up to 96 kHz
|
||||||
|
supports_multiple_formats: true,
|
||||||
|
supports_advanced_search: false,
|
||||||
|
supports_pagination: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_available_formats(&self, _object_id: &str) -> Result<Vec<pmosource::AudioFormat>> {
|
||||||
|
use pmosource::AudioFormat;
|
||||||
|
|
||||||
|
// Radio Paradise offers 5 quality levels
|
||||||
|
Ok(vec![
|
||||||
|
AudioFormat {
|
||||||
|
format_id: "mp3-128".to_string(),
|
||||||
|
mime_type: "audio/mpeg".to_string(),
|
||||||
|
sample_rate: Some(44100),
|
||||||
|
bit_depth: None,
|
||||||
|
bitrate: Some(128),
|
||||||
|
channels: Some(2),
|
||||||
|
},
|
||||||
|
AudioFormat {
|
||||||
|
format_id: "aac-64".to_string(),
|
||||||
|
mime_type: "audio/aac".to_string(),
|
||||||
|
sample_rate: Some(44100),
|
||||||
|
bit_depth: None,
|
||||||
|
bitrate: Some(64),
|
||||||
|
channels: Some(2),
|
||||||
|
},
|
||||||
|
AudioFormat {
|
||||||
|
format_id: "aac-128".to_string(),
|
||||||
|
mime_type: "audio/aac".to_string(),
|
||||||
|
sample_rate: Some(44100),
|
||||||
|
bit_depth: None,
|
||||||
|
bitrate: Some(128),
|
||||||
|
channels: Some(2),
|
||||||
|
},
|
||||||
|
AudioFormat {
|
||||||
|
format_id: "aac-320".to_string(),
|
||||||
|
mime_type: "audio/aac".to_string(),
|
||||||
|
sample_rate: Some(44100),
|
||||||
|
bit_depth: None,
|
||||||
|
bitrate: Some(320),
|
||||||
|
channels: Some(2),
|
||||||
|
},
|
||||||
|
AudioFormat {
|
||||||
|
format_id: "flac".to_string(),
|
||||||
|
mime_type: "audio/flac".to_string(),
|
||||||
|
sample_rate: Some(44100),
|
||||||
|
bit_depth: Some(16),
|
||||||
|
bitrate: None,
|
||||||
|
channels: Some(2),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_cache_status(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
|
||||||
|
use pmosource::CacheStatus;
|
||||||
|
|
||||||
|
let cache = self.inner.track_cache.read().await;
|
||||||
|
|
||||||
|
if let Some(metadata) = cache.get(object_id) {
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
{
|
||||||
|
if let Some(ref audio_cache) = self.inner.audio_cache {
|
||||||
|
if let Some(ref pk) = metadata.cached_audio_pk {
|
||||||
|
// Check if the cached file exists and get its size
|
||||||
|
if let Ok(Some(info)) = audio_cache.get_info(pk).await {
|
||||||
|
return Ok(CacheStatus::Cached {
|
||||||
|
size_bytes: info.size_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check legacy cached_pk for backward compatibility
|
||||||
|
if metadata.cached_pk.is_some() {
|
||||||
|
// We don't have size info for legacy cache
|
||||||
|
return Ok(CacheStatus::Cached { size_bytes: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CacheStatus::NotCached)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cache_item(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
|
||||||
|
#[cfg(not(feature = "cache"))]
|
||||||
|
{
|
||||||
|
let _ = object_id;
|
||||||
|
return Err(MusicSourceError::NotSupported("Caching not enabled".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
{
|
||||||
|
use pmosource::CacheStatus;
|
||||||
|
|
||||||
|
// Get the track metadata
|
||||||
|
let cache = self.inner.track_cache.read().await;
|
||||||
|
let metadata = cache
|
||||||
|
.get(object_id)
|
||||||
|
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?
|
||||||
|
.clone();
|
||||||
|
drop(cache);
|
||||||
|
|
||||||
|
// If already cached, return status
|
||||||
|
if metadata.cached_audio_pk.is_some() {
|
||||||
|
return self.get_cache_status(object_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache it now
|
||||||
|
if let Some(ref audio_cache) = self.inner.audio_cache {
|
||||||
|
let song = &metadata.block.songs[metadata.song_index];
|
||||||
|
|
||||||
|
let audio_metadata = pmoaudiocache::AudioMetadata {
|
||||||
|
title: Some(song.title.clone()),
|
||||||
|
artist: if !song.artist.is_empty() {
|
||||||
|
Some(song.artist.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
album: if !song.album.is_empty() {
|
||||||
|
Some(song.album.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
duration_secs: if song.duration > 0 {
|
||||||
|
Some((song.duration / 1000) as u64)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
year: None,
|
||||||
|
track_number: None,
|
||||||
|
track_total: None,
|
||||||
|
disc_number: None,
|
||||||
|
disc_total: None,
|
||||||
|
genre: None,
|
||||||
|
sample_rate: None,
|
||||||
|
channels: None,
|
||||||
|
bitrate: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match audio_cache
|
||||||
|
.add_from_url(&metadata.original_uri, Some(audio_metadata))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((pk, _)) => {
|
||||||
|
// Update the metadata
|
||||||
|
let mut cache = self.inner.track_cache.write().await;
|
||||||
|
if let Some(meta) = cache.get_mut(object_id) {
|
||||||
|
meta.cached_audio_pk = Some(pk);
|
||||||
|
}
|
||||||
|
return self.get_cache_status(object_id).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(CacheStatus::Failed {
|
||||||
|
error: e.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CacheStatus::NotCached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn browse_paginated(
|
||||||
|
&self,
|
||||||
|
object_id: &str,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<BrowseResult> {
|
||||||
|
// For Radio Paradise, we can efficiently paginate the FIFO
|
||||||
|
if object_id == "radio-paradise" || object_id == "0" {
|
||||||
|
let tracks = self.inner.playlist.get_items(offset, limit).await;
|
||||||
|
let items: Vec<Item> = tracks.iter().map(|t| self.track_to_item(t)).collect();
|
||||||
|
Ok(BrowseResult::Items(items))
|
||||||
|
} else {
|
||||||
|
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_item_count(&self, object_id: &str) -> Result<usize> {
|
||||||
|
if object_id == "radio-paradise" || object_id == "0" {
|
||||||
|
Ok(self.inner.playlist.len().await)
|
||||||
|
} else {
|
||||||
|
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn statistics(&self) -> Result<pmosource::SourceStatistics> {
|
||||||
|
let mut stats = pmosource::SourceStatistics::default();
|
||||||
|
|
||||||
|
// Total items in FIFO
|
||||||
|
stats.total_items = Some(self.inner.playlist.len().await);
|
||||||
|
|
||||||
|
// Cache statistics
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
{
|
||||||
|
let cache = self.inner.track_cache.read().await;
|
||||||
|
let cached_count = cache.values().filter(|m| m.cached_audio_pk.is_some()).count();
|
||||||
|
stats.cached_items = Some(cached_count);
|
||||||
|
|
||||||
|
if let Some(ref audio_cache) = self.inner.audio_cache {
|
||||||
|
if let Ok(cache_stats) = audio_cache.statistics().await {
|
||||||
|
stats.cache_size_bytes = Some(cache_stats.total_size_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -68,3 +68,8 @@ tokio-test = "0.4"
|
|||||||
mockito = "1.0"
|
mockito = "1.0"
|
||||||
# Pour les exemples
|
# Pour les exemples
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
|
|
||||||
|
# Specify that the with_cache example requires the cache feature
|
||||||
|
[[example]]
|
||||||
|
name = "with_cache"
|
||||||
|
required-features = ["cache"]
|
||||||
|
|||||||
@@ -5,14 +5,16 @@
|
|||||||
//! - Accessing the embedded WebP image
|
//! - Accessing the embedded WebP image
|
||||||
//! - Optionally saving it to a file
|
//! - Optionally saving it to a file
|
||||||
|
|
||||||
use pmoqobuz::QobuzSource;
|
use pmoqobuz::{QobuzSource, QobuzClient};
|
||||||
use pmosource::MusicSource;
|
use pmosource::MusicSource;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
#[tokio::main]
|
||||||
// Create the source
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let source = QobuzSource;
|
// Create the client and source
|
||||||
|
let client = QobuzClient::from_config().await?;
|
||||||
|
let source = QobuzSource::new(client, "http://localhost:8080");
|
||||||
|
|
||||||
// Display source information
|
// Display source information
|
||||||
println!("Music Source Information");
|
println!("Music Source Information");
|
||||||
|
|||||||
@@ -121,4 +121,74 @@ impl QobuzApi {
|
|||||||
.map(QobuzApi::parse_playlist)
|
.map(QobuzApi::parse_playlist)
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ajoute un album aux favoris
|
||||||
|
pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> {
|
||||||
|
let user_id = self.ensure_authenticated()?;
|
||||||
|
debug!("Adding album {} to favorites for user {}", album_id, user_id);
|
||||||
|
|
||||||
|
let params = [
|
||||||
|
("album_id", album_id),
|
||||||
|
("user_id", user_id),
|
||||||
|
];
|
||||||
|
|
||||||
|
self.get::<serde_json::Value>("/favorite/create", ¶ms).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un album des favoris
|
||||||
|
pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> {
|
||||||
|
let user_id = self.ensure_authenticated()?;
|
||||||
|
debug!("Removing album {} from favorites for user {}", album_id, user_id);
|
||||||
|
|
||||||
|
let params = [
|
||||||
|
("album_ids", album_id),
|
||||||
|
("user_id", user_id),
|
||||||
|
];
|
||||||
|
|
||||||
|
self.get::<serde_json::Value>("/favorite/delete", ¶ms).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un track aux favoris
|
||||||
|
pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> {
|
||||||
|
let user_id = self.ensure_authenticated()?;
|
||||||
|
debug!("Adding track {} to favorites for user {}", track_id, user_id);
|
||||||
|
|
||||||
|
let params = [
|
||||||
|
("track_id", track_id),
|
||||||
|
("user_id", user_id),
|
||||||
|
];
|
||||||
|
|
||||||
|
self.get::<serde_json::Value>("/favorite/create", ¶ms).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un track des favoris
|
||||||
|
pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> {
|
||||||
|
let user_id = self.ensure_authenticated()?;
|
||||||
|
debug!("Removing track {} from favorites for user {}", track_id, user_id);
|
||||||
|
|
||||||
|
let params = [
|
||||||
|
("track_ids", track_id),
|
||||||
|
("user_id", user_id),
|
||||||
|
];
|
||||||
|
|
||||||
|
self.get::<serde_json::Value>("/favorite/delete", ¶ms).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un track à une playlist
|
||||||
|
pub async fn add_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> {
|
||||||
|
let user_id = self.ensure_authenticated()?;
|
||||||
|
debug!("Adding track {} to playlist {} for user {}", track_id, playlist_id, user_id);
|
||||||
|
|
||||||
|
let params = [
|
||||||
|
("playlist_id", playlist_id),
|
||||||
|
("track_ids", track_id),
|
||||||
|
];
|
||||||
|
|
||||||
|
self.get::<serde_json::Value>("/playlist/addTracks", ¶ms).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,6 +327,31 @@ impl QobuzClient {
|
|||||||
pub async fn get_user_playlists(&self) -> Result<Vec<Playlist>> {
|
pub async fn get_user_playlists(&self) -> Result<Vec<Playlist>> {
|
||||||
self.api.get_user_playlists().await
|
self.api.get_user_playlists().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ajoute un album aux favoris
|
||||||
|
pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> {
|
||||||
|
self.api.add_favorite_album(album_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un album des favoris
|
||||||
|
pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> {
|
||||||
|
self.api.remove_favorite_album(album_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un track aux favoris
|
||||||
|
pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> {
|
||||||
|
self.api.add_favorite_track(track_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un track des favoris
|
||||||
|
pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> {
|
||||||
|
self.api.remove_favorite_track(track_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un track à une playlist
|
||||||
|
pub async fn add_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> {
|
||||||
|
self.api.add_to_playlist(playlist_id, track_id).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
use crate::client::QobuzClient;
|
use crate::client::QobuzClient;
|
||||||
use crate::didl::ToDIDL;
|
use crate::didl::ToDIDL;
|
||||||
use crate::models::{Album, Track};
|
use crate::models::Track;
|
||||||
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
|
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
|
||||||
use pmodidl::{Container, Item};
|
use pmodidl::{Container, Item};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -582,6 +582,381 @@ impl MusicSource for QobuzSource {
|
|||||||
Ok(BrowseResult::Items(vec![]))
|
Ok(BrowseResult::Items(vec![]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============= Extended Features Implementation =============
|
||||||
|
|
||||||
|
fn capabilities(&self) -> pmosource::SourceCapabilities {
|
||||||
|
pmosource::SourceCapabilities {
|
||||||
|
supports_fifo: false,
|
||||||
|
supports_search: true,
|
||||||
|
supports_favorites: true,
|
||||||
|
supports_playlists: true,
|
||||||
|
supports_user_content: false,
|
||||||
|
supports_high_res_audio: true,
|
||||||
|
max_sample_rate: Some(192_000), // Qobuz supports up to 192kHz
|
||||||
|
supports_multiple_formats: true,
|
||||||
|
supports_advanced_search: false, // TODO: Qobuz API supports it, not yet implemented
|
||||||
|
supports_pagination: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_available_formats(&self, object_id: &str) -> Result<Vec<pmosource::AudioFormat>> {
|
||||||
|
use pmosource::AudioFormat;
|
||||||
|
|
||||||
|
// Extract track ID from object_id
|
||||||
|
let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") {
|
||||||
|
id
|
||||||
|
} else {
|
||||||
|
object_id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get track details from Qobuz
|
||||||
|
let track = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.get_track(track_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
|
// Qobuz provides multiple formats based on subscription
|
||||||
|
let mut formats = vec![];
|
||||||
|
|
||||||
|
// MP3 320 (format_id 5) - available to all
|
||||||
|
formats.push(AudioFormat {
|
||||||
|
format_id: "mp3-320".to_string(),
|
||||||
|
mime_type: "audio/mpeg".to_string(),
|
||||||
|
sample_rate: Some(44100),
|
||||||
|
bit_depth: None,
|
||||||
|
bitrate: Some(320),
|
||||||
|
channels: Some(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
// FLAC 16/44.1 (format_id 6) - CD quality
|
||||||
|
formats.push(AudioFormat {
|
||||||
|
format_id: "flac-16-44".to_string(),
|
||||||
|
mime_type: "audio/flac".to_string(),
|
||||||
|
sample_rate: Some(44100),
|
||||||
|
bit_depth: Some(16),
|
||||||
|
bitrate: None,
|
||||||
|
channels: Some(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hi-Res formats (if available for this track)
|
||||||
|
if let Some(sample_rate) = track.sample_rate {
|
||||||
|
if sample_rate > 44100 {
|
||||||
|
// FLAC 24-bit Hi-Res
|
||||||
|
let bit_depth = track.bit_depth.map(|d| d as u8).or(Some(24));
|
||||||
|
|
||||||
|
formats.push(AudioFormat {
|
||||||
|
format_id: format!("flac-{}-{}", bit_depth.unwrap_or(24), sample_rate / 1000),
|
||||||
|
mime_type: "audio/flac".to_string(),
|
||||||
|
sample_rate: Some(sample_rate),
|
||||||
|
bit_depth,
|
||||||
|
bitrate: None,
|
||||||
|
channels: track.channels,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(formats)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_cache_status(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
|
||||||
|
use pmosource::CacheStatus;
|
||||||
|
|
||||||
|
let cache = self.inner.track_cache.read().await;
|
||||||
|
|
||||||
|
if let Some(metadata) = cache.get(object_id) {
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
{
|
||||||
|
if let Some(ref _audio_cache) = self.inner.audio_cache {
|
||||||
|
if let Some(ref _pk) = metadata.cached_audio_pk {
|
||||||
|
// TODO: AudioCache doesn't have get_info method yet
|
||||||
|
// For now, just return that it's cached without size info
|
||||||
|
return Ok(CacheStatus::Cached {
|
||||||
|
size_bytes: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CacheStatus::NotCached)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cache_item(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
|
||||||
|
#[cfg(not(feature = "cache"))]
|
||||||
|
{
|
||||||
|
let _ = object_id;
|
||||||
|
return Err(MusicSourceError::NotSupported("Caching not enabled".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
{
|
||||||
|
use pmosource::CacheStatus;
|
||||||
|
|
||||||
|
// Extract track ID
|
||||||
|
let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") {
|
||||||
|
id
|
||||||
|
} else {
|
||||||
|
object_id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get track details
|
||||||
|
let track = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.get_track(track_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
|
// Add track to cache
|
||||||
|
let cached_id = self.add_track(&track).await?;
|
||||||
|
|
||||||
|
// Return the cache status
|
||||||
|
self.get_cache_status(&cached_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_favorite(&self, object_id: &str) -> Result<()> {
|
||||||
|
// Parse object_id to determine type
|
||||||
|
let parts: Vec<&str> = object_id.split(':').collect();
|
||||||
|
|
||||||
|
match parts.as_slice() {
|
||||||
|
["qobuz", "album", id] | ["qobuz://album", id] => {
|
||||||
|
self.inner
|
||||||
|
.client
|
||||||
|
.add_favorite_album(id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
||||||
|
}
|
||||||
|
["qobuz", "track", id] | ["qobuz://track", id] => {
|
||||||
|
self.inner
|
||||||
|
.client
|
||||||
|
.add_favorite_track(id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(MusicSourceError::NotSupported(
|
||||||
|
"Favorites only supported for albums and tracks".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.increment_update_id().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_favorite(&self, object_id: &str) -> Result<()> {
|
||||||
|
// Parse object_id to determine type
|
||||||
|
let parts: Vec<&str> = object_id.split(':').collect();
|
||||||
|
|
||||||
|
match parts.as_slice() {
|
||||||
|
["qobuz", "album", id] | ["qobuz://album", id] => {
|
||||||
|
self.inner
|
||||||
|
.client
|
||||||
|
.remove_favorite_album(id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
||||||
|
}
|
||||||
|
["qobuz", "track", id] | ["qobuz://track", id] => {
|
||||||
|
self.inner
|
||||||
|
.client
|
||||||
|
.remove_favorite_track(id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(MusicSourceError::NotSupported(
|
||||||
|
"Favorites only supported for albums and tracks".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.increment_update_id().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_favorite(&self, object_id: &str) -> Result<bool> {
|
||||||
|
// Parse object_id to determine type
|
||||||
|
let parts: Vec<&str> = object_id.split(':').collect();
|
||||||
|
|
||||||
|
match parts.as_slice() {
|
||||||
|
["qobuz", "album", id] | ["qobuz://album", id] => {
|
||||||
|
let favorites = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.get_favorite_albums()
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(favorites.iter().any(|album| album.id == *id))
|
||||||
|
}
|
||||||
|
["qobuz", "track", id] | ["qobuz://track", id] => {
|
||||||
|
let favorites = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.get_favorite_tracks()
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(favorites.iter().any(|track| track.id == *id))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
Err(MusicSourceError::NotSupported(
|
||||||
|
"Favorites only supported for albums and tracks".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_playlists(&self) -> Result<Vec<Container>> {
|
||||||
|
let playlists = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.get_user_playlists()
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
|
let containers: Vec<Container> = playlists
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|playlist| playlist.to_didl_container("qobuz").ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(containers)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_to_playlist(&self, playlist_id: &str, item_id: &str) -> Result<()> {
|
||||||
|
// Extract track ID from item_id
|
||||||
|
let track_id = if let Some(id) = item_id.strip_prefix("qobuz://track/") {
|
||||||
|
id
|
||||||
|
} else if let Some(id) = item_id.strip_prefix("qobuz:track:") {
|
||||||
|
id
|
||||||
|
} else {
|
||||||
|
item_id
|
||||||
|
};
|
||||||
|
|
||||||
|
self.inner
|
||||||
|
.client
|
||||||
|
.add_to_playlist(playlist_id, track_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
|
self.increment_update_id().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_item_count(&self, object_id: &str) -> Result<usize> {
|
||||||
|
match self.parse_object_id(object_id) {
|
||||||
|
ObjectIdType::Album(album_id) => {
|
||||||
|
let album = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.get_album(&album_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(album.tracks_count.unwrap_or(0) as usize)
|
||||||
|
}
|
||||||
|
ObjectIdType::Playlist(playlist_id) => {
|
||||||
|
let playlist = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.get_playlist(&playlist_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(playlist.tracks_count.unwrap_or(0) as usize)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Fall back to default implementation
|
||||||
|
let result = self.browse(object_id).await?;
|
||||||
|
Ok(result.count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn browse_paginated(
|
||||||
|
&self,
|
||||||
|
object_id: &str,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<BrowseResult> {
|
||||||
|
match self.parse_object_id(object_id) {
|
||||||
|
ObjectIdType::Album(album_id) => {
|
||||||
|
// Qobuz returns all tracks, so we slice them
|
||||||
|
let tracks = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.get_album_tracks(&album_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let items: Vec<Item> = tracks
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset)
|
||||||
|
.take(limit)
|
||||||
|
.filter_map(|track| {
|
||||||
|
track
|
||||||
|
.to_didl_item(&format!("qobuz:album:{}", album_id))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(BrowseResult::Items(items))
|
||||||
|
}
|
||||||
|
ObjectIdType::Favorites => {
|
||||||
|
let albums = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.get_favorite_albums()
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let containers: Vec<Container> = albums
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset)
|
||||||
|
.take(limit)
|
||||||
|
.filter_map(|album| album.to_didl_container("qobuz:favorites").ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(BrowseResult::Containers(containers))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Fall back to default implementation
|
||||||
|
self.browse(object_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn statistics(&self) -> Result<pmosource::SourceStatistics> {
|
||||||
|
let mut stats = pmosource::SourceStatistics::default();
|
||||||
|
|
||||||
|
// Try to get favorite counts
|
||||||
|
if let Ok(albums) = self.inner.client.get_favorite_albums().await {
|
||||||
|
stats.total_containers = Some(albums.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(tracks) = self.inner.client.get_favorite_tracks().await {
|
||||||
|
stats.total_items = Some(tracks.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cache statistics
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
{
|
||||||
|
let cache = self.inner.track_cache.read().await;
|
||||||
|
stats.cached_items = Some(cache.len());
|
||||||
|
|
||||||
|
// TODO: AudioCache doesn't have statistics method yet
|
||||||
|
// For now, just count cached items
|
||||||
|
if let Some(ref _audio_cache) = self.inner.audio_cache {
|
||||||
|
// stats.cache_size_bytes will remain None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -53,11 +53,118 @@ pub enum MusicSourceError {
|
|||||||
|
|
||||||
#[error("URI resolution failed: {0}")]
|
#[error("URI resolution failed: {0}")]
|
||||||
UriResolutionError(String),
|
UriResolutionError(String),
|
||||||
|
|
||||||
|
#[error("Feature not supported: {0}")]
|
||||||
|
NotSupported(String),
|
||||||
|
|
||||||
|
#[error("Favorites operation failed: {0}")]
|
||||||
|
FavoritesError(String),
|
||||||
|
|
||||||
|
#[error("Playlist operation failed: {0}")]
|
||||||
|
PlaylistError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result type for music source operations
|
/// Result type for music source operations
|
||||||
pub type Result<T> = std::result::Result<T, MusicSourceError>;
|
pub type Result<T> = std::result::Result<T, MusicSourceError>;
|
||||||
|
|
||||||
|
/// Source capabilities describing what features are supported
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SourceCapabilities {
|
||||||
|
/// Supports FIFO operations (dynamic playlists)
|
||||||
|
pub supports_fifo: bool,
|
||||||
|
/// Supports search functionality
|
||||||
|
pub supports_search: bool,
|
||||||
|
/// Supports user favorites
|
||||||
|
pub supports_favorites: bool,
|
||||||
|
/// Supports user playlists
|
||||||
|
pub supports_playlists: bool,
|
||||||
|
/// Supports user-created content
|
||||||
|
pub supports_user_content: bool,
|
||||||
|
/// Supports high-resolution audio
|
||||||
|
pub supports_high_res_audio: bool,
|
||||||
|
/// Maximum sample rate supported (Hz)
|
||||||
|
pub max_sample_rate: Option<u32>,
|
||||||
|
/// Supports multiple audio formats
|
||||||
|
pub supports_multiple_formats: bool,
|
||||||
|
/// Supports advanced search with filters
|
||||||
|
pub supports_advanced_search: bool,
|
||||||
|
/// Supports pagination in browse operations
|
||||||
|
pub supports_pagination: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio format information
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AudioFormat {
|
||||||
|
/// Format identifier (e.g., "flac-24-96", "mp3-320")
|
||||||
|
pub format_id: String,
|
||||||
|
/// MIME type (e.g., "audio/flac", "audio/mpeg")
|
||||||
|
pub mime_type: String,
|
||||||
|
/// Sample rate in Hz (e.g., 44100, 96000)
|
||||||
|
pub sample_rate: Option<u32>,
|
||||||
|
/// Bit depth (e.g., 16, 24)
|
||||||
|
pub bit_depth: Option<u8>,
|
||||||
|
/// Bitrate in kbps (for lossy formats)
|
||||||
|
pub bitrate: Option<u32>,
|
||||||
|
/// Number of audio channels (e.g., 2 for stereo)
|
||||||
|
pub channels: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioFormat {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
format_id: "default".to_string(),
|
||||||
|
mime_type: "audio/flac".to_string(),
|
||||||
|
sample_rate: Some(44100),
|
||||||
|
bit_depth: Some(16),
|
||||||
|
bitrate: None,
|
||||||
|
channels: Some(2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache status for an item
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum CacheStatus {
|
||||||
|
/// Item is not cached
|
||||||
|
NotCached,
|
||||||
|
/// Item is currently being cached
|
||||||
|
Caching { progress: f32 },
|
||||||
|
/// Item is fully cached
|
||||||
|
Cached { size_bytes: u64 },
|
||||||
|
/// Caching failed
|
||||||
|
Failed { error: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search filters for advanced search
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SearchFilters {
|
||||||
|
/// Filter by artist name
|
||||||
|
pub artist: Option<String>,
|
||||||
|
/// Filter by album name
|
||||||
|
pub album: Option<String>,
|
||||||
|
/// Filter by genre
|
||||||
|
pub genre: Option<String>,
|
||||||
|
/// Minimum year
|
||||||
|
pub year_min: Option<u32>,
|
||||||
|
/// Maximum year
|
||||||
|
pub year_max: Option<u32>,
|
||||||
|
/// Maximum number of results
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source statistics
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SourceStatistics {
|
||||||
|
/// Total number of items in the source
|
||||||
|
pub total_items: Option<usize>,
|
||||||
|
/// Total number of containers in the source
|
||||||
|
pub total_containers: Option<usize>,
|
||||||
|
/// Number of cached items
|
||||||
|
pub cached_items: Option<usize>,
|
||||||
|
/// Total cache size in bytes
|
||||||
|
pub cache_size_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a browse operation
|
/// Result of a browse operation
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum BrowseResult {
|
pub enum BrowseResult {
|
||||||
@@ -447,6 +554,331 @@ pub trait MusicSource: Debug + Send + Sync {
|
|||||||
let _ = query;
|
let _ = query;
|
||||||
Err(MusicSourceError::SearchNotSupported)
|
Err(MusicSourceError::SearchNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============= Extended Features =============
|
||||||
|
|
||||||
|
/// Returns the capabilities of this music source
|
||||||
|
///
|
||||||
|
/// This allows clients to discover what features are supported without
|
||||||
|
/// having to call methods and handle errors.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A `SourceCapabilities` struct describing supported features.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let caps = source.capabilities();
|
||||||
|
/// if caps.supports_search {
|
||||||
|
/// let results = source.search("query").await?;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
fn capabilities(&self) -> SourceCapabilities {
|
||||||
|
SourceCapabilities {
|
||||||
|
supports_fifo: self.supports_fifo(),
|
||||||
|
supports_search: false,
|
||||||
|
supports_favorites: false,
|
||||||
|
supports_playlists: false,
|
||||||
|
supports_user_content: false,
|
||||||
|
supports_high_res_audio: false,
|
||||||
|
max_sample_rate: None,
|
||||||
|
supports_multiple_formats: false,
|
||||||
|
supports_advanced_search: false,
|
||||||
|
supports_pagination: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get available audio formats for a specific track
|
||||||
|
///
|
||||||
|
/// Some sources (like Qobuz) offer multiple quality levels and formats.
|
||||||
|
/// This method returns all available formats for a given track.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `object_id` - The ID of the track
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A vector of available audio formats, or a single default format.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let formats = source.get_available_formats("track-123").await?;
|
||||||
|
/// for format in formats {
|
||||||
|
/// println!("{}: {} Hz, {} bit", format.format_id,
|
||||||
|
/// format.sample_rate.unwrap_or(0),
|
||||||
|
/// format.bit_depth.unwrap_or(0));
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
async fn get_available_formats(&self, object_id: &str) -> Result<Vec<AudioFormat>> {
|
||||||
|
let _ = object_id;
|
||||||
|
Ok(vec![AudioFormat::default()])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the cache status for a specific item
|
||||||
|
///
|
||||||
|
/// Returns information about whether an item is cached, being cached,
|
||||||
|
/// or not cached at all.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `object_id` - The ID of the item to check
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The current cache status of the item.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let status = source.get_cache_status("track-123").await?;
|
||||||
|
/// match status {
|
||||||
|
/// CacheStatus::Cached { size_bytes } => {
|
||||||
|
/// println!("Cached: {} bytes", size_bytes);
|
||||||
|
/// }
|
||||||
|
/// CacheStatus::NotCached => {
|
||||||
|
/// println!("Not cached");
|
||||||
|
/// }
|
||||||
|
/// _ => {}
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
async fn get_cache_status(&self, object_id: &str) -> Result<CacheStatus> {
|
||||||
|
let _ = object_id;
|
||||||
|
Ok(CacheStatus::NotCached)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request caching of a specific item
|
||||||
|
///
|
||||||
|
/// Initiates asynchronous caching of an item (audio and/or cover art).
|
||||||
|
/// The operation happens in the background.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `object_id` - The ID of the item to cache
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The initial cache status after the request.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MusicSourceError::NotSupported` if caching is not available.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let status = source.cache_item("track-123").await?;
|
||||||
|
/// ```
|
||||||
|
async fn cache_item(&self, object_id: &str) -> Result<CacheStatus> {
|
||||||
|
let _ = object_id;
|
||||||
|
Err(MusicSourceError::NotSupported("Caching not supported".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an item to favorites
|
||||||
|
///
|
||||||
|
/// Marks an item (track, album, artist, etc.) as a favorite.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `object_id` - The ID of the item to favorite
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MusicSourceError::NotSupported` if favorites are not available.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// source.add_favorite("album-123").await?;
|
||||||
|
/// ```
|
||||||
|
async fn add_favorite(&self, object_id: &str) -> Result<()> {
|
||||||
|
let _ = object_id;
|
||||||
|
Err(MusicSourceError::NotSupported("Favorites not supported".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an item from favorites
|
||||||
|
///
|
||||||
|
/// Unmarks an item as a favorite.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `object_id` - The ID of the item to unfavorite
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MusicSourceError::NotSupported` if favorites are not available.
|
||||||
|
async fn remove_favorite(&self, object_id: &str) -> Result<()> {
|
||||||
|
let _ = object_id;
|
||||||
|
Err(MusicSourceError::NotSupported("Favorites not supported".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an item is in favorites
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `object_id` - The ID of the item to check
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// `true` if the item is favorited, `false` otherwise.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MusicSourceError::NotSupported` if favorites are not available.
|
||||||
|
async fn is_favorite(&self, object_id: &str) -> Result<bool> {
|
||||||
|
let _ = object_id;
|
||||||
|
Err(MusicSourceError::NotSupported("Favorites not supported".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user playlists
|
||||||
|
///
|
||||||
|
/// Returns all playlists created or followed by the user.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A vector of Container objects representing playlists.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MusicSourceError::NotSupported` if playlists are not available.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let playlists = source.get_user_playlists().await?;
|
||||||
|
/// for playlist in playlists {
|
||||||
|
/// println!("Playlist: {}", playlist.title);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
async fn get_user_playlists(&self) -> Result<Vec<Container>> {
|
||||||
|
Err(MusicSourceError::NotSupported("Playlists not supported".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an item to a playlist
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `playlist_id` - The ID of the playlist
|
||||||
|
/// * `item_id` - The ID of the item to add
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MusicSourceError::NotSupported` if playlists are not available.
|
||||||
|
async fn add_to_playlist(&self, playlist_id: &str, item_id: &str) -> Result<()> {
|
||||||
|
let _ = (playlist_id, item_id);
|
||||||
|
Err(MusicSourceError::NotSupported("Playlists not supported".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total item count for a container
|
||||||
|
///
|
||||||
|
/// This is more efficient than browsing and counting for large collections.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `object_id` - The ID of the container
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The total number of items in the container.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let count = source.get_item_count("album-123").await?;
|
||||||
|
/// println!("Album has {} tracks", count);
|
||||||
|
/// ```
|
||||||
|
async fn get_item_count(&self, object_id: &str) -> Result<usize> {
|
||||||
|
// Default implementation: browse and count
|
||||||
|
let result = self.browse(object_id).await?;
|
||||||
|
Ok(result.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browse with pagination support
|
||||||
|
///
|
||||||
|
/// More efficient than `browse()` for large containers.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `object_id` - The ID of the container to browse
|
||||||
|
/// * `offset` - Starting index (0-based)
|
||||||
|
/// * `limit` - Maximum number of items to return
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A `BrowseResult` containing the requested subset of items.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// // Get items 10-19
|
||||||
|
/// let result = source.browse_paginated("album-123", 10, 10).await?;
|
||||||
|
/// ```
|
||||||
|
async fn browse_paginated(
|
||||||
|
&self,
|
||||||
|
object_id: &str,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<BrowseResult> {
|
||||||
|
// Default implementation: browse all then slice (inefficient)
|
||||||
|
let _ = (offset, limit);
|
||||||
|
self.browse(object_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advanced search with filters
|
||||||
|
///
|
||||||
|
/// Provides more fine-grained search control than basic `search()`.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `query` - Search query string
|
||||||
|
/// * `filters` - Additional search filters
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A `BrowseResult` containing matching items/containers.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MusicSourceError::SearchNotSupported` if not implemented.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let filters = SearchFilters {
|
||||||
|
/// artist: Some("Pink Floyd".to_string()),
|
||||||
|
/// year_min: Some(1970),
|
||||||
|
/// year_max: Some(1980),
|
||||||
|
/// ..Default::default()
|
||||||
|
/// };
|
||||||
|
/// let results = source.search_advanced("Wall", filters).await?;
|
||||||
|
/// ```
|
||||||
|
async fn search_advanced(&self, query: &str, filters: SearchFilters) -> Result<BrowseResult> {
|
||||||
|
// Default: ignore filters and call basic search
|
||||||
|
let _ = filters;
|
||||||
|
self.search(query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get source statistics
|
||||||
|
///
|
||||||
|
/// Returns information about the source such as total items, cache usage, etc.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A `SourceStatistics` struct with available statistics.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let stats = source.statistics().await?;
|
||||||
|
/// if let Some(total) = stats.total_items {
|
||||||
|
/// println!("Total tracks: {}", total);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
async fn statistics(&self) -> Result<SourceStatistics> {
|
||||||
|
Ok(SourceStatistics::default())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
|
|||||||
Reference in New Issue
Block a user