diff --git a/crates/qbz-models/Cargo.toml b/crates/qbz-models/Cargo.toml index eaf21d16b..75c2113d1 100644 --- a/crates/qbz-models/Cargo.toml +++ b/crates/qbz-models/Cargo.toml @@ -13,6 +13,9 @@ serde_json = { workspace = true } # Async async-trait = { workspace = true } +# Logging +log = { workspace = true } + # Error handling thiserror = { workspace = true } diff --git a/crates/qbz-models/src/error.rs b/crates/qbz-models/src/error.rs new file mode 100644 index 000000000..281971913 --- /dev/null +++ b/crates/qbz-models/src/error.rs @@ -0,0 +1,111 @@ +//! Common error types for QBZ +//! +//! This module defines error types that are shared across crates. + +use thiserror::Error; + +/// Top-level error type for QBZ operations +#[derive(Error, Debug)] +pub enum QbzError { + /// Authentication failed + #[error("Authentication failed: {0}")] + AuthError(String), + + /// API request failed + #[error("API error: {0}")] + ApiError(String), + + /// Network error + #[error("Network error: {0}")] + NetworkError(String), + + /// Playback error + #[error("Playback error: {0}")] + PlaybackError(String), + + /// Audio device error + #[error("Audio device error: {0}")] + AudioDeviceError(String), + + /// Queue error + #[error("Queue error: {0}")] + QueueError(String), + + /// Configuration error + #[error("Configuration error: {0}")] + ConfigError(String), + + /// Storage/database error + #[error("Storage error: {0}")] + StorageError(String), + + /// Track not found + #[error("Track not found: {0}")] + TrackNotFound(u64), + + /// Album not found + #[error("Album not found: {0}")] + AlbumNotFound(String), + + /// Artist not found + #[error("Artist not found: {0}")] + ArtistNotFound(u64), + + /// Playlist not found + #[error("Playlist not found: {0}")] + PlaylistNotFound(u64), + + /// Not authenticated (login required) + #[error("Not authenticated")] + NotAuthenticated, + + /// Stream URL unavailable (geo-restriction or rights issue) + #[error("Stream unavailable: {0}")] + StreamUnavailable(String), + + /// Operation cancelled + #[error("Operation cancelled")] + Cancelled, + + /// Generic/unknown error + #[error("{0}")] + Other(String), +} + +impl QbzError { + /// Check if this error is recoverable (user can retry or fix) + pub fn is_recoverable(&self) -> bool { + matches!( + self, + QbzError::NetworkError(_) + | QbzError::NotAuthenticated + | QbzError::StreamUnavailable(_) + | QbzError::Cancelled + ) + } + + /// Get an error code for frontend handling + pub fn code(&self) -> &'static str { + match self { + QbzError::AuthError(_) => "AUTH_ERROR", + QbzError::ApiError(_) => "API_ERROR", + QbzError::NetworkError(_) => "NETWORK_ERROR", + QbzError::PlaybackError(_) => "PLAYBACK_ERROR", + QbzError::AudioDeviceError(_) => "AUDIO_DEVICE_ERROR", + QbzError::QueueError(_) => "QUEUE_ERROR", + QbzError::ConfigError(_) => "CONFIG_ERROR", + QbzError::StorageError(_) => "STORAGE_ERROR", + QbzError::TrackNotFound(_) => "TRACK_NOT_FOUND", + QbzError::AlbumNotFound(_) => "ALBUM_NOT_FOUND", + QbzError::ArtistNotFound(_) => "ARTIST_NOT_FOUND", + QbzError::PlaylistNotFound(_) => "PLAYLIST_NOT_FOUND", + QbzError::NotAuthenticated => "NOT_AUTHENTICATED", + QbzError::StreamUnavailable(_) => "STREAM_UNAVAILABLE", + QbzError::Cancelled => "CANCELLED", + QbzError::Other(_) => "UNKNOWN_ERROR", + } + } +} + +/// Result type alias using QbzError +pub type QbzResult = Result; diff --git a/crates/qbz-models/src/events.rs b/crates/qbz-models/src/events.rs new file mode 100644 index 000000000..bb425dcf0 --- /dev/null +++ b/crates/qbz-models/src/events.rs @@ -0,0 +1,178 @@ +//! Core events for frontend communication +//! +//! This module defines all events that flow from the core to frontends. +//! Frontends implement FrontendAdapter to receive these events. + +use serde::Serialize; + +use crate::playback::{PlaybackState, PlaybackStatus, QueueState, QueueTrack}; +use crate::types::{Playlist, SearchResults, UserSession}; + +/// All events emitted by QBZ core to frontends +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", content = "data")] +pub enum CoreEvent { + // ============ Playback Events ============ + /// Track started playing + TrackStarted { + track: QueueTrack, + position_secs: u64, + }, + + /// Playback state changed (play/pause/stop) + PlaybackStateChanged { + state: PlaybackState, + }, + + /// Playback position updated (periodic, e.g., every second) + PositionUpdated { + position_secs: u64, + duration_secs: u64, + }, + + /// Track finished playing naturally + TrackEnded { + track_id: u64, + }, + + /// Volume changed + VolumeChanged { + volume: f32, + }, + + /// Full playback status update + PlaybackStatusUpdated { + status: PlaybackStatus, + }, + + // ============ Queue Events ============ + /// Queue state changed (tracks added/removed/reordered) + QueueUpdated { + state: QueueState, + }, + + /// Shuffle mode changed + ShuffleChanged { + enabled: bool, + }, + + /// Repeat mode changed + RepeatModeChanged { + mode: crate::playback::RepeatMode, + }, + + // ============ Authentication Events ============ + /// User logged in successfully + LoggedIn { + session: UserSession, + }, + + /// User logged out + LoggedOut, + + /// Session expired or became invalid + SessionExpired, + + // ============ Library Events ============ + /// Favorites updated + FavoritesUpdated { + /// Type of favorite that changed: "album", "track", or "artist" + favorite_type: String, + }, + + /// Playlist created + PlaylistCreated { + playlist: Playlist, + }, + + /// Playlist updated (tracks added/removed) + PlaylistUpdated { + playlist_id: u64, + }, + + /// Playlist deleted + PlaylistDeleted { + playlist_id: u64, + }, + + // ============ Loading/Progress Events ============ + /// Loading started for an operation + LoadingStarted { + operation: String, + }, + + /// Loading completed + LoadingCompleted { + operation: String, + }, + + /// Download progress for offline content + DownloadProgress { + track_id: u64, + progress_percent: u8, + }, + + /// Download completed + DownloadCompleted { + track_id: u64, + }, + + // ============ Error Events ============ + /// An error occurred + Error { + code: String, + message: String, + /// Whether error is recoverable + recoverable: bool, + }, + + /// Playback error (track couldn't play) + PlaybackError { + track_id: u64, + message: String, + }, + + /// Network error + NetworkError { + message: String, + }, + + // ============ Audio System Events ============ + /// Audio device changed + AudioDeviceChanged { + device_name: String, + }, + + /// Audio backend changed + AudioBackendChanged { + backend: crate::playback::AudioBackendType, + }, + + /// Audio system diagnostic info + AudioDiagnostic { + message: String, + }, + + // ============ Search Events ============ + /// Search results received + SearchResultsReceived { + query: String, + results: SearchResults, + }, + + // ============ Navigation Hints ============ + /// Suggest navigation to album + NavigateToAlbum { + album_id: String, + }, + + /// Suggest navigation to artist + NavigateToArtist { + artist_id: u64, + }, + + /// Suggest navigation to playlist + NavigateToPlaylist { + playlist_id: u64, + }, +} diff --git a/crates/qbz-models/src/lib.rs b/crates/qbz-models/src/lib.rs index e35ae2bd1..b3060565d 100644 --- a/crates/qbz-models/src/lib.rs +++ b/crates/qbz-models/src/lib.rs @@ -4,12 +4,49 @@ //! - Type definitions (Track, Album, Artist, etc.) //! - Event definitions (CoreEvent enum) //! - Trait definitions (FrontendAdapter) -//! - Configuration types +//! - Playback types (QueueTrack, PlaybackState) +//! - Error types +//! +//! # Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────┐ +//! │ qbz-models (Tier 0) │ +//! │ Types, Events, Traits - No dependencies on other qbz-* │ +//! └─────────────────────────────────────────────────────────────┘ +//! ↑ +//! ┌────────────────────────┼────────────────────────┐ +//! │ │ │ +//! ┌───┴───┐ ┌─────┴─────┐ ┌─────┴─────┐ +//! │qbz-audio│ │qbz-qobuz │ │qbz-player │ +//! │ Tier 1 │ │ Tier 1 │ │ Tier 2 │ +//! └────────┘ └───────────┘ └───────────┘ +//! ``` +//! +//! # Usage +//! +//! ```rust +//! use qbz_models::{Track, Album, CoreEvent, FrontendAdapter}; +//! ``` -// TODO: Phase 1 - Implement these modules -// pub mod types; -// pub mod playback; -// pub mod events; -// pub mod traits; -// pub mod config; -// pub mod error; +pub mod error; +pub mod events; +pub mod playback; +pub mod traits; +pub mod types; + +// Re-export commonly used types at crate root +pub use error::{QbzError, QbzResult}; +pub use events::CoreEvent; +pub use playback::{ + AudioBackendType, AudioDevice, PlaybackState, PlaybackStatus, QueueState, QueueTrack, + RepeatMode, +}; +pub use traits::{FrontendAdapter, LoggingAdapter, NoOpAdapter}; +pub use types::{ + Album, AlbumSummary, Artist, ArtistAlbums, ArtistBiography, Favorites, Genre, GenreInfo, + GenreListContainer, GenreListResponse, ImageSet, Label, LabelDetail, Playlist, + PlaylistDuplicateResult, PlaylistGenre, PlaylistOwner, PlaylistWithTrackIds, Quality, + SearchResults, SearchResultsPage, StreamRestriction, StreamUrl, Track, TracksContainer, + UserSession, +}; diff --git a/crates/qbz-models/src/playback.rs b/crates/qbz-models/src/playback.rs new file mode 100644 index 000000000..a151895f0 --- /dev/null +++ b/crates/qbz-models/src/playback.rs @@ -0,0 +1,143 @@ +//! Playback-related types for QBZ +//! +//! This module contains types related to audio playback: +//! - Queue track representation +//! - Repeat mode +//! - Queue state snapshots +//! - Playback state + +use serde::{Deserialize, Serialize}; + +// ============ Queue Types ============ + +/// Track info stored in the queue +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueueTrack { + pub id: u64, + pub title: String, + pub artist: String, + pub album: String, + pub duration_secs: u64, + pub artwork_url: Option, + #[serde(default)] + pub hires: bool, + pub bit_depth: Option, + pub sample_rate: Option, + /// Whether this is a local library track (not from streaming service) + #[serde(default)] + pub is_local: bool, + /// Album ID for navigation + pub album_id: Option, + /// Artist ID for navigation + pub artist_id: Option, + /// Whether the track is streamable (false = removed/unavailable) + #[serde(default = "default_streamable")] + pub streamable: bool, + /// Source identifier (e.g., "qobuz", "local", "plex") + #[serde(default)] + pub source: Option, +} + +fn default_streamable() -> bool { + true +} + +/// Repeat mode options +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RepeatMode { + Off, + All, + One, +} + +impl Default for RepeatMode { + fn default() -> Self { + Self::Off + } +} + +/// Queue state snapshot for frontend +#[derive(Debug, Clone, Serialize)] +pub struct QueueState { + pub current_track: Option, + pub current_index: Option, + pub upcoming: Vec, + pub history: Vec, + pub shuffle: bool, + pub repeat: RepeatMode, + pub total_tracks: usize, +} + +// ============ Playback State ============ + +/// Current playback state +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PlaybackState { + /// No track loaded + Stopped, + /// Track loaded and playing + Playing, + /// Track loaded but paused + Paused, + /// Loading/buffering track + Loading, +} + +impl Default for PlaybackState { + fn default() -> Self { + Self::Stopped + } +} + +/// Detailed playback status with position and duration +#[derive(Debug, Clone, Serialize)] +pub struct PlaybackStatus { + pub state: PlaybackState, + pub track_id: Option, + pub position_secs: u64, + pub duration_secs: u64, + pub volume: f32, + /// Sample rate of currently playing track (Hz) + pub sample_rate: Option, + /// Bit depth of currently playing track + pub bit_depth: Option, +} + +impl Default for PlaybackStatus { + fn default() -> Self { + Self { + state: PlaybackState::Stopped, + track_id: None, + position_secs: 0, + duration_secs: 0, + volume: 1.0, + sample_rate: None, + bit_depth: None, + } + } +} + +// ============ Audio Backend Types ============ + +/// Audio backend type selection +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AudioBackendType { + /// PipeWire/PulseAudio via CPAL (default, most compatible) + PipeWire, + /// Direct ALSA for bit-perfect playback + AlsaDirect, +} + +impl Default for AudioBackendType { + fn default() -> Self { + Self::PipeWire + } +} + +/// Audio device information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AudioDevice { + pub id: String, + pub name: String, + pub is_default: bool, +} diff --git a/crates/qbz-models/src/traits.rs b/crates/qbz-models/src/traits.rs new file mode 100644 index 000000000..d79aebc04 --- /dev/null +++ b/crates/qbz-models/src/traits.rs @@ -0,0 +1,92 @@ +//! Core traits for QBZ +//! +//! This module defines the key traits that enable multiple frontends: +//! - FrontendAdapter: Receives events from the core +//! - Additional interface traits as needed + +use async_trait::async_trait; + +use crate::events::CoreEvent; + +/// Adapter trait that frontends implement to receive events from QBZ core. +/// +/// This is the primary integration point for any frontend (Tauri, Slint, Iced, etc.). +/// The core calls `on_event` whenever something happens that the frontend should know about. +/// +/// # Example +/// +/// ```rust,ignore +/// use qbz_models::{FrontendAdapter, CoreEvent}; +/// +/// struct TauriAdapter { +/// app_handle: tauri::AppHandle, +/// } +/// +/// #[async_trait] +/// impl FrontendAdapter for TauriAdapter { +/// async fn on_event(&self, event: CoreEvent) { +/// // Emit event to Tauri webview +/// self.app_handle.emit("core-event", event).ok(); +/// } +/// } +/// ``` +#[async_trait] +pub trait FrontendAdapter: Send + Sync { + /// Called when the core emits an event. + /// + /// Implementations should handle this efficiently (non-blocking) since + /// events may be emitted frequently (e.g., position updates every second). + async fn on_event(&self, event: CoreEvent); + + /// Optional: Called when the core is ready for interaction. + /// Default implementation does nothing. + async fn on_ready(&self) {} + + /// Optional: Called when the core is shutting down. + /// Default implementation does nothing. + async fn on_shutdown(&self) {} +} + +/// A no-op adapter for testing or headless operation +pub struct NoOpAdapter; + +#[async_trait] +impl FrontendAdapter for NoOpAdapter { + async fn on_event(&self, _event: CoreEvent) { + // Intentionally empty - events are discarded + } +} + +/// An adapter that logs all events (useful for debugging) +pub struct LoggingAdapter { + prefix: String, +} + +impl LoggingAdapter { + pub fn new(prefix: impl Into) -> Self { + Self { + prefix: prefix.into(), + } + } +} + +impl Default for LoggingAdapter { + fn default() -> Self { + Self::new("CoreEvent") + } +} + +#[async_trait] +impl FrontendAdapter for LoggingAdapter { + async fn on_event(&self, event: CoreEvent) { + log::debug!("{}: {:?}", self.prefix, event); + } + + async fn on_ready(&self) { + log::info!("{}: Core is ready", self.prefix); + } + + async fn on_shutdown(&self) { + log::info!("{}: Core is shutting down", self.prefix); + } +} diff --git a/crates/qbz-models/src/types.rs b/crates/qbz-models/src/types.rs new file mode 100644 index 000000000..59f2b47a2 --- /dev/null +++ b/crates/qbz-models/src/types.rs @@ -0,0 +1,412 @@ +//! Core API types for QBZ +//! +//! This module contains all shared data types used across the application: +//! - Media types: Track, Album, Artist, Playlist +//! - Quality/streaming types +//! - Search and favorites types +//! - Image and metadata types + +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +// ============ Quality Types ============ + +/// Audio quality format IDs (matches Qobuz API format IDs) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[repr(u32)] +pub enum Quality { + Mp3 = 5, + Lossless = 6, // 16-bit/44.1kHz (CD Quality) + HiRes = 7, // 24-bit/≤96kHz + UltraHiRes = 27, // 24-bit/>96kHz +} + +impl Quality { + pub fn from_id(id: u32) -> Option { + match id { + 5 => Some(Quality::Mp3), + 6 => Some(Quality::Lossless), + 7 => Some(Quality::HiRes), + 27 => Some(Quality::UltraHiRes), + _ => None, + } + } + + pub fn id(&self) -> u32 { + *self as u32 + } + + pub fn label(&self) -> &'static str { + match self { + Quality::Mp3 => "MP3 320kbps", + Quality::Lossless => "FLAC 16-bit/44.1kHz", + Quality::HiRes => "FLAC 24-bit/≤96kHz", + Quality::UltraHiRes => "FLAC 24-bit/>96kHz", + } + } + + /// Quality levels in descending order for fallback + pub fn fallback_order() -> &'static [Quality] { + &[ + Quality::UltraHiRes, + Quality::HiRes, + Quality::Lossless, + Quality::Mp3, + ] + } +} + +impl Default for Quality { + fn default() -> Self { + Quality::Lossless + } +} + +// ============ User Session ============ + +/// User credentials and session info +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserSession { + pub user_auth_token: String, + pub user_id: u64, + pub email: String, + pub display_name: String, + pub subscription_label: String, + #[serde(default)] + pub subscription_valid_until: Option, +} + +// ============ Stream Types ============ + +/// Stream URL response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamUrl { + pub url: String, + pub format_id: u32, + pub mime_type: String, + pub sampling_rate: f64, + pub bit_depth: Option, + pub track_id: u64, + pub restrictions: Vec, +} + +impl StreamUrl { + /// Check if the stream has restrictions that prevent playback + pub fn has_restrictions(&self) -> bool { + self.restrictions.iter().any(|r| { + r.code == "FormatRestrictedByFormatAvailability" + || r.code == "SampleRestrictedByRightHolders" + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamRestriction { + pub code: String, +} + +// ============ Image Types ============ + +/// Image set with multiple resolutions +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ImageSet { + pub small: Option, + pub thumbnail: Option, + pub large: Option, + pub extralarge: Option, + pub mega: Option, + pub back: Option, +} + +impl ImageSet { + pub fn best(&self) -> Option<&String> { + self.mega + .as_ref() + .or(self.extralarge.as_ref()) + .or(self.large.as_ref()) + .or(self.thumbnail.as_ref()) + .or(self.small.as_ref()) + } +} + +// ============ Core Media Types ============ + +/// Track model +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Track { + #[serde(default)] + pub id: u64, + #[serde(default)] + pub title: String, + pub isrc: Option, + #[serde(default)] + pub duration: u32, + #[serde(default)] + pub track_number: u32, + pub media_number: Option, + pub performer: Option, + pub album: Option, + #[serde(default)] + pub hires: bool, + #[serde(default)] + pub hires_streamable: bool, + pub maximum_sampling_rate: Option, + pub maximum_bit_depth: Option, + #[serde(default)] + pub streamable: bool, + #[serde(default)] + pub parental_warning: bool, + /// Playlist-specific: ID within the playlist (for removal) + pub playlist_track_id: Option, + /// Performers/credits string (format: "Name, Role - Name, Role") + pub performers: Option, + /// Composer information + pub composer: Option, + /// Copyright information + pub copyright: Option, +} + +/// Album summary (embedded in track responses) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlbumSummary { + #[serde(default)] + pub id: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub image: ImageSet, + /// Label (if returned in track response) + pub label: Option