diff --git a/Cargo.lock b/Cargo.lock index 2a1deb79..ffe5d72f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,7 +163,7 @@ dependencies = [ "crc32fast", "futures-lite", "pin-project", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", ] @@ -481,7 +481,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1608,6 +1608,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "id3" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1" +dependencies = [ + "bitflags", + "byteorder", + "flate2", +] + [[package]] name = "idna" version = "1.1.0" @@ -2045,6 +2056,15 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mp3-duration" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348bdc7300502f0801e5b57c448815713cd843b744ef9bda252a2698fdf90a0f" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "multer" version = "3.1.0" @@ -2179,7 +2199,7 @@ dependencies = [ "postcard", "rustc-hash", "serde", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2192,7 +2212,7 @@ dependencies = [ "owo-colors", "oxc-miette-derive", "textwrap", - "thiserror", + "thiserror 2.0.18", "unicode-segmentation", "unicode-width", ] @@ -2547,6 +2567,7 @@ dependencies = [ "http-body", "http-body-util", "http-range-header", + "id3", "image", "infer", "jsonwebtoken", @@ -2557,6 +2578,7 @@ dependencies = [ "mime_guess", "mockall", "moka", + "mp3-duration", "oxc_allocator", "oxc_codegen", "oxc_minifier", @@ -2573,7 +2595,7 @@ dependencies = [ "socket2", "sqlx", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", @@ -3044,7 +3066,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -3065,7 +3087,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -3622,7 +3644,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -3724,7 +3746,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -3809,7 +3831,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -3848,7 +3870,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -3874,7 +3896,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -3993,13 +4015,33 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d23d8481..060b7d0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ async-stream = "0.3.6" mime_guess = "2.0.5" uuid = { version = "1.21.0", features = ["v4", "serde"] } thiserror = "2.0.18" + mockall = { version = "0.14.0", optional = true } sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-rustls", "chrono", "uuid", "json", "migrate"] } jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } @@ -37,6 +38,8 @@ dotenvy = "0.15.7" moka = { version = "0.12", features = ["future", "sync"] } http-range-header = "0.4" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +id3 = "1.14" +mp3-duration = "0.1" kamadak-exif = "0.5" md-5 = "0.10" sha2 = "0.10.9" diff --git a/migrations/20260308000000_audio_schema.sql b/migrations/20260308000000_audio_schema.sql new file mode 100644 index 00000000..965d325e --- /dev/null +++ b/migrations/20260308000000_audio_schema.sql @@ -0,0 +1,82 @@ +-- ============================================================ +-- Audio Schema for Music Library +-- Playlists and audio metadata storage +-- ============================================================ + +-- Audio schema for music library +CREATE SCHEMA IF NOT EXISTS audio; + +-- Playlists +CREATE TABLE IF NOT EXISTS audio.playlists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + is_public BOOLEAN NOT NULL DEFAULT FALSE, + cover_file_id UUID REFERENCES storage.files(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_playlists_owner_id ON audio.playlists(owner_id); +CREATE INDEX IF NOT EXISTS idx_playlists_is_public ON audio.playlists(is_public) WHERE is_public = TRUE; + +COMMENT ON TABLE audio.playlists IS 'User playlists for organizing music tracks'; + +-- Playlist items (tracks in a playlist) +CREATE TABLE IF NOT EXISTS audio.playlist_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + playlist_id UUID NOT NULL REFERENCES audio.playlists(id) ON DELETE CASCADE, + file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + added_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(playlist_id, file_id) +); + +CREATE INDEX IF NOT EXISTS idx_playlist_items_playlist_id ON audio.playlist_items(playlist_id); +CREATE INDEX IF NOT EXISTS idx_playlist_items_file_id ON audio.playlist_items(file_id); +CREATE INDEX IF NOT EXISTS idx_playlist_items_position ON audio.playlist_items(playlist_id, position); + +COMMENT ON TABLE audio.playlist_items IS 'Tracks belonging to playlists'; + +-- Audio metadata for files (extracted from audio files) +CREATE TABLE IF NOT EXISTS audio.file_metadata ( + file_id UUID PRIMARY KEY REFERENCES storage.files(id) ON DELETE CASCADE, + title TEXT, + artist TEXT, + album TEXT, + album_artist TEXT, + genre TEXT, + track_number INTEGER, + disc_number INTEGER, + year INTEGER, + duration_secs INTEGER NOT NULL DEFAULT 0, + bitrate INTEGER, + sample_rate INTEGER, + channels SMALLINT, + format TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_audio_metadata_artist ON audio.file_metadata(artist); +CREATE INDEX IF NOT EXISTS idx_audio_metadata_album ON audio.file_metadata(album); +CREATE INDEX IF NOT EXISTS idx_audio_metadata_genre ON audio.file_metadata(genre); +CREATE INDEX IF NOT EXISTS idx_audio_metadata_year ON audio.file_metadata(year); + +COMMENT ON TABLE audio.file_metadata IS 'Audio metadata extracted from music files (ID3, Vorbis tags)'; + +-- Playlist sharing (similar to calendar/address book sharing) +CREATE TABLE IF NOT EXISTS audio.playlist_shares ( + id SERIAL PRIMARY KEY, + playlist_id UUID NOT NULL REFERENCES audio.playlists(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + can_write BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(playlist_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_playlist_shares_playlist_id ON audio.playlist_shares(playlist_id); +CREATE INDEX IF NOT EXISTS idx_playlist_shares_user_id ON audio.playlist_shares(user_id); + +COMMENT ON TABLE audio.playlist_shares IS 'Playlist sharing permissions between users'; diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 9b1f8879..6efa5558 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -10,6 +10,7 @@ pub mod folder_dto; pub mod folder_listing_dto; pub mod i18n_dto; pub mod pagination; +pub mod playlist_dto; pub mod recent_dto; pub mod search_dto; pub mod settings_dto; diff --git a/src/application/dtos/playlist_dto.rs b/src/application/dtos/playlist_dto.rs new file mode 100644 index 00000000..5c738532 --- /dev/null +++ b/src/application/dtos/playlist_dto.rs @@ -0,0 +1,224 @@ +use crate::domain::entities::playlist::{AudioFileMetadata, Playlist, PlaylistItem}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PlaylistDto { + pub id: String, + pub name: String, + pub description: Option, + pub owner_id: String, + pub is_public: bool, + pub cover_file_id: Option, + pub track_count: Option, + pub total_duration_secs: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Default for PlaylistDto { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + description: None, + owner_id: String::new(), + is_public: false, + cover_file_id: None, + track_count: None, + total_duration_secs: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } +} + +impl From for PlaylistDto { + fn from(playlist: Playlist) -> Self { + Self { + id: playlist.id().to_string(), + name: playlist.name().to_string(), + description: playlist.description().map(|s| s.to_string()), + owner_id: playlist.owner_id().to_string(), + is_public: playlist.is_public(), + cover_file_id: playlist.cover_file_id().map(|s| s.to_string()), + track_count: None, + total_duration_secs: None, + created_at: *playlist.created_at(), + updated_at: *playlist.updated_at(), + } + } +} + +impl PlaylistDto { + pub fn with_track_info(mut self, track_count: i64, total_duration_secs: i32) -> Self { + self.track_count = Some(track_count); + self.total_duration_secs = Some(total_duration_secs); + self + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PlaylistItemDto { + pub id: String, + pub playlist_id: String, + pub file_id: String, + pub position: i32, + pub added_at: DateTime, + pub file_name: Option, + pub file_size: Option, + pub mime_type: Option, + pub title: Option, + pub artist: Option, + pub album: Option, + pub duration_secs: Option, +} + +impl Default for PlaylistItemDto { + fn default() -> Self { + Self { + id: String::new(), + playlist_id: String::new(), + file_id: String::new(), + position: 0, + added_at: Utc::now(), + file_name: None, + file_size: None, + mime_type: None, + title: None, + artist: None, + album: None, + duration_secs: None, + } + } +} + +impl From for PlaylistItemDto { + fn from(item: PlaylistItem) -> Self { + Self { + id: item.id().to_string(), + playlist_id: item.playlist_id().to_string(), + file_id: item.file_id().to_string(), + position: item.position(), + added_at: *item.added_at(), + file_name: None, + file_size: None, + mime_type: None, + title: None, + artist: None, + album: None, + duration_secs: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreatePlaylistDto { + pub name: String, + pub description: Option, + pub is_public: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdatePlaylistDto { + pub name: Option, + pub description: Option, + pub is_public: Option, + pub cover_file_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AddTracksDto { + pub file_ids: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ReorderTracksDto { + pub item_ids: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SharePlaylistDto { + pub user_id: String, + pub can_write: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PlaylistShareInfoDto { + pub user_id: String, + pub can_write: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AudioMetadataDto { + pub file_id: String, + pub title: Option, + pub artist: Option, + pub album: Option, + pub album_artist: Option, + pub genre: Option, + pub track_number: Option, + pub disc_number: Option, + pub year: Option, + pub duration_secs: i32, + pub bitrate: Option, + pub sample_rate: Option, + pub channels: Option, + pub format: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Default for AudioMetadataDto { + fn default() -> Self { + Self { + file_id: String::new(), + title: None, + artist: None, + album: None, + album_artist: None, + genre: None, + track_number: None, + disc_number: None, + year: None, + duration_secs: 0, + bitrate: None, + sample_rate: None, + channels: None, + format: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } +} + +impl From for AudioMetadataDto { + fn from(metadata: AudioFileMetadata) -> Self { + Self { + file_id: metadata.file_id().to_string(), + title: metadata.title().map(|s| s.to_string()), + artist: metadata.artist().map(|s| s.to_string()), + album: metadata.album().map(|s| s.to_string()), + album_artist: metadata.album_artist().map(|s| s.to_string()), + genre: metadata.genre().map(|s| s.to_string()), + track_number: metadata.track_number(), + disc_number: metadata.disc_number(), + year: metadata.year(), + duration_secs: metadata.duration_secs(), + bitrate: metadata.bitrate(), + sample_rate: metadata.sample_rate(), + channels: metadata.channels(), + format: metadata.format().map(|s| s.to_string()), + created_at: *metadata.created_at(), + updated_at: *metadata.updated_at(), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PlaylistQueryDto { + pub include_shared: Option, + pub include_public: Option, + pub limit: Option, + pub offset: Option, +} diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index 5e1fd915..a2fc2bf7 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -8,6 +8,7 @@ pub mod dedup_ports; pub mod favorites_ports; pub mod file_ports; pub mod inbound; +pub mod music_ports; pub mod outbound; pub mod recent_ports; pub mod share_ports; diff --git a/src/application/ports/music_ports.rs b/src/application/ports/music_ports.rs new file mode 100644 index 00000000..c20cd454 --- /dev/null +++ b/src/application/ports/music_ports.rs @@ -0,0 +1,158 @@ +use crate::application::dtos::playlist_dto::{ + AddTracksDto, AudioMetadataDto, CreatePlaylistDto, PlaylistDto, PlaylistItemDto, + PlaylistQueryDto, PlaylistShareInfoDto, ReorderTracksDto, SharePlaylistDto, UpdatePlaylistDto, +}; +use crate::common::errors::DomainError; +use uuid::Uuid; + +pub trait MusicUseCase: Send + Sync { + async fn create_playlist( + &self, + dto: CreatePlaylistDto, + user_id: Uuid, + ) -> Result; + + async fn update_playlist( + &self, + playlist_id: &str, + dto: UpdatePlaylistDto, + user_id: Uuid, + ) -> Result; + + async fn delete_playlist(&self, playlist_id: &str, user_id: Uuid) -> Result<(), DomainError>; + + async fn get_playlist( + &self, + playlist_id: &str, + user_id: Uuid, + ) -> Result; + + async fn list_playlists( + &self, + query: PlaylistQueryDto, + user_id: Uuid, + ) -> Result, DomainError>; + + async fn add_tracks( + &self, + playlist_id: &str, + dto: AddTracksDto, + user_id: Uuid, + ) -> Result, DomainError>; + + async fn remove_track( + &self, + playlist_id: &str, + file_id: &str, + user_id: Uuid, + ) -> Result<(), DomainError>; + + async fn reorder_tracks( + &self, + playlist_id: &str, + dto: ReorderTracksDto, + user_id: Uuid, + ) -> Result<(), DomainError>; + + async fn list_playlist_tracks( + &self, + playlist_id: &str, + user_id: Uuid, + ) -> Result, DomainError>; + + async fn share_playlist( + &self, + playlist_id: &str, + dto: SharePlaylistDto, + caller_id: Uuid, + ) -> Result<(), DomainError>; + + async fn remove_share( + &self, + playlist_id: &str, + target_user_id: &str, + caller_id: Uuid, + ) -> Result<(), DomainError>; + + async fn get_playlist_shares( + &self, + playlist_id: &str, + user_id: Uuid, + ) -> Result, DomainError>; + + async fn get_audio_metadata( + &self, + file_id: &str, + user_id: Uuid, + ) -> Result, DomainError>; +} + +pub trait MusicStoragePort: Send + Sync { + async fn create_playlist( + &self, + dto: CreatePlaylistDto, + user_id: Uuid, + ) -> Result; + + async fn update_playlist( + &self, + playlist_id: &str, + dto: UpdatePlaylistDto, + ) -> Result; + + async fn delete_playlist(&self, playlist_id: &str) -> Result<(), DomainError>; + + async fn get_playlist(&self, playlist_id: &str) -> Result, DomainError>; + + async fn list_playlists_by_owner( + &self, + owner_id: Uuid, + ) -> Result, DomainError>; + + async fn list_shared_with_user(&self, user_id: Uuid) -> Result, DomainError>; + + async fn list_public_playlists( + &self, + limit: i64, + offset: i64, + ) -> Result, DomainError>; + + async fn user_has_access(&self, playlist_id: &str, user_id: Uuid) -> Result; + + async fn user_can_write(&self, playlist_id: &str, user_id: Uuid) -> Result; + + async fn add_tracks( + &self, + playlist_id: &Uuid, + file_ids: &[Uuid], + ) -> Result, DomainError>; + + async fn remove_track(&self, playlist_id: &Uuid, file_id: &Uuid) -> Result<(), DomainError>; + + async fn reorder_tracks( + &self, + playlist_id: &Uuid, + item_ids: &[Uuid], + ) -> Result<(), DomainError>; + + async fn list_playlist_tracks( + &self, + playlist_id: &Uuid, + ) -> Result, DomainError>; + + async fn share_playlist( + &self, + playlist_id: &Uuid, + user_id: Uuid, + can_write: bool, + ) -> Result<(), DomainError>; + + async fn remove_share(&self, playlist_id: &Uuid, user_id: Uuid) -> Result<(), DomainError>; + + async fn get_shares(&self, playlist_id: &Uuid) -> Result, DomainError>; + + async fn get_audio_metadata( + &self, + file_id: &Uuid, + ) -> Result, DomainError>; +} diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 8db8ccc1..98991b93 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -12,6 +12,7 @@ pub mod file_upload_service; pub mod file_use_case_factory; pub mod folder_service; pub mod i18n_application_service; +pub mod music_service; pub mod nextcloud_file_id_service; pub mod nextcloud_login_flow_service; pub mod recent_service; diff --git a/src/application/services/music_service.rs b/src/application/services/music_service.rs new file mode 100644 index 00000000..78ce6757 --- /dev/null +++ b/src/application/services/music_service.rs @@ -0,0 +1,384 @@ +use std::sync::Arc; +use uuid::Uuid; + +use crate::application::dtos::playlist_dto::{ + AddTracksDto, AudioMetadataDto, CreatePlaylistDto, PlaylistDto, PlaylistItemDto, + PlaylistQueryDto, PlaylistShareInfoDto, ReorderTracksDto, SharePlaylistDto, UpdatePlaylistDto, +}; +use crate::application::ports::music_ports::{MusicStoragePort, MusicUseCase}; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::infrastructure::adapters::music_storage_adapter::MusicStorageAdapter; + +pub struct MusicService { + storage: Arc, +} + +impl MusicService { + pub fn new(storage: Arc) -> Self { + Self { storage } + } +} + +impl MusicUseCase for MusicService { + async fn create_playlist( + &self, + dto: CreatePlaylistDto, + user_id: Uuid, + ) -> Result { + self.storage.create_playlist(dto, user_id).await + } + + async fn update_playlist( + &self, + playlist_id: &str, + dto: UpdatePlaylistDto, + user_id: Uuid, + ) -> Result { + let has_access = self.storage.user_has_access(playlist_id, user_id).await?; + if !has_access { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "You don't have permission to update this playlist", + )); + } + let can_write = self.storage.user_can_write(playlist_id, user_id).await?; + if !can_write { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "You need write access to update this playlist", + )); + } + self.storage.update_playlist(playlist_id, dto).await + } + + async fn delete_playlist(&self, playlist_id: &str, user_id: Uuid) -> Result<(), DomainError> { + let playlist = self.storage.get_playlist(playlist_id).await?; + let playlist = match playlist { + Some(p) => p, + None => { + return Err(DomainError::new( + ErrorKind::NotFound, + "Playlist", + "Playlist not found", + )); + } + }; + if playlist.owner_id != user_id.to_string() { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "Only the owner can delete this playlist", + )); + } + self.storage.delete_playlist(playlist_id).await + } + + async fn get_playlist( + &self, + playlist_id: &str, + user_id: Uuid, + ) -> Result { + let has_access = self.storage.user_has_access(playlist_id, user_id).await?; + if !has_access { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "You don't have permission to view this playlist", + )); + } + let playlist = self.storage.get_playlist(playlist_id).await?; + match playlist { + Some(p) => Ok(p), + None => Err(DomainError::new( + ErrorKind::NotFound, + "Playlist", + "Playlist not found", + )), + } + } + + async fn list_playlists( + &self, + query: PlaylistQueryDto, + user_id: Uuid, + ) -> Result, DomainError> { + let include_shared = query.include_shared.unwrap_or(true); + let include_public = query.include_public.unwrap_or(false); + let limit = query.limit.unwrap_or(100); + let offset = query.offset.unwrap_or(0); + + let mut playlists = Vec::new(); + + let owned = self.storage.list_playlists_by_owner(user_id).await?; + playlists.extend(owned); + + if include_shared { + let shared = self.storage.list_shared_with_user(user_id).await?; + for s in shared { + if !playlists.iter().any(|p: &PlaylistDto| p.id == s.id) { + playlists.push(s); + } + } + } + + if include_public { + let public = self.storage.list_public_playlists(limit, offset).await?; + for p in public { + if !playlists.iter().any(|pl: &PlaylistDto| pl.id == p.id) { + playlists.push(p); + } + } + } + + Ok(playlists) + } + + async fn add_tracks( + &self, + playlist_id: &str, + dto: AddTracksDto, + user_id: Uuid, + ) -> Result, DomainError> { + let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + + let has_access = self.storage.user_has_access(playlist_id, user_id).await?; + if !has_access { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "You don't have permission to modify this playlist", + )); + } + let can_write = self.storage.user_can_write(playlist_id, user_id).await?; + if !can_write { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "You need write access to add tracks", + )); + } + + let file_ids: Result, _> = + dto.file_ids.iter().map(|id| Uuid::parse_str(id)).collect(); + let file_ids = file_ids.map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid file ID") + })?; + + self.storage.add_tracks(&playlist_uuid, &file_ids).await + } + + async fn remove_track( + &self, + playlist_id: &str, + file_id: &str, + user_id: Uuid, + ) -> Result<(), DomainError> { + let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + let file_uuid = Uuid::parse_str(file_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid file ID") + })?; + + let has_access = self.storage.user_has_access(playlist_id, user_id).await?; + if !has_access { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "You don't have permission to modify this playlist", + )); + } + let can_write = self.storage.user_can_write(playlist_id, user_id).await?; + if !can_write { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "You need write access to remove tracks", + )); + } + + self.storage.remove_track(&playlist_uuid, &file_uuid).await + } + + async fn reorder_tracks( + &self, + playlist_id: &str, + dto: ReorderTracksDto, + user_id: Uuid, + ) -> Result<(), DomainError> { + let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + + let has_access = self.storage.user_has_access(playlist_id, user_id).await?; + if !has_access { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "You don't have permission to modify this playlist", + )); + } + let can_write = self.storage.user_can_write(playlist_id, user_id).await?; + if !can_write { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "You need write access to reorder tracks", + )); + } + + let item_ids: Result, _> = + dto.item_ids.iter().map(|id| Uuid::parse_str(id)).collect(); + let item_ids = item_ids.map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid item ID") + })?; + + self.storage.reorder_tracks(&playlist_uuid, &item_ids).await + } + + async fn list_playlist_tracks( + &self, + playlist_id: &str, + user_id: Uuid, + ) -> Result, DomainError> { + let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + + let has_access = self.storage.user_has_access(playlist_id, user_id).await?; + if !has_access { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "You don't have permission to view this playlist", + )); + } + + self.storage.list_playlist_tracks(&playlist_uuid).await + } + + async fn share_playlist( + &self, + playlist_id: &str, + dto: SharePlaylistDto, + caller_id: Uuid, + ) -> Result<(), DomainError> { + let playlist = self.storage.get_playlist(playlist_id).await?; + let playlist = match playlist { + Some(p) => p, + None => { + return Err(DomainError::new( + ErrorKind::NotFound, + "Playlist", + "Playlist not found", + )); + } + }; + if playlist.owner_id != caller_id.to_string() { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "Only the owner can share this playlist", + )); + } + + let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + let target_user_id = Uuid::parse_str(&dto.user_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID") + })?; + let can_write = dto.can_write.unwrap_or(false); + + self.storage + .share_playlist(&playlist_uuid, target_user_id, can_write) + .await + } + + async fn remove_share( + &self, + playlist_id: &str, + target_user_id: &str, + caller_id: Uuid, + ) -> Result<(), DomainError> { + let playlist = self.storage.get_playlist(playlist_id).await?; + let playlist = match playlist { + Some(p) => p, + None => { + return Err(DomainError::new( + ErrorKind::NotFound, + "Playlist", + "Playlist not found", + )); + } + }; + if playlist.owner_id != caller_id.to_string() { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "Only the owner can manage sharing", + )); + } + + let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + let target_uuid = Uuid::parse_str(target_user_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID") + })?; + + self.storage.remove_share(&playlist_uuid, target_uuid).await + } + + async fn get_playlist_shares( + &self, + playlist_id: &str, + user_id: Uuid, + ) -> Result, DomainError> { + let playlist = self.storage.get_playlist(playlist_id).await?; + let playlist = match playlist { + Some(p) => p, + None => { + return Err(DomainError::new( + ErrorKind::NotFound, + "Playlist", + "Playlist not found", + )); + } + }; + if playlist.owner_id != user_id.to_string() { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Playlist", + "Only the owner can view sharing info", + )); + } + + let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + + let shares = self.storage.get_shares(&playlist_uuid).await?; + Ok(shares + .into_iter() + .map(|(uid, can_write)| PlaylistShareInfoDto { + user_id: uid.to_string(), + can_write, + }) + .collect()) + } + + async fn get_audio_metadata( + &self, + file_id: &str, + _user_id: Uuid, + ) -> Result, DomainError> { + let file_uuid = Uuid::parse_str(file_id) + .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Music", "Invalid file ID"))?; + self.storage.get_audio_metadata(&file_uuid).await + } +} diff --git a/src/common/config.rs b/src/common/config.rs index 4a803308..7330b8b3 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -490,6 +490,7 @@ pub struct FeaturesConfig { pub enable_file_sharing: bool, pub enable_trash: bool, pub enable_search: bool, + pub enable_music: bool, } impl Default for FeaturesConfig { @@ -500,6 +501,7 @@ impl Default for FeaturesConfig { enable_file_sharing: true, // Enable file sharing by default enable_trash: true, // Enable trash feature enable_search: true, // Enable search feature + enable_music: true, // Enable music feature } } } @@ -813,6 +815,12 @@ impl AppConfig { config.features.enable_search = val; } + if let Ok(enable_music) = env::var("OXICLOUD_ENABLE_MUSIC").map(|v| v.parse::()) + && let Ok(val) = enable_music + { + config.features.enable_music = val; + } + // Storage limits if let Ok(max_upload) = env::var("OXICLOUD_MAX_UPLOAD_SIZE").map(|v| v.parse::()) && let Ok(val) = max_upload diff --git a/src/common/di.rs b/src/common/di.rs index ca3a0ccc..83ff9bbb 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -38,6 +38,7 @@ use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::application::services::app_password_service::AppPasswordService; use crate::application::services::calendar_service::CalendarService; use crate::application::services::device_auth_service::DeviceAuthService; +use crate::application::services::music_service::MusicService; use crate::application::services::storage_usage_service::StorageUsageService; use crate::application::services::wopi_lock_service::WopiLockService; use crate::application::services::wopi_token_service::WopiTokenService; @@ -45,9 +46,11 @@ use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdap use crate::infrastructure::repositories::AppPasswordPgRepository; use crate::infrastructure::repositories::DeviceCodePgRepository; use crate::infrastructure::repositories::pg::{ - AddressBookPgRepository, CalendarEventPgRepository, CalendarPgRepository, - ContactGroupPgRepository, ContactPgRepository, SessionPgRepository, UserPgRepository, + AddressBookPgRepository, AudioMetadataPgRepository, CalendarEventPgRepository, + CalendarPgRepository, ContactGroupPgRepository, ContactPgRepository, PlaylistItemPgRepository, + PlaylistPgRepository, SessionPgRepository, UserPgRepository, }; +use crate::infrastructure::services::audio_metadata_service::AudioMetadataService; use crate::infrastructure::services::chunked_upload_service::ChunkedUploadService; use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::image_transcode_service::ImageTranscodeService; @@ -240,6 +243,7 @@ impl AppServiceFactory { core: &CoreServices, repos: &RepositoryServices, trash_service: Option>, + db_pool: &Arc, ) -> ApplicationServices { // Main services let folder_service = Arc::new(FolderService::new(repos.folder_repository.clone())); @@ -298,9 +302,26 @@ impl AppServiceFactory { share_service: None, // Configured later with create_share_service favorites_service: None, // Configured later with create_favorites_service recent_service: None, // Configured later with create_recent_service + audio_metadata_service: self.create_audio_metadata_service(db_pool), } } + /// Creates the audio metadata service (extracts ID3 tags from audio files) + pub fn create_audio_metadata_service( + &self, + db_pool: &Arc, + ) -> Option> { + if !self.config.features.enable_music { + tracing::info!("Audio metadata service is disabled (music feature disabled)"); + return None; + } + let blob_root = self.storage_path.join(".blobs"); + Some(Arc::new(AudioMetadataService::new( + db_pool.clone(), + blob_root, + ))) + } + /// Creates the trash service pub async fn create_trash_service( &self, @@ -465,7 +486,8 @@ impl AppServiceFactory { let trash_service = self.create_trash_service(&repos, &core).await; // 4. Application services (with trash already wired) - let mut apps = self.create_application_services(&core, &repos, trash_service.clone()); + let mut apps = + self.create_application_services(&core, &repos, trash_service.clone(), &pool); // 5. Share service let share_service = self.create_share_service(&repos, &pool); @@ -614,6 +636,7 @@ impl AppServiceFactory { calendar_use_case: None, addressbook_use_case: None, contact_use_case: None, + music_service: None, wopi_token_service: None, wopi_lock_service: None, wopi_discovery_service: None, @@ -765,6 +788,26 @@ impl AppServiceFactory { tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories"); } + // Music service + { + let playlist_repo: Arc = + Arc::new(PlaylistPgRepository::new(pool.clone())); + let item_repo: Arc = + Arc::new(PlaylistItemPgRepository::new(pool.clone())); + let audio_metadata_repo: Arc = + Arc::new(AudioMetadataPgRepository::new(pool.clone())); + let music_storage = Arc::new( + crate::infrastructure::adapters::music_storage_adapter::MusicStorageAdapter::new( + playlist_repo, + item_repo, + audio_metadata_repo, + ), + ); + let music_svc = Arc::new(MusicService::new(music_storage)); + app_state.music_service = Some(music_svc); + tracing::info!("Music service initialized"); + } + // 11. Wire WOPI services if enabled if self.config.wopi.enabled { let discovery_url = &self.config.wopi.discovery_url; @@ -847,6 +890,7 @@ pub struct ApplicationServices { pub share_service: Option>, pub favorites_service: Option>, pub recent_service: Option>, + pub audio_metadata_service: Option>, } /// Container for authentication services @@ -889,6 +933,7 @@ pub struct AppState { pub calendar_use_case: Option>, pub addressbook_use_case: Option>, pub contact_use_case: Option>, + pub music_service: Option>, pub wopi_token_service: Option>, pub wopi_lock_service: diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs index d049c8e2..f59ddd2f 100644 --- a/src/domain/entities/mod.rs +++ b/src/domain/entities/mod.rs @@ -6,6 +6,7 @@ pub mod device_code; pub mod entity_errors; pub mod file; pub mod folder; +pub mod playlist; pub mod session; pub mod share; pub mod trashed_item; diff --git a/src/domain/entities/playlist.rs b/src/domain/entities/playlist.rs new file mode 100644 index 00000000..a2894b4f --- /dev/null +++ b/src/domain/entities/playlist.rs @@ -0,0 +1,470 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::common::errors::{DomainError, ErrorKind, Result}; + +#[derive(Debug, Clone)] +pub struct Playlist { + id: Uuid, + name: String, + description: Option, + owner_id: Uuid, + is_public: bool, + cover_file_id: Option, + created_at: DateTime, + updated_at: DateTime, +} + +impl Playlist { + pub fn new(name: String, owner_id: Uuid, description: Option) -> Result { + if name.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Playlist", + "Playlist name cannot be empty", + )); + } + + let now = Utc::now(); + + Ok(Self { + id: Uuid::new_v4(), + name, + description, + owner_id, + is_public: false, + cover_file_id: None, + created_at: now, + updated_at: now, + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn with_id( + id: Uuid, + name: String, + description: Option, + owner_id: Uuid, + is_public: bool, + cover_file_id: Option, + created_at: DateTime, + updated_at: DateTime, + ) -> Result { + if name.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Playlist", + "Playlist name cannot be empty", + )); + } + + Ok(Self { + id, + name, + description, + owner_id, + is_public, + cover_file_id, + created_at, + updated_at, + }) + } + + pub fn id(&self) -> &Uuid { + &self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + pub fn owner_id(&self) -> &Uuid { + &self.owner_id + } + + pub fn is_public(&self) -> bool { + self.is_public + } + + pub fn cover_file_id(&self) -> Option<&Uuid> { + self.cover_file_id.as_ref() + } + + pub fn created_at(&self) -> &DateTime { + &self.created_at + } + + pub fn updated_at(&self) -> &DateTime { + &self.updated_at + } + + pub fn update_name(&mut self, name: String) -> Result<()> { + if name.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Playlist", + "Playlist name cannot be empty", + )); + } + self.name = name; + self.updated_at = Utc::now(); + Ok(()) + } + + pub fn update_description(&mut self, description: Option) { + self.description = description; + self.updated_at = Utc::now(); + } + + pub fn set_public(&mut self, is_public: bool) { + self.is_public = is_public; + self.updated_at = Utc::now(); + } + + pub fn set_cover(&mut self, cover_file_id: Option) { + self.cover_file_id = cover_file_id; + self.updated_at = Utc::now(); + } + + pub fn belongs_to(&self, user_id: &Uuid) -> bool { + self.owner_id == *user_id + } + + pub fn touch(&mut self) { + self.updated_at = Utc::now(); + } +} + +#[derive(Debug, Clone)] +pub struct PlaylistItem { + id: Uuid, + playlist_id: Uuid, + file_id: Uuid, + position: i32, + added_at: DateTime, +} + +impl PlaylistItem { + pub fn new(playlist_id: Uuid, file_id: Uuid, position: i32) -> Result { + if position < 0 { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "PlaylistItem", + "Position cannot be negative", + )); + } + + Ok(Self { + id: Uuid::new_v4(), + playlist_id, + file_id, + position, + added_at: Utc::now(), + }) + } + + pub fn with_id( + id: Uuid, + playlist_id: Uuid, + file_id: Uuid, + position: i32, + added_at: DateTime, + ) -> Result { + if position < 0 { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "PlaylistItem", + "Position cannot be negative", + )); + } + + Ok(Self { + id, + playlist_id, + file_id, + position, + added_at, + }) + } + + pub fn id(&self) -> &Uuid { + &self.id + } + + pub fn playlist_id(&self) -> &Uuid { + &self.playlist_id + } + + pub fn file_id(&self) -> &Uuid { + &self.file_id + } + + pub fn position(&self) -> i32 { + self.position + } + + pub fn added_at(&self) -> &DateTime { + &self.added_at + } + + pub fn set_position(&mut self, position: i32) -> Result<()> { + if position < 0 { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "PlaylistItem", + "Position cannot be negative", + )); + } + self.position = position; + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct AudioFileMetadata { + file_id: Uuid, + title: Option, + artist: Option, + album: Option, + album_artist: Option, + genre: Option, + track_number: Option, + disc_number: Option, + year: Option, + duration_secs: i32, + bitrate: Option, + sample_rate: Option, + channels: Option, + format: Option, + created_at: DateTime, + updated_at: DateTime, +} + +impl AudioFileMetadata { + pub fn new(file_id: Uuid, duration_secs: i32) -> Self { + let now = Utc::now(); + Self { + file_id, + title: None, + artist: None, + album: None, + album_artist: None, + genre: None, + track_number: None, + disc_number: None, + year: None, + duration_secs, + bitrate: None, + sample_rate: None, + channels: None, + format: None, + created_at: now, + updated_at: now, + } + } + + #[allow(clippy::too_many_arguments)] + pub fn with_all_fields( + file_id: Uuid, + title: Option, + artist: Option, + album: Option, + album_artist: Option, + genre: Option, + track_number: Option, + disc_number: Option, + year: Option, + duration_secs: i32, + bitrate: Option, + sample_rate: Option, + channels: Option, + format: Option, + created_at: DateTime, + updated_at: DateTime, + ) -> Self { + Self { + file_id, + title, + artist, + album, + album_artist, + genre, + track_number, + disc_number, + year, + duration_secs, + bitrate, + sample_rate, + channels, + format, + created_at, + updated_at, + } + } + + pub fn file_id(&self) -> &Uuid { + &self.file_id + } + + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + pub fn artist(&self) -> Option<&str> { + self.artist.as_deref() + } + + pub fn album(&self) -> Option<&str> { + self.album.as_deref() + } + + pub fn album_artist(&self) -> Option<&str> { + self.album_artist.as_deref() + } + + pub fn genre(&self) -> Option<&str> { + self.genre.as_deref() + } + + pub fn track_number(&self) -> Option { + self.track_number + } + + pub fn disc_number(&self) -> Option { + self.disc_number + } + + pub fn year(&self) -> Option { + self.year + } + + pub fn duration_secs(&self) -> i32 { + self.duration_secs + } + + pub fn bitrate(&self) -> Option { + self.bitrate + } + + pub fn sample_rate(&self) -> Option { + self.sample_rate + } + + pub fn channels(&self) -> Option { + self.channels + } + + pub fn format(&self) -> Option<&str> { + self.format.as_deref() + } + + pub fn created_at(&self) -> &DateTime { + &self.created_at + } + + pub fn updated_at(&self) -> &DateTime { + &self.updated_at + } + + pub fn set_title(&mut self, title: Option) { + self.title = title; + self.updated_at = Utc::now(); + } + + pub fn set_artist(&mut self, artist: Option) { + self.artist = artist; + self.updated_at = Utc::now(); + } + + pub fn set_album(&mut self, album: Option) { + self.album = album; + self.updated_at = Utc::now(); + } + + pub fn set_album_artist(&mut self, album_artist: Option) { + self.album_artist = album_artist; + self.updated_at = Utc::now(); + } + + pub fn set_genre(&mut self, genre: Option) { + self.genre = genre; + self.updated_at = Utc::now(); + } + + pub fn set_track_number(&mut self, track_number: Option) { + self.track_number = track_number; + self.updated_at = Utc::now(); + } + + pub fn set_disc_number(&mut self, disc_number: Option) { + self.disc_number = disc_number; + self.updated_at = Utc::now(); + } + + pub fn set_year(&mut self, year: Option) { + self.year = year; + self.updated_at = Utc::now(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_playlist() { + let owner_id = Uuid::new_v4(); + let playlist = Playlist::new("My Playlist".to_string(), owner_id, None); + assert!(playlist.is_ok()); + let playlist = playlist.unwrap(); + assert_eq!(playlist.name(), "My Playlist"); + assert!(!playlist.is_public()); + } + + #[test] + fn test_create_playlist_empty_name() { + let owner_id = Uuid::new_v4(); + let playlist = Playlist::new("".to_string(), owner_id, None); + assert!(playlist.is_err()); + } + + #[test] + fn test_playlist_update() { + let owner_id = Uuid::new_v4(); + let mut playlist = Playlist::new("My Playlist".to_string(), owner_id, None).unwrap(); + playlist + .update_name("Updated Playlist".to_string()) + .unwrap(); + assert_eq!(playlist.name(), "Updated Playlist"); + } + + #[test] + fn test_create_playlist_item() { + let playlist_id = Uuid::new_v4(); + let file_id = Uuid::new_v4(); + let item = PlaylistItem::new(playlist_id, file_id, 0); + assert!(item.is_ok()); + let item = item.unwrap(); + assert_eq!(item.position(), 0); + } + + #[test] + fn test_create_playlist_item_negative_position() { + let playlist_id = Uuid::new_v4(); + let file_id = Uuid::new_v4(); + let item = PlaylistItem::new(playlist_id, file_id, -1); + assert!(item.is_err()); + } + + #[test] + fn test_audio_metadata() { + let file_id = Uuid::new_v4(); + let metadata = AudioFileMetadata::new(file_id, 180); + assert_eq!(metadata.duration_secs(), 180); + assert!(metadata.title().is_none()); + } +} diff --git a/src/domain/repositories/mod.rs b/src/domain/repositories/mod.rs index ae8aec81..469a3724 100644 --- a/src/domain/repositories/mod.rs +++ b/src/domain/repositories/mod.rs @@ -4,6 +4,7 @@ pub mod calendar_repository; pub mod contact_repository; pub mod file_repository; pub mod folder_repository; +pub mod playlist_repository; pub mod session_repository; pub mod settings_repository; pub mod share_repository; diff --git a/src/domain/repositories/playlist_repository.rs b/src/domain/repositories/playlist_repository.rs new file mode 100644 index 00000000..186b633d --- /dev/null +++ b/src/domain/repositories/playlist_repository.rs @@ -0,0 +1,115 @@ +use crate::common::errors::DomainError; +use crate::domain::entities::playlist::{AudioFileMetadata, Playlist, PlaylistItem}; +use uuid::Uuid; + +pub type PlaylistRepositoryResult = Result; + +pub trait PlaylistRepository: Send + Sync + 'static { + async fn create_playlist(&self, playlist: Playlist) -> PlaylistRepositoryResult; + + async fn update_playlist(&self, playlist: Playlist) -> PlaylistRepositoryResult; + + async fn delete_playlist(&self, id: &Uuid) -> PlaylistRepositoryResult<()>; + + async fn find_playlist_by_id(&self, id: &Uuid) -> PlaylistRepositoryResult; + + async fn list_playlists_by_owner( + &self, + owner_id: Uuid, + ) -> PlaylistRepositoryResult>; + + async fn list_public_playlists( + &self, + limit: i64, + offset: i64, + ) -> PlaylistRepositoryResult>; + + async fn list_shared_with_user(&self, user_id: Uuid) + -> PlaylistRepositoryResult>; + + async fn user_has_access( + &self, + playlist_id: &Uuid, + user_id: Uuid, + ) -> PlaylistRepositoryResult; + + async fn share_playlist( + &self, + playlist_id: &Uuid, + user_id: Uuid, + can_write: bool, + ) -> PlaylistRepositoryResult<()>; + + async fn remove_share(&self, playlist_id: &Uuid, user_id: Uuid) + -> PlaylistRepositoryResult<()>; + + async fn get_shares(&self, playlist_id: &Uuid) -> PlaylistRepositoryResult>; +} + +pub type PlaylistItemRepositoryResult = Result; + +pub trait PlaylistItemRepository: Send + Sync + 'static { + async fn add_item(&self, item: PlaylistItem) -> PlaylistItemRepositoryResult; + + async fn remove_item(&self, id: &Uuid) -> PlaylistItemRepositoryResult<()>; + + async fn remove_item_by_playlist_and_file( + &self, + playlist_id: &Uuid, + file_id: &Uuid, + ) -> PlaylistItemRepositoryResult<()>; + + async fn find_item_by_id(&self, id: &Uuid) -> PlaylistItemRepositoryResult; + + async fn list_items_in_playlist( + &self, + playlist_id: &Uuid, + ) -> PlaylistItemRepositoryResult>; + + async fn update_position( + &self, + id: &Uuid, + new_position: i32, + ) -> PlaylistItemRepositoryResult<()>; + + async fn reorder_items( + &self, + playlist_id: &Uuid, + item_ids: &[Uuid], + ) -> PlaylistItemRepositoryResult<()>; + + async fn get_item_count(&self, playlist_id: &Uuid) -> PlaylistItemRepositoryResult; + + async fn get_max_position(&self, playlist_id: &Uuid) -> PlaylistItemRepositoryResult; +} + +pub type AudioMetadataRepositoryResult = Result; + +pub trait AudioMetadataRepository: Send + Sync + 'static { + async fn create_or_update( + &self, + metadata: AudioFileMetadata, + ) -> AudioMetadataRepositoryResult; + + async fn find_by_file_id( + &self, + file_id: &Uuid, + ) -> AudioMetadataRepositoryResult>; + + async fn delete(&self, file_id: &Uuid) -> AudioMetadataRepositoryResult<()>; + + async fn list_by_artist( + &self, + artist: &str, + ) -> AudioMetadataRepositoryResult>; + + async fn list_by_album( + &self, + album: &str, + ) -> AudioMetadataRepositoryResult>; + + async fn list_by_genre( + &self, + genre: &str, + ) -> AudioMetadataRepositoryResult>; +} diff --git a/src/infrastructure/adapters/mod.rs b/src/infrastructure/adapters/mod.rs index 70b0b1b1..81a3f482 100644 --- a/src/infrastructure/adapters/mod.rs +++ b/src/infrastructure/adapters/mod.rs @@ -10,7 +10,9 @@ pub mod calendar_storage_adapter; pub mod contact_storage_adapter; pub mod error_adapters; +pub mod music_storage_adapter; pub use calendar_storage_adapter::CalendarStorageAdapter; pub use contact_storage_adapter::ContactStorageAdapter; pub use error_adapters::IntoDomainError; +pub use music_storage_adapter::MusicStorageAdapter; diff --git a/src/infrastructure/adapters/music_storage_adapter.rs b/src/infrastructure/adapters/music_storage_adapter.rs new file mode 100644 index 00000000..00df57c3 --- /dev/null +++ b/src/infrastructure/adapters/music_storage_adapter.rs @@ -0,0 +1,296 @@ +use std::sync::Arc; +use uuid::Uuid; + +use crate::application::dtos::playlist_dto::{ + AudioMetadataDto, CreatePlaylistDto, PlaylistDto, PlaylistItemDto, UpdatePlaylistDto, +}; +use crate::application::ports::music_ports::MusicStoragePort; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::playlist::Playlist; +use crate::domain::repositories::playlist_repository::{ + AudioMetadataRepository, PlaylistItemRepository, PlaylistRepository, +}; +use crate::infrastructure::repositories::pg::{ + AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository, +}; + +pub struct MusicStorageAdapter { + playlist_repository: Arc, + item_repository: Arc, + audio_metadata_repository: Arc, +} + +impl MusicStorageAdapter { + pub fn new( + playlist_repository: Arc, + item_repository: Arc, + audio_metadata_repository: Arc, + ) -> Self { + Self { + playlist_repository, + item_repository, + audio_metadata_repository, + } + } +} + +impl MusicStoragePort for MusicStorageAdapter { + async fn create_playlist( + &self, + dto: CreatePlaylistDto, + user_id: Uuid, + ) -> Result { + let playlist = Playlist::new(dto.name, user_id, dto.description)?; + let created = self.playlist_repository.create_playlist(playlist).await?; + Ok(PlaylistDto::from(created)) + } + + async fn update_playlist( + &self, + playlist_id: &str, + dto: UpdatePlaylistDto, + ) -> Result { + let uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + + let mut playlist = self.playlist_repository.find_playlist_by_id(&uuid).await?; + + if let Some(name) = dto.name { + playlist.update_name(name)?; + } + if let Some(description) = dto.description { + playlist.update_description(Some(description)); + } + if let Some(is_public) = dto.is_public { + playlist.set_public(is_public); + } + if let Some(cover_file_id) = dto.cover_file_id { + let cover_uuid = Uuid::parse_str(&cover_file_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid cover file ID") + })?; + playlist.set_cover(Some(cover_uuid)); + } + + let updated = self.playlist_repository.update_playlist(playlist).await?; + Ok(PlaylistDto::from(updated)) + } + + async fn delete_playlist(&self, playlist_id: &str) -> Result<(), DomainError> { + let uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + self.playlist_repository.delete_playlist(&uuid).await + } + + async fn get_playlist(&self, playlist_id: &str) -> Result, DomainError> { + let uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + + match self.playlist_repository.find_playlist_by_id(&uuid).await { + Ok(playlist) => Ok(Some(PlaylistDto::from(playlist))), + Err(e) if e.kind == ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } + } + + async fn list_playlists_by_owner( + &self, + owner_id: Uuid, + ) -> Result, DomainError> { + let playlists = self + .playlist_repository + .list_playlists_by_owner(owner_id) + .await?; + let mut result = Vec::new(); + for playlist in playlists { + let dto = PlaylistDto::from(playlist); + let track_count = self + .get_track_count(&uuid::Uuid::parse_str(&dto.id).unwrap()) + .await?; + result.push(dto.with_track_info(track_count, 0)); + } + Ok(result) + } + + async fn list_shared_with_user(&self, user_id: Uuid) -> Result, DomainError> { + let playlists = self + .playlist_repository + .list_shared_with_user(user_id) + .await?; + let mut result = Vec::new(); + for playlist in playlists { + let dto = PlaylistDto::from(playlist); + let track_count = self + .get_track_count(&uuid::Uuid::parse_str(&dto.id).unwrap()) + .await?; + result.push(dto.with_track_info(track_count, 0)); + } + Ok(result) + } + + async fn list_public_playlists( + &self, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + let playlists = self + .playlist_repository + .list_public_playlists(limit, offset) + .await?; + let mut result = Vec::new(); + for playlist in playlists { + let dto = PlaylistDto::from(playlist); + let track_count = self + .get_track_count(&uuid::Uuid::parse_str(&dto.id).unwrap()) + .await?; + result.push(dto.with_track_info(track_count, 0)); + } + Ok(result) + } + + async fn user_has_access(&self, playlist_id: &str, user_id: Uuid) -> Result { + let uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + self.playlist_repository + .user_has_access(&uuid, user_id) + .await + } + + async fn user_can_write(&self, playlist_id: &str, user_id: Uuid) -> Result { + let uuid = Uuid::parse_str(playlist_id).map_err(|_| { + DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID") + })?; + + let playlist = self.playlist_repository.find_playlist_by_id(&uuid).await?; + if playlist.owner_id() == &user_id { + return Ok(true); + } + + let shares = self.playlist_repository.get_shares(&uuid).await?; + Ok(shares + .iter() + .any(|(uid, can_write)| uid == &user_id && *can_write)) + } + + async fn add_tracks( + &self, + playlist_id: &Uuid, + file_ids: &[Uuid], + ) -> Result, DomainError> { + let mut max_position = self.item_repository.get_max_position(playlist_id).await?; + let mut items = Vec::new(); + + for file_id in file_ids { + max_position += 1; + let item = crate::domain::entities::playlist::PlaylistItem::new( + *playlist_id, + *file_id, + max_position, + )?; + let created = self.item_repository.add_item(item).await?; + items.push(PlaylistItemDto::from(created)); + } + + Ok(items) + } + + async fn remove_track(&self, playlist_id: &Uuid, file_id: &Uuid) -> Result<(), DomainError> { + self.item_repository + .remove_item_by_playlist_and_file(playlist_id, file_id) + .await + } + + async fn reorder_tracks( + &self, + playlist_id: &Uuid, + item_ids: &[Uuid], + ) -> Result<(), DomainError> { + self.item_repository + .reorder_items(playlist_id, item_ids) + .await + } + + async fn list_playlist_tracks( + &self, + playlist_id: &Uuid, + ) -> Result, DomainError> { + let enriched_items = self + .item_repository + .list_items_in_playlist_enriched(playlist_id) + .await?; + + let items: Vec = enriched_items + .into_iter() + .map( + |row| crate::application::dtos::playlist_dto::PlaylistItemDto { + id: row.id.to_string(), + playlist_id: row.playlist_id.to_string(), + file_id: row.file_id.to_string(), + position: row.position, + added_at: row.added_at, + file_name: row.file_name, + file_size: row.file_size, + mime_type: row.mime_type, + title: row.title, + artist: row.artist, + album: row.album, + duration_secs: row.duration_secs, + }, + ) + .collect(); + Ok(items) + } + + async fn share_playlist( + &self, + playlist_id: &Uuid, + user_id: Uuid, + can_write: bool, + ) -> Result<(), DomainError> { + self.playlist_repository + .share_playlist(playlist_id, user_id, can_write) + .await + } + + async fn remove_share(&self, playlist_id: &Uuid, user_id: Uuid) -> Result<(), DomainError> { + self.playlist_repository + .remove_share(playlist_id, user_id) + .await + } + + async fn get_shares(&self, playlist_id: &Uuid) -> Result, DomainError> { + self.playlist_repository.get_shares(playlist_id).await + } + + async fn get_audio_metadata( + &self, + file_id: &Uuid, + ) -> Result, DomainError> { + match self + .audio_metadata_repository + .find_by_file_id(file_id) + .await + { + Ok(Some(metadata)) => Ok(Some(AudioMetadataDto::from(metadata))), + Ok(None) => Ok(None), + Err(e) => Err(e), + } + } +} + +impl MusicStorageAdapter { + async fn get_track_count(&self, playlist_id: &Uuid) -> Result { + let count: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM audio.playlist_items WHERE playlist_id = $1") + .bind(playlist_id) + .fetch_one(self.playlist_repository.pool()) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to get track count: {}", e)) + })?; + Ok(count.0) + } +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index baf9b598..87ac46c6 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -9,6 +9,7 @@ mod device_code_pg_repository; mod favorites_pg_repository; pub mod file_metadata_repository; mod nextcloud_object_id_repository; +pub mod playlist_pg_repository; mod recent_items_pg_repository; mod session_pg_repository; mod settings_pg_repository; @@ -36,6 +37,9 @@ pub use file_blob_write_repository::FileBlobWriteRepository; pub use file_metadata_repository::FileMetadataRepository; pub use folder_db_repository::FolderDbRepository; pub use nextcloud_object_id_repository::NextcloudObjectIdRepository; +pub use playlist_pg_repository::{ + AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository, +}; pub use recent_items_pg_repository::RecentItemsPgRepository; pub use session_pg_repository::SessionPgRepository; pub use settings_pg_repository::SettingsPgRepository; diff --git a/src/infrastructure/repositories/pg/playlist_pg_repository.rs b/src/infrastructure/repositories/pg/playlist_pg_repository.rs new file mode 100644 index 00000000..8f3f880c --- /dev/null +++ b/src/infrastructure/repositories/pg/playlist_pg_repository.rs @@ -0,0 +1,787 @@ +use chrono::{DateTime, Utc}; +use sqlx::{FromRow, PgPool}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::playlist::{AudioFileMetadata, Playlist, PlaylistItem}; +use crate::domain::repositories::playlist_repository::{ + AudioMetadataRepository, AudioMetadataRepositoryResult, PlaylistItemRepository, + PlaylistItemRepositoryResult, PlaylistRepository, PlaylistRepositoryResult, +}; + +#[derive(FromRow)] +struct PlaylistRow { + id: Uuid, + name: String, + description: Option, + owner_id: Uuid, + is_public: bool, + cover_file_id: Option, + created_at: DateTime, + updated_at: DateTime, +} + +#[derive(FromRow)] +struct PlaylistItemRow { + id: Uuid, + playlist_id: Uuid, + file_id: Uuid, + position: i32, + added_at: DateTime, +} + +#[derive(FromRow)] +struct AudioMetadataRow { + file_id: Uuid, + title: Option, + artist: Option, + album: Option, + album_artist: Option, + genre: Option, + track_number: Option, + disc_number: Option, + year: Option, + duration_secs: i32, + bitrate: Option, + sample_rate: Option, + channels: Option, + format: Option, + created_at: DateTime, + updated_at: DateTime, +} + +#[derive(FromRow)] +pub struct PlaylistItemEnrichedRow { + pub id: Uuid, + pub playlist_id: Uuid, + pub file_id: Uuid, + pub position: i32, + pub added_at: DateTime, + pub file_name: Option, + pub file_size: Option, + pub mime_type: Option, + pub title: Option, + pub artist: Option, + pub album: Option, + pub duration_secs: Option, +} + +pub struct PlaylistPgRepository { + pool: Arc, +} + +impl PlaylistPgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + pub fn pool(&self) -> &PgPool { + &self.pool + } +} + +impl PlaylistRepository for PlaylistPgRepository { + async fn create_playlist(&self, playlist: Playlist) -> PlaylistRepositoryResult { + let row = sqlx::query_as::<_, PlaylistRow>( + r#" + INSERT INTO audio.playlists (id, name, description, owner_id, is_public, cover_file_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, name, description, owner_id, is_public, cover_file_id, created_at, updated_at + "#, + ) + .bind(playlist.id()) + .bind(playlist.name()) + .bind(playlist.description()) + .bind(playlist.owner_id()) + .bind(playlist.is_public()) + .bind(playlist.cover_file_id()) + .bind(playlist.created_at()) + .bind(playlist.updated_at()) + .fetch_one(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to create playlist: {}", e)))?; + + Playlist::with_id( + row.id, + row.name, + row.description, + row.owner_id, + row.is_public, + row.cover_file_id, + row.created_at, + row.updated_at, + ) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string())) + } + + async fn update_playlist(&self, playlist: Playlist) -> PlaylistRepositoryResult { + let row = sqlx::query_as::<_, PlaylistRow>( + r#" + UPDATE audio.playlists + SET name = $2, description = $3, is_public = $4, cover_file_id = $5, updated_at = NOW() + WHERE id = $1 + RETURNING id, name, description, owner_id, is_public, cover_file_id, created_at, updated_at + "#, + ) + .bind(playlist.id()) + .bind(playlist.name()) + .bind(playlist.description()) + .bind(playlist.is_public()) + .bind(playlist.cover_file_id()) + .fetch_one(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to update playlist: {}", e)))?; + + Playlist::with_id( + row.id, + row.name, + row.description, + row.owner_id, + row.is_public, + row.cover_file_id, + row.created_at, + row.updated_at, + ) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string())) + } + + async fn delete_playlist(&self, id: &Uuid) -> PlaylistRepositoryResult<()> { + sqlx::query("DELETE FROM audio.playlists WHERE id = $1") + .bind(id) + .execute(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to delete playlist: {}", e)) + })?; + Ok(()) + } + + async fn find_playlist_by_id(&self, id: &Uuid) -> PlaylistRepositoryResult { + let row = sqlx::query_as::<_, PlaylistRow>( + "SELECT id, name, description, owner_id, is_public, cover_file_id, created_at, updated_at FROM audio.playlists WHERE id = $1", + ) + .bind(id) + .fetch_optional(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to find playlist: {}", e)))? + .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Playlist", "Playlist not found"))?; + + Playlist::with_id( + row.id, + row.name, + row.description, + row.owner_id, + row.is_public, + row.cover_file_id, + row.created_at, + row.updated_at, + ) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string())) + } + + async fn list_playlists_by_owner( + &self, + owner_id: Uuid, + ) -> PlaylistRepositoryResult> { + let rows = sqlx::query_as::<_, PlaylistRow>( + "SELECT id, name, description, owner_id, is_public, cover_file_id, created_at, updated_at FROM audio.playlists WHERE owner_id = $1 ORDER BY updated_at DESC", + ) + .bind(owner_id) + .fetch_all(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to list playlists: {}", e)))?; + + rows.into_iter() + .map(|row| { + Playlist::with_id( + row.id, + row.name, + row.description, + row.owner_id, + row.is_public, + row.cover_file_id, + row.created_at, + row.updated_at, + ) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string())) + }) + .collect() + } + + async fn list_public_playlists( + &self, + limit: i64, + offset: i64, + ) -> PlaylistRepositoryResult> { + let rows = sqlx::query_as::<_, PlaylistRow>( + "SELECT id, name, description, owner_id, is_public, cover_file_id, created_at, updated_at FROM audio.playlists WHERE is_public = TRUE ORDER BY updated_at DESC LIMIT $1 OFFSET $2", + ) + .bind(limit) + .bind(offset) + .fetch_all(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to list public playlists: {}", e)))?; + + rows.into_iter() + .map(|row| { + Playlist::with_id( + row.id, + row.name, + row.description, + row.owner_id, + row.is_public, + row.cover_file_id, + row.created_at, + row.updated_at, + ) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string())) + }) + .collect() + } + + async fn list_shared_with_user( + &self, + user_id: Uuid, + ) -> PlaylistRepositoryResult> { + let rows = sqlx::query_as::<_, PlaylistRow>( + r#" + SELECT p.id, p.name, p.description, p.owner_id, p.is_public, p.cover_file_id, p.created_at, p.updated_at + FROM audio.playlists p + JOIN audio.playlist_shares ps ON p.id = ps.playlist_id + WHERE ps.user_id = $1 + ORDER BY p.updated_at DESC + "#, + ) + .bind(user_id) + .fetch_all(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to list shared playlists: {}", e)))?; + + rows.into_iter() + .map(|row| { + Playlist::with_id( + row.id, + row.name, + row.description, + row.owner_id, + row.is_public, + row.cover_file_id, + row.created_at, + row.updated_at, + ) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string())) + }) + .collect() + } + + async fn user_has_access( + &self, + playlist_id: &Uuid, + user_id: Uuid, + ) -> PlaylistRepositoryResult { + let row = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 FROM audio.playlists p + WHERE p.id = $1 AND (p.owner_id = $2 OR p.is_public = TRUE) + UNION + SELECT 1 FROM audio.playlist_shares ps + WHERE ps.playlist_id = $1 AND ps.user_id = $2 + ) + "#, + ) + .bind(playlist_id) + .bind(user_id) + .fetch_one(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to check playlist access: {}", e)) + })?; + + Ok(row) + } + + async fn share_playlist( + &self, + playlist_id: &Uuid, + user_id: Uuid, + can_write: bool, + ) -> PlaylistRepositoryResult<()> { + sqlx::query( + r#" + INSERT INTO audio.playlist_shares (playlist_id, user_id, can_write) + VALUES ($1, $2, $3) + ON CONFLICT (playlist_id, user_id) DO UPDATE SET can_write = $3 + "#, + ) + .bind(playlist_id) + .bind(user_id) + .bind(can_write) + .execute(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to share playlist: {}", e)))?; + Ok(()) + } + + async fn remove_share( + &self, + playlist_id: &Uuid, + user_id: Uuid, + ) -> PlaylistRepositoryResult<()> { + sqlx::query("DELETE FROM audio.playlist_shares WHERE playlist_id = $1 AND user_id = $2") + .bind(playlist_id) + .bind(user_id) + .execute(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to remove playlist share: {}", e)) + })?; + Ok(()) + } + + async fn get_shares(&self, playlist_id: &Uuid) -> PlaylistRepositoryResult> { + let rows = sqlx::query_as::<_, (Uuid, bool)>( + "SELECT user_id, can_write FROM audio.playlist_shares WHERE playlist_id = $1", + ) + .bind(playlist_id) + .fetch_all(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to get playlist shares: {}", e)) + })?; + Ok(rows) + } +} + +pub struct PlaylistItemPgRepository { + pool: Arc, +} + +impl PlaylistItemPgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + pub async fn list_items_in_playlist_enriched( + &self, + playlist_id: &Uuid, + ) -> PlaylistItemRepositoryResult> { + let rows = sqlx::query_as::<_, PlaylistItemEnrichedRow>( + r#" + SELECT + pi.id, pi.playlist_id, pi.file_id, pi.position, pi.added_at, + f.name as file_name, f.size as file_size, f.mime_type, + m.title, m.artist, m.album, m.duration_secs + FROM audio.playlist_items pi + LEFT JOIN storage.files f ON pi.file_id = f.id + LEFT JOIN audio.file_metadata m ON pi.file_id = m.file_id + WHERE pi.playlist_id = $1 + ORDER BY pi.position ASC + "#, + ) + .bind(playlist_id) + .fetch_all(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to list playlist items: {}", e)) + })?; + Ok(rows) + } +} + +impl PlaylistItemRepository for PlaylistItemPgRepository { + async fn add_item(&self, item: PlaylistItem) -> PlaylistItemRepositoryResult { + let row = sqlx::query_as::<_, PlaylistItemRow>( + r#" + INSERT INTO audio.playlist_items (id, playlist_id, file_id, position, added_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (playlist_id, file_id) DO UPDATE SET position = $4 + RETURNING id, playlist_id, file_id, position, added_at + "#, + ) + .bind(item.id()) + .bind(item.playlist_id()) + .bind(item.file_id()) + .bind(item.position()) + .bind(item.added_at()) + .fetch_one(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to add playlist item: {}", e)))?; + + PlaylistItem::with_id( + row.id, + row.playlist_id, + row.file_id, + row.position, + row.added_at, + ) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "PlaylistItem", e.to_string())) + } + + async fn remove_item(&self, id: &Uuid) -> PlaylistItemRepositoryResult<()> { + sqlx::query("DELETE FROM audio.playlist_items WHERE id = $1") + .bind(id) + .execute(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to remove playlist item: {}", e)) + })?; + Ok(()) + } + + async fn remove_item_by_playlist_and_file( + &self, + playlist_id: &Uuid, + file_id: &Uuid, + ) -> PlaylistItemRepositoryResult<()> { + sqlx::query("DELETE FROM audio.playlist_items WHERE playlist_id = $1 AND file_id = $2") + .bind(playlist_id) + .bind(file_id) + .execute(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to remove playlist item: {}", e)) + })?; + Ok(()) + } + + async fn find_item_by_id(&self, id: &Uuid) -> PlaylistItemRepositoryResult { + let row = sqlx::query_as::<_, PlaylistItemRow>( + "SELECT id, playlist_id, file_id, position, added_at FROM audio.playlist_items WHERE id = $1", + ) + .bind(id) + .fetch_optional(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to find playlist item: {}", e)))? + .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "PlaylistItem", "Item not found"))?; + + PlaylistItem::with_id( + row.id, + row.playlist_id, + row.file_id, + row.position, + row.added_at, + ) + .map_err(|e| DomainError::new(ErrorKind::InternalError, "PlaylistItem", e.to_string())) + } + + async fn list_items_in_playlist( + &self, + playlist_id: &Uuid, + ) -> PlaylistItemRepositoryResult> { + let rows = sqlx::query_as::<_, PlaylistItemRow>( + "SELECT id, playlist_id, file_id, position, added_at FROM audio.playlist_items WHERE playlist_id = $1 ORDER BY position ASC", + ) + .bind(playlist_id) + .fetch_all(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to list playlist items: {}", e)))?; + + rows.into_iter() + .map(|row| { + PlaylistItem::with_id( + row.id, + row.playlist_id, + row.file_id, + row.position, + row.added_at, + ) + .map_err(|e| { + DomainError::new(ErrorKind::InternalError, "PlaylistItem", e.to_string()) + }) + }) + .collect() + } + + async fn update_position( + &self, + id: &Uuid, + new_position: i32, + ) -> PlaylistItemRepositoryResult<()> { + sqlx::query("UPDATE audio.playlist_items SET position = $2 WHERE id = $1") + .bind(id) + .bind(new_position) + .execute(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to update position: {}", e)) + })?; + Ok(()) + } + + async fn reorder_items( + &self, + playlist_id: &Uuid, + item_ids: &[Uuid], + ) -> PlaylistItemRepositoryResult<()> { + for (index, item_id) in item_ids.iter().enumerate() { + sqlx::query( + "UPDATE audio.playlist_items SET position = $2 WHERE id = $1 AND playlist_id = $3", + ) + .bind(item_id) + .bind(index as i32) + .bind(playlist_id) + .execute(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to reorder: {}", e)))?; + } + Ok(()) + } + + async fn get_item_count(&self, playlist_id: &Uuid) -> PlaylistItemRepositoryResult { + let count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audio.playlist_items WHERE playlist_id = $1", + ) + .bind(playlist_id) + .fetch_one(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to count items: {}", e)))?; + Ok(count) + } + + async fn get_max_position(&self, playlist_id: &Uuid) -> PlaylistItemRepositoryResult { + let max_pos = sqlx::query_scalar::<_, Option>( + "SELECT MAX(position) FROM audio.playlist_items WHERE playlist_id = $1", + ) + .bind(playlist_id) + .fetch_one(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to get max position: {}", e)))?; + Ok(max_pos.unwrap_or(-1)) + } +} + +pub struct AudioMetadataPgRepository { + pool: Arc, +} + +impl AudioMetadataPgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +impl AudioMetadataRepository for AudioMetadataPgRepository { + async fn create_or_update( + &self, + metadata: AudioFileMetadata, + ) -> AudioMetadataRepositoryResult { + let row = sqlx::query_as::<_, AudioMetadataRow>( + r#" + INSERT INTO audio.file_metadata (file_id, title, artist, album, album_artist, genre, track_number, disc_number, year, duration_secs, bitrate, sample_rate, channels, format, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + ON CONFLICT (file_id) DO UPDATE SET + title = COALESCE($2, audio.file_metadata.title), + artist = COALESCE($3, audio.file_metadata.artist), + album = COALESCE($4, audio.file_metadata.album), + album_artist = COALESCE($5, audio.file_metadata.album_artist), + genre = COALESCE($6, audio.file_metadata.genre), + track_number = COALESCE($7, audio.file_metadata.track_number), + disc_number = COALESCE($8, audio.file_metadata.disc_number), + year = COALESCE($9, audio.file_metadata.year), + duration_secs = $10, + bitrate = COALESCE($11, audio.file_metadata.bitrate), + sample_rate = COALESCE($12, audio.file_metadata.sample_rate), + channels = COALESCE($13, audio.file_metadata.channels), + format = COALESCE($14, audio.file_metadata.format), + updated_at = NOW() + RETURNING file_id, title, artist, album, album_artist, genre, track_number, disc_number, year, duration_secs, bitrate, sample_rate, channels, format, created_at, updated_at + "#, + ) + .bind(metadata.file_id()) + .bind(metadata.title()) + .bind(metadata.artist()) + .bind(metadata.album()) + .bind(metadata.album_artist()) + .bind(metadata.genre()) + .bind(metadata.track_number()) + .bind(metadata.disc_number()) + .bind(metadata.year()) + .bind(metadata.duration_secs()) + .bind(metadata.bitrate()) + .bind(metadata.sample_rate()) + .bind(metadata.channels()) + .bind(metadata.format()) + .bind(metadata.created_at()) + .bind(metadata.updated_at()) + .fetch_one(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to create audio metadata: {}", e)))?; + + Ok(AudioFileMetadata::with_all_fields( + row.file_id, + row.title, + row.artist, + row.album, + row.album_artist, + row.genre, + row.track_number, + row.disc_number, + row.year, + row.duration_secs, + row.bitrate, + row.sample_rate, + row.channels, + row.format, + row.created_at, + row.updated_at, + )) + } + + async fn find_by_file_id( + &self, + file_id: &Uuid, + ) -> AudioMetadataRepositoryResult> { + let row = sqlx::query_as::<_, AudioMetadataRow>( + "SELECT file_id, title, artist, album, album_artist, genre, track_number, disc_number, year, duration_secs, bitrate, sample_rate, channels, format, created_at, updated_at FROM audio.file_metadata WHERE file_id = $1", + ) + .bind(file_id) + .fetch_optional(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to find audio metadata: {}", e)))?; + + Ok(row.map(|r| { + AudioFileMetadata::with_all_fields( + r.file_id, + r.title, + r.artist, + r.album, + r.album_artist, + r.genre, + r.track_number, + r.disc_number, + r.year, + r.duration_secs, + r.bitrate, + r.sample_rate, + r.channels, + r.format, + r.created_at, + r.updated_at, + ) + })) + } + + async fn delete(&self, file_id: &Uuid) -> AudioMetadataRepositoryResult<()> { + sqlx::query("DELETE FROM audio.file_metadata WHERE file_id = $1") + .bind(file_id) + .execute(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to delete audio metadata: {}", e)) + })?; + Ok(()) + } + + async fn list_by_artist( + &self, + artist: &str, + ) -> AudioMetadataRepositoryResult> { + let rows = sqlx::query_as::<_, AudioMetadataRow>( + "SELECT file_id, title, artist, album, album_artist, genre, track_number, disc_number, year, duration_secs, bitrate, sample_rate, channels, format, created_at, updated_at FROM audio.file_metadata WHERE artist ILIKE $1 ORDER BY album, track_number", + ) + .bind(format!("%{}%", artist)) + .fetch_all(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to list by artist: {}", e)))?; + + Ok(rows + .into_iter() + .map(|r| { + AudioFileMetadata::with_all_fields( + r.file_id, + r.title, + r.artist, + r.album, + r.album_artist, + r.genre, + r.track_number, + r.disc_number, + r.year, + r.duration_secs, + r.bitrate, + r.sample_rate, + r.channels, + r.format, + r.created_at, + r.updated_at, + ) + }) + .collect()) + } + + async fn list_by_album( + &self, + album: &str, + ) -> AudioMetadataRepositoryResult> { + let rows = sqlx::query_as::<_, AudioMetadataRow>( + "SELECT file_id, title, artist, album, album_artist, genre, track_number, disc_number, year, duration_secs, bitrate, sample_rate, channels, format, created_at, updated_at FROM audio.file_metadata WHERE album ILIKE $1 ORDER BY disc_number, track_number", + ) + .bind(format!("%{}%", album)) + .fetch_all(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to list by album: {}", e)))?; + + Ok(rows + .into_iter() + .map(|r| { + AudioFileMetadata::with_all_fields( + r.file_id, + r.title, + r.artist, + r.album, + r.album_artist, + r.genre, + r.track_number, + r.disc_number, + r.year, + r.duration_secs, + r.bitrate, + r.sample_rate, + r.channels, + r.format, + r.created_at, + r.updated_at, + ) + }) + .collect()) + } + + async fn list_by_genre( + &self, + genre: &str, + ) -> AudioMetadataRepositoryResult> { + let rows = sqlx::query_as::<_, AudioMetadataRow>( + "SELECT file_id, title, artist, album, album_artist, genre, track_number, disc_number, year, duration_secs, bitrate, sample_rate, channels, format, created_at, updated_at FROM audio.file_metadata WHERE genre ILIKE $1 ORDER BY artist, album, track_number", + ) + .bind(format!("%{}%", genre)) + .fetch_all(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to list by genre: {}", e)))?; + + Ok(rows + .into_iter() + .map(|r| { + AudioFileMetadata::with_all_fields( + r.file_id, + r.title, + r.artist, + r.album, + r.album_artist, + r.genre, + r.track_number, + r.disc_number, + r.year, + r.duration_secs, + r.bitrate, + r.sample_rate, + r.channels, + r.format, + r.created_at, + r.updated_at, + ) + }) + .collect()) + } +} diff --git a/src/infrastructure/services/audio_metadata_service.rs b/src/infrastructure/services/audio_metadata_service.rs new file mode 100644 index 00000000..24dc9150 --- /dev/null +++ b/src/infrastructure/services/audio_metadata_service.rs @@ -0,0 +1,224 @@ +use id3::{Tag, TagLike}; +use sqlx::{FromRow, PgPool}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::common::errors::DomainError; + +#[derive(Debug, FromRow)] +pub struct AudioFileRow { + pub file_id: Uuid, + pub blob_hash: String, +} + +pub struct AudioMetadataService { + pool: Arc, + blob_root: PathBuf, +} + +impl AudioMetadataService { + pub fn new(pool: Arc, blob_root: PathBuf) -> Self { + Self { pool, blob_root } + } + + pub fn is_audio_file(mime_type: &str) -> bool { + mime_type.starts_with("audio/") + } + + pub fn spawn_extraction_background(service: Arc, file_id: Uuid, file_path: PathBuf) { + tokio::spawn(async move { + tracing::info!("🎵 Extracting audio metadata for: {}", file_id); + if let Err(e) = service.extract_and_save(&file_id, &file_path).await { + tracing::warn!("Failed to extract audio metadata: {}", e); + } + }); + } + + pub fn spawn_extraction_with_delete_background( + service: Arc, + file_id: Uuid, + file_path: PathBuf, + ) { + tokio::spawn(async move { + tracing::info!("🎵 Updating audio metadata for: {}", file_id); + let _ = service.delete_metadata(&file_id).await; + if let Err(e) = service.extract_and_save(&file_id, &file_path).await { + tracing::warn!("Failed to update audio metadata: {}", e); + } + }); + } + + fn blob_path(&self, hash: &str) -> PathBuf { + let prefix = &hash[0..2]; + self.blob_root.join(prefix).join(format!("{}.blob", hash)) + } + + fn get_duration_secs(file_path: &Path) -> i32 { + match mp3_duration::from_path(file_path) { + Ok(dur) => dur.as_secs_f64().round() as i32, + Err(_) => { + if let Ok(tag) = Tag::read_from_path(file_path) { + tag.duration().unwrap_or(0) as i32 + } else { + 0 + } + } + } + } + + pub async fn extract_and_save( + &self, + file_id: &Uuid, + file_path: &Path, + ) -> Result<(), DomainError> { + info!( + "AudioMetadataService: blob_root={:?}, file_id={}, file_path={:?}, exists={}", + self.blob_root, + file_id, + file_path, + file_path.exists() + ); + + if !file_path.exists() { + warn!("File does not exist: {:?}", file_path); + return Ok(()); + } + + let tag = match Tag::read_from_path(file_path) { + Ok(t) => t, + Err(e) => { + warn!("Failed to read ID3 tag from {:?}: {}", file_path, e); + return Ok(()); + } + }; + + let title = tag.title().map(|s| s.to_string()); + let artist = tag.artist().map(|s| s.to_string()); + let album = tag.album().map(|s| s.to_string()); + let genre = tag.genre().map(|s| s.to_string()); + let track_number: Option = tag.track().map(|n| n as i32); + let disc_number: Option = tag.disc().map(|n| n as i32); + let year: Option = tag.year(); + let duration_secs = Self::get_duration_secs(file_path); + + let album_artist = + tag.frames() + .find(|f| f.id() == "TPE2") + .and_then(|f| match f.content() { + id3::frame::Content::Text(t) => Some(t.clone()), + _ => None, + }); + + info!( + "Extracted audio metadata for file {}: title={:?}, artist={:?}, album={:?}, duration={}s", + file_id, title, artist, album, duration_secs + ); + + info!("Saving metadata to database for file_id={}", file_id); + + sqlx::query( + r#" + INSERT INTO audio.file_metadata + (file_id, title, artist, album, album_artist, genre, track_number, disc_number, + year, duration_secs, format) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (file_id) DO UPDATE SET + title = EXCLUDED.title, + artist = EXCLUDED.artist, + album = EXCLUDED.album, + album_artist = EXCLUDED.album_artist, + genre = EXCLUDED.genre, + track_number = EXCLUDED.track_number, + disc_number = EXCLUDED.disc_number, + year = EXCLUDED.year, + duration_secs = EXCLUDED.duration_secs, + format = EXCLUDED.format, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(file_id) + .bind(&title) + .bind(&artist) + .bind(&album) + .bind(&album_artist) + .bind(&genre) + .bind(track_number) + .bind(disc_number) + .bind(year) + .bind(duration_secs) + .bind("MPEG") + .execute(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to save audio metadata: {}", e)) + })?; + + Ok(()) + } + + pub async fn delete_metadata(&self, file_id: &Uuid) -> Result<(), DomainError> { + sqlx::query("DELETE FROM audio.file_metadata WHERE file_id = $1") + .bind(file_id) + .execute(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to delete audio metadata: {}", e)) + })?; + Ok(()) + } + + pub async fn reextract_all_audio_metadata( + &self, + ) -> Result { + let audio_files = sqlx::query_as::<_, AudioFileRow>( + r#" + SELECT id as file_id, blob_hash + FROM storage.files + WHERE mime_type LIKE 'audio/%' + "#, + ) + .fetch_all(&*self.pool) + .await + .map_err(|e| DomainError::database_error(format!("Failed to fetch audio files: {}", e)))?; + + let total = audio_files.len(); + let mut processed = 0; + let mut failed = 0; + + info!("Starting metadata extraction for {} audio files", total); + + for audio_file in audio_files { + let file_path = self.blob_path(&audio_file.blob_hash); + match self.extract_and_save(&audio_file.file_id, &file_path).await { + Ok(()) => processed += 1, + Err(e) => { + warn!( + "Failed to extract metadata for file {}: {}", + audio_file.file_id, e + ); + failed += 1; + } + } + } + + info!( + "Metadata extraction complete: {} processed, {} failed out of {} total", + processed, failed, total + ); + + Ok(MetadataExtractionResult { + total, + processed, + failed, + }) + } +} + +#[derive(Debug, serde::Serialize)] +pub struct MetadataExtractionResult { + pub total: usize, + pub processed: usize, + pub failed: usize, +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index da2f6983..500b6987 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -1,3 +1,4 @@ +pub mod audio_metadata_service; pub mod chunked_upload_service; pub mod compression_service; pub mod dedup_service; diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 6cf5a458..cef37682 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -39,6 +39,8 @@ pub fn admin_routes() -> Router> { // Registration control .route("/settings/registration", get(get_registration_setting)) .route("/settings/registration", put(set_registration_setting)) + // Audio metadata + .route("/audio/metadata/reextract", post(reextract_audio_metadata)) } /// Validate JWT and require admin role. Returns (user_id, role). @@ -587,3 +589,30 @@ async fn set_registration_setting( })), )) } + +async fn reextract_audio_metadata( + State(state): State>, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + + let audio_service = state + .applications + .audio_metadata_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Audio metadata service not available"))?; + + let result = audio_service + .reextract_all_audio_metadata() + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to re-extract audio metadata: {}", e)) + })?; + + Ok(Json(serde_json::json!({ + "message": "Audio metadata extraction complete", + "total": result.total, + "processed": result.processed, + "failed": result.failed, + }))) +} diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 658f526f..67810447 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -18,6 +18,7 @@ use crate::application::ports::file_ports::{ use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort}; use crate::application::ports::thumbnail_ports::ThumbnailPort; use crate::common::di::AppState; +use crate::infrastructure::services::audio_metadata_service::AudioMetadataService; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; use std::sync::Arc; @@ -748,6 +749,19 @@ impl FileHandler { }); } + // Extract audio metadata for supported audio files in background. + if let Some(ref audio_service) = state.applications.audio_metadata_service + && AudioMetadataService::is_audio_file(&file.mime_type) + && let Ok(file_id) = uuid::Uuid::parse_str(&file.id) + { + let file_path = state.core.dedup_service.blob_path(&blob_hash); + AudioMetadataService::spawn_extraction_background( + audio_service.clone(), + file_id, + file_path, + ); + } + Self::created_json_response(&file).into_response() } diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 0e7048e2..9681657e 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -11,6 +11,7 @@ pub mod favorites_handler; pub mod file_handler; pub mod folder_handler; pub mod i18n_handler; +pub mod music_handler; pub mod photos_handler; pub mod recent_handler; pub mod search_handler; diff --git a/src/interfaces/api/handlers/music_handler.rs b/src/interfaces/api/handlers/music_handler.rs new file mode 100644 index 00000000..191a0156 --- /dev/null +++ b/src/interfaces/api/handlers/music_handler.rs @@ -0,0 +1,207 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::Deserialize; + +use crate::application::dtos::playlist_dto::{ + AddTracksDto, CreatePlaylistDto, PlaylistQueryDto, ReorderTracksDto, SharePlaylistDto, + UpdatePlaylistDto, +}; +use crate::application::ports::music_ports::MusicUseCase; +use crate::application::services::music_service::MusicService; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::AuthUser; + +#[derive(Debug, Deserialize)] +pub struct PaginationQuery { + pub limit: Option, + pub offset: Option, +} + +pub async fn create_playlist( + State(music_service): State>, + auth_user: AuthUser, + Json(dto): Json, +) -> impl IntoResponse { + match music_service.create_playlist(dto, auth_user.id).await { + Ok(playlist) => (StatusCode::CREATED, Json(playlist)).into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +pub async fn get_playlist( + State(music_service): State>, + auth_user: AuthUser, + Path(playlist_id): Path, +) -> impl IntoResponse { + match music_service.get_playlist(&playlist_id, auth_user.id).await { + Ok(playlist) => (StatusCode::OK, Json(playlist)).into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +pub async fn list_playlists( + State(music_service): State>, + auth_user: AuthUser, + Query(query): Query, +) -> impl IntoResponse { + match music_service.list_playlists(query, auth_user.id).await { + Ok(playlists) => (StatusCode::OK, Json(playlists)).into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +#[derive(Debug, Deserialize)] +pub struct IncludeSharedQuery { + pub include_shared: Option, + pub include_public: Option, +} + +pub async fn update_playlist( + State(music_service): State>, + auth_user: AuthUser, + Path(playlist_id): Path, + Json(dto): Json, +) -> impl IntoResponse { + match music_service + .update_playlist(&playlist_id, dto, auth_user.id) + .await + { + Ok(playlist) => (StatusCode::OK, Json(playlist)).into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +pub async fn delete_playlist( + State(music_service): State>, + auth_user: AuthUser, + Path(playlist_id): Path, +) -> impl IntoResponse { + match music_service + .delete_playlist(&playlist_id, auth_user.id) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +pub async fn add_tracks( + State(music_service): State>, + auth_user: AuthUser, + Path(playlist_id): Path, + Json(dto): Json, +) -> impl IntoResponse { + match music_service + .add_tracks(&playlist_id, dto, auth_user.id) + .await + { + Ok(tracks) => (StatusCode::CREATED, Json(tracks)).into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +pub async fn remove_track( + State(music_service): State>, + auth_user: AuthUser, + Path((playlist_id, file_id)): Path<(String, String)>, +) -> impl IntoResponse { + match music_service + .remove_track(&playlist_id, &file_id, auth_user.id) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +pub async fn reorder_tracks( + State(music_service): State>, + auth_user: AuthUser, + Path(playlist_id): Path, + Json(dto): Json, +) -> impl IntoResponse { + match music_service + .reorder_tracks(&playlist_id, dto, auth_user.id) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +pub async fn list_playlist_tracks( + State(music_service): State>, + auth_user: AuthUser, + Path(playlist_id): Path, +) -> impl IntoResponse { + match music_service + .list_playlist_tracks(&playlist_id, auth_user.id) + .await + { + Ok(tracks) => (StatusCode::OK, Json(tracks)).into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +pub async fn share_playlist( + State(music_service): State>, + auth_user: AuthUser, + Path(playlist_id): Path, + Json(dto): Json, +) -> impl IntoResponse { + match music_service + .share_playlist(&playlist_id, dto, auth_user.id) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +pub async fn remove_share( + State(music_service): State>, + auth_user: AuthUser, + Path((playlist_id, user_id)): Path<(String, String)>, +) -> impl IntoResponse { + match music_service + .remove_share(&playlist_id, &user_id, auth_user.id) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +pub async fn get_playlist_shares( + State(music_service): State>, + auth_user: AuthUser, + Path(playlist_id): Path, +) -> impl IntoResponse { + match music_service + .get_playlist_shares(&playlist_id, auth_user.id) + .await + { + Ok(shares) => (StatusCode::OK, Json(shares)).into_response(), + Err(err) => AppError::from(err).into_response(), + } +} + +pub async fn get_audio_metadata( + State(music_service): State>, + auth_user: AuthUser, + Path(file_id): Path, +) -> impl IntoResponse { + match music_service + .get_audio_metadata(&file_id, auth_user.id) + .await + { + Ok(metadata) => (StatusCode::OK, Json(metadata)).into_response(), + Err(err) => AppError::from(err).into_response(), + } +} diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 8516db33..826c8664 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -27,6 +27,7 @@ use crate::application::ports::storage_ports::StorageUsagePort; use crate::application::services::file_retrieval_service::FileRetrievalService; use crate::application::services::folder_service::FolderService; use crate::common::di::AppState; +use crate::infrastructure::services::audio_metadata_service::AudioMetadataService; use crate::infrastructure::services::path_resolver_service::ResolvedResource; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; @@ -957,10 +958,25 @@ async fn handle_put( let _ = tokio::fs::remove_file(&temp_path).await; match result { - Ok(_) => Ok(Response::builder() - .status(StatusCode::NO_CONTENT) - .body(Body::empty()) - .unwrap()), + Ok(file_dto) => { + // Extract audio metadata for supported audio files in background. + if let Some(ref audio_service) = state.applications.audio_metadata_service + && AudioMetadataService::is_audio_file(&file_dto.mime_type) + && let Ok(file_id) = Uuid::parse_str(&file_dto.id) + { + let file_path = state.core.dedup_service.blob_path(&file_dto.etag); + AudioMetadataService::spawn_extraction_background( + audio_service.clone(), + file_id, + file_path, + ); + } + + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap()) + } Err(e) => Err(AppError::internal_error(format!( "Failed to put file: {}", e diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index f6edf151..2b323019 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -354,6 +354,44 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { tracing::warn!("Trash service not available - trash view will not work"); } + // Music/Playlist routes + if let Some(ref music_svc) = app_state.music_service { + use crate::interfaces::api::handlers::music_handler; + + let music_router = Router::new() + .route("/", post(music_handler::create_playlist)) + .route("/", get(music_handler::list_playlists)) + .route("/{playlist_id}", get(music_handler::get_playlist)) + .route("/{playlist_id}", put(music_handler::update_playlist)) + .route( + "/{playlist_id}", + axum::routing::delete(music_handler::delete_playlist), + ) + .route( + "/{playlist_id}/tracks", + get(music_handler::list_playlist_tracks), + ) + .route("/{playlist_id}/tracks", post(music_handler::add_tracks)) + .route( + "/{playlist_id}/tracks/{file_id}", + axum::routing::delete(music_handler::remove_track), + ) + .route("/{playlist_id}/reorder", put(music_handler::reorder_tracks)) + .route("/{playlist_id}/share", post(music_handler::share_playlist)) + .route( + "/{playlist_id}/share/{user_id}", + axum::routing::delete(music_handler::remove_share), + ) + .route( + "/{playlist_id}/shares", + get(music_handler::get_playlist_shares), + ) + .with_state(music_svc.clone()); + + router = router.nest("/playlists", music_router); + tracing::info!("Music routes initialized"); + } + // NOTE: WebDAV routes are mounted at top-level (/webdav) in main.rs // for client compatibility, NOT under /api. diff --git a/src/interfaces/nextcloud/uploads_handler.rs b/src/interfaces/nextcloud/uploads_handler.rs index 8f14f8df..e5227ce6 100644 --- a/src/interfaces/nextcloud/uploads_handler.rs +++ b/src/interfaces/nextcloud/uploads_handler.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase}; use crate::common::di::AppState; use crate::common::mime_detect::{filename_from_path, refine_content_type_from_file}; +use crate::infrastructure::services::audio_metadata_service::AudioMetadataService; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; @@ -184,6 +185,20 @@ async fn handle_assemble( .create_file(parent_internal, filename, &assembled, &content_type) .await .map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?; + + // Extract audio metadata for supported audio files in background. + if let Some(ref audio_service) = state.applications.audio_metadata_service + && AudioMetadataService::is_audio_file(&dto.mime_type) + && let Ok(file_id) = uuid::Uuid::parse_str(&dto.id) + { + let file_path = state.core.dedup_service.blob_path(&dto.etag); + AudioMetadataService::spawn_extraction_background( + audio_service.clone(), + file_id, + file_path, + ); + } + Some(dto.etag) }; diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 5377bccc..c78e9b52 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -21,6 +21,7 @@ use crate::application::ports::inbound::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; use crate::common::mime_detect::{filename_from_path, refine_content_type}; +use crate::infrastructure::services::audio_metadata_service::AudioMetadataService; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; @@ -551,6 +552,22 @@ async fn handle_put( .await .map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?; + // Update audio metadata for supported audio files. + if let Some(ref audio_service) = state.applications.audio_metadata_service + && let Ok(file_id) = uuid::Uuid::parse_str(&updated.id) + { + let file_path = state.core.dedup_service.blob_path(&updated.etag); + let is_audio = AudioMetadataService::is_audio_file(&content_type); + + if is_audio { + AudioMetadataService::spawn_extraction_with_delete_background( + audio_service.clone(), + file_id, + file_path, + ); + } + } + return Ok(Response::builder() .status(StatusCode::NO_CONTENT) .header(header::ETAG, format!("\"{}\"", updated.etag)) @@ -572,6 +589,19 @@ async fn handle_put( .await .map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?; + // Extract audio metadata for supported audio files in background. + if let Some(ref audio_service) = state.applications.audio_metadata_service + && AudioMetadataService::is_audio_file(&file_dto.mime_type) + && let Ok(file_id) = uuid::Uuid::parse_str(&file_dto.id) + { + let file_path = state.core.dedup_service.blob_path(&file_dto.etag); + AudioMetadataService::spawn_extraction_background( + audio_service.clone(), + file_id, + file_path, + ); + } + let builder = Response::builder() .status(StatusCode::CREATED) .header(header::ETAG, format!("\"{}\"", file_dto.etag)) diff --git a/static/css/components/dialogs.css b/static/css/components/dialogs.css index a450b049..dc7c7244 100644 --- a/static/css/components/dialogs.css +++ b/static/css/components/dialogs.css @@ -497,6 +497,13 @@ font-size: 20px; } +/* Playlist track count in selector */ +.playlist-track-count { + margin-left: auto; + font-size: 12px; + color: #718096; +} + /* Custom confirm dialog */ .confirm-dialog { position: fixed; diff --git a/static/css/layout/sidebar.css b/static/css/layout/sidebar.css index bc568f7d..06a95d76 100644 --- a/static/css/layout/sidebar.css +++ b/static/css/layout/sidebar.css @@ -148,6 +148,7 @@ } .nav-item i, +.nav-item .nav-icon, .nav-item .oxi-icon { margin-right: 14px; width: 20px; diff --git a/static/css/views/music.css b/static/css/views/music.css new file mode 100644 index 00000000..8bb0c601 --- /dev/null +++ b/static/css/views/music.css @@ -0,0 +1,896 @@ +/* Music library view */ +.music-container { + display: none; + padding: 0; +} + +.music-container.active { + display: block; +} + +.music-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 8px; +} + +.music-content { + display: flex; + gap: 0; + height: calc(100vh - 200px); + min-height: 400px; +} + +.music-sidebar { + width: 280px; + min-width: 280px; + border-right: 1px solid var(--border-color, #e2e8f0); + display: flex; + flex-direction: column; + background: var(--bg-secondary, #f8fafc); +} + +.music-sidebar-header { + padding: 16px; + border-bottom: 1px solid var(--border-color, #e2e8f0); +} + +.music-sidebar-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-secondary, #64748b); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.music-playlist-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.music-playlist-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s ease; +} + +.music-playlist-item:hover { + background: var(--hover-bg, #e2e8f0); +} + +.music-playlist-item.active { + background: var(--active-bg, #dbeafe); +} + +.music-playlist-icon { + width: 40px; + height: 40px; + border-radius: 6px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 16px; +} + +.music-playlist-item-info { + flex: 1; + min-width: 0; +} + +.music-playlist-item-name { + display: block; + font-weight: 500; + color: var(--text-primary, #1e293b); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.music-playlist-item-count { + display: block; + font-size: 12px; + color: var(--text-secondary, #64748b); +} + +.music-main { + flex: 1; + padding: 24px; + overflow-y: auto; +} + +.music-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: var(--text-secondary, #64748b); +} + +.music-welcome i { + font-size: 64px; + margin-bottom: 20px; + color: var(--text-muted, #cbd5e1); +} + +.music-welcome h3 { + margin: 0 0 8px; + font-size: 20px; + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +.music-welcome p { + margin: 0; + font-size: 14px; +} + +.music-playlist-detail { + max-width: 900px; +} + +.music-playlist-header { + display: flex; + align-items: center; + gap: 24px; + margin-bottom: 24px; +} + +.music-playlist-cover { + width: 180px; + height: 180px; + border-radius: 12px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 48px; + box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3); +} + +.music-playlist-info { + flex: 1; +} + +.music-playlist-info h2 { + margin: 0 0 8px; + font-size: 28px; + font-weight: 700; + color: var(--text-primary, #1e293b); +} + +.music-playlist-info p { + margin: 0; + font-size: 14px; + color: var(--text-secondary, #64748b); +} + +.music-playlist-actions { + display: flex; + gap: 12px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.music-track-list { + background: var(--bg-secondary, #f8fafc); + border-radius: 12px; + overflow: hidden; +} + +.music-track-header { + display: flex; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color, #e2e8f0); + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #64748b); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.music-track { + display: flex; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--border-color, #e2e8f0); + cursor: pointer; + transition: background 0.15s ease; +} + +.music-track:last-child { + border-bottom: none; +} + +.music-track:hover { + background: var(--hover-bg, #f1f5f9); +} + +.music-track-col { + padding: 0 8px; +} + +.music-track-num { + width: 40px; + text-align: center; + color: var(--text-secondary, #64748b); + font-size: 14px; +} + +.music-track-title { + flex: 2; + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.music-track-icon { + color: var(--text-secondary, #64748b); + font-size: 14px; +} + +.music-track-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + color: var(--text-primary, #1e293b); +} + +.music-track-artist { + flex: 1; + color: var(--text-secondary, #64748b); + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.music-track-album { + flex: 1; + color: var(--text-secondary, #64748b); + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.music-track-duration { + width: 60px; + text-align: right; + color: var(--text-secondary, #64748b); + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.music-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + color: var(--text-secondary, #64748b); +} + +.music-empty i { + font-size: 48px; + margin-bottom: 16px; + color: var(--text-muted, #cbd5e1); +} + +.music-empty p { + margin: 0; + font-size: 14px; +} + +.music-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + color: var(--text-secondary, #64748b); + font-size: 14px; + gap: 8px; +} + +.music-loading i { + animation: spin 1s linear infinite; +} + +.music-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + text-align: center; + color: var(--error-color, #ef4444); +} + +.music-error i { + font-size: 48px; + margin-bottom: 16px; +} + +.music-error p { + margin: 0; + font-size: 14px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Dark theme */ +[data-theme="dark"] .music-sidebar { + background: var(--bg-secondary, #1e293b); + border-color: var(--border-color, #334155); +} + +[data-theme="dark"] .music-playlist-item-name { + color: var(--text-primary, #f1f5f9); +} + +[data-theme="dark"] .music-welcome h3 { + color: var(--text-primary, #f1f5f9); +} + +[data-theme="dark"] .music-playlist-info h2 { + color: var(--text-primary, #f1f5f9); +} + +[data-theme="dark"] .music-track-name { + color: var(--text-primary, #f1f5f9); +} + +[data-theme="dark"] .music-track-list { + background: var(--bg-secondary, #1e293b); +} + +/* Responsive */ +@media (max-width: 768px) { + .music-content { + flex-direction: column; + height: auto; + } + + .music-sidebar { + width: 100%; + min-width: 100%; + border-right: none; + border-bottom: 1px solid var(--border-color, #e2e8f0); + max-height: 200px; + } + + .music-playlist-header { + flex-direction: column; + align-items: flex-start; + } + + .music-playlist-cover { + width: 120px; + height: 120px; + font-size: 32px; + } + + .music-playlist-info h2 { + font-size: 20px; + } + + .music-track-album { + display: none; + } +} + +/* ═══════════════════════════════════════════════════════════════ + MUSIC PLAYER + ═══════════════════════════════════════════════════════════════ */ + +.music-player { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 90px; + background: var(--bg-primary, #fff); + border-top: 1px solid var(--border-color, #e2e8f0); + display: flex; + align-items: center; + padding: 0 16px; + gap: 16px; + z-index: 1000; + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1); + transform: translateY(100%); + transition: transform 0.3s ease; +} + +.music-player.has-track { + transform: translateY(0); +} + +.music-player.hidden { + display: none; +} + +/* Track Info */ +.player-track-info { + display: flex; + align-items: center; + gap: 12px; + min-width: 180px; + max-width: 240px; +} + +.player-album-art { + width: 56px; + height: 56px; + border-radius: 8px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 20px; + flex-shrink: 0; +} + +.player-track-details { + display: flex; + flex-direction: column; + min-width: 0; +} + +.player-track-name { + font-weight: 500; + color: var(--text-primary, #1e293b); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; +} + +.player-track-artist { + font-size: 12px; + color: var(--text-secondary, #64748b); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; +} + +/* Player Controls */ +.player-controls { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + max-width: 600px; +} + +.player-buttons { + display: flex; + align-items: center; + gap: 8px; +} + +.player-btn { + background: none; + border: none; + cursor: pointer; + padding: 8px; + border-radius: 50%; + color: var(--text-secondary, #64748b); + font-size: 16px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.player-btn:hover { + color: var(--text-primary, #1e293b); + background: var(--hover-bg, #f1f5f9); +} + +.player-btn.active { + color: var(--primary-color, #667eea); +} + +.player-btn-main { + background: var(--primary-color, #667eea); + color: #fff !important; + font-size: 21px; +} + +.player-btn-main:hover { + background: var(--primary-color-dark, #5a67d8); + transform: scale(1.05); +} + +.player-btn-small { + padding: 6px; + font-size: 14px; +} + +/* Progress Bar */ +.player-progress { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} + +.player-time { + font-size: 11px; + color: var(--text-secondary, #64748b); + font-variant-numeric: tabular-nums; + min-width: 40px; +} + +.player-time-current { + text-align: right; +} + +.player-time-total { + text-align: left; +} + +.player-progress-bar { + flex: 1; + height: 4px; + background: var(--border-color, #e2e8f0); + border-radius: 2px; + cursor: pointer; + position: relative; + overflow: visible; +} + +.player-progress-fill { + height: 100%; + background: var(--primary-color, #667eea); + border-radius: 2px; + transition: width 0.1s linear; + width: 0; +} + +.player-progress-handle { + position: absolute; + top: 50%; + left: 0; + width: 12px; + height: 12px; + background: var(--primary-color, #667eea); + border-radius: 50%; + transform: translate(-50%, -50%); + opacity: 0; + transition: opacity 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.player-progress-bar:hover .player-progress-handle { + opacity: 1; +} + +.player-progress-bar:hover .player-progress-fill { + height: 6px; + margin-top: -1px; +} + +/* Volume Controls */ +.player-extra { + display: flex; + align-items: center; + gap: 8px; + min-width: 140px; + justify-content: flex-end; +} + +.player-volume-slider { + width: 80px; + display: flex; + align-items: center; +} + +#player-volume-input { + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--border-color, #e2e8f0); + border-radius: 2px; + cursor: pointer; +} + +#player-volume-input::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + background: var(--primary-color, #667eea); + border-radius: 50%; + cursor: pointer; +} + +#player-volume-input::-moz-range-thumb { + width: 12px; + height: 12px; + background: var(--primary-color, #667eea); + border-radius: 50%; + cursor: pointer; + border: none; +} + +/* Queue Panel */ +.player-queue { + position: fixed; + bottom: 100px; + right: 16px; + width: 320px; + max-height: 400px; + background: var(--bg-primary, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; + z-index: 1001; + overflow: hidden; +} + +.player-queue.hidden { + display: none; +} + +.player-queue-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color, #e2e8f0); +} + +.player-queue-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +.player-queue-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.player-queue-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s ease; +} + +.player-queue-item:hover { + background: var(--hover-bg, #f1f5f9); +} + +.player-queue-item.active { + background: var(--active-bg, #dbeafe); +} + +.queue-item-num { + width: 20px; + text-align: center; + font-size: 12px; + color: var(--text-secondary, #64748b); +} + +.player-queue-item.active .queue-item-num { + color: var(--primary-color, #667eea); + font-weight: 600; +} + +.queue-item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.queue-item-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary, #1e293b); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.queue-item-artist { + font-size: 11px; + color: var(--text-secondary, #64748b); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.queue-item-duration { + font-size: 11px; + color: var(--text-secondary, #64748b); +} + +.queue-item-remove { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--text-secondary, #64748b); + opacity: 0; + transition: opacity 0.15s ease; +} + +.player-queue-item:hover .queue-item-remove { + opacity: 1; +} + +.queue-item-remove:hover { + color: var(--error-color, #ef4444); +} + +.player-queue-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + color: var(--text-secondary, #64748b); +} + +.player-queue-empty i { + font-size: 32px; + margin-bottom: 12px; + color: var(--text-muted, #cbd5e1); +} + +/* Track playing indicator */ +.music-track.playing { + background: var(--active-bg, #dbeafe); +} + +.music-track.playing .music-track-name { + color: var(--primary-color, #667eea); +} + +.track-num-text.hidden { + display: none; +} + +.track-play-icon { + display: none; + color: var(--primary-color, #667eea); +} + +/* Repeat one icon */ +.player-btn.repeat-one i::after { + content: '1'; + font-size: 8px; + position: absolute; + top: 0; + right: 0; +} + +/* Dark theme for player */ +[data-theme="dark"] .music-player { + background: var(--bg-secondary, #1e293b); + border-color: var(--border-color, #334155); +} + +[data-theme="dark"] .player-track-name { + color: var(--text-primary, #f1f5f9); +} + +[data-theme="dark"] .player-queue { + background: var(--bg-secondary, #1e293b); + border-color: var(--border-color, #334155); +} + +[data-theme="dark"] .player-queue-header { + border-color: var(--border-color, #334155); +} + +[data-theme="dark"] .player-queue-header h3 { + color: var(--text-primary, #f1f5f9); +} + +[data-theme="dark"] .queue-item-name { + color: var(--text-primary, #f1f5f9); +} + +[data-theme="dark"] #player-volume-input { + background: var(--border-color, #334155); +} + +[data-theme="dark"] .player-progress-bar { + background: var(--border-color, #334155); +} + +/* Mobile player adjustments */ +@media (max-width: 768px) { + .music-player { + height: 70px; + padding: 0 8px; + gap: 8px; + } + + .player-track-info { + min-width: auto; + max-width: 120px; + } + + .player-album-art { + width: 44px; + height: 44px; + font-size: 16px; + } + + .player-track-name { + max-width: 70px; + font-size: 12px; + } + + .player-track-artist { + max-width: 70px; + font-size: 10px; + } + + .player-controls { + max-width: none; + } + + .player-buttons { + gap: 4px; + } + + .player-btn { + padding: 6px; + font-size: 14px; + } + + .player-btn-main { + width: 36px; + height: 36px; + font-size: 16px; + } + + .player-extra { + min-width: auto; + } + + .player-volume-slider { + display: none; + } + + .player-queue { + right: 8px; + left: 8px; + width: auto; + } +} diff --git a/static/index.html b/static/index.html index a4af491e..efde5626 100644 --- a/static/index.html +++ b/static/index.html @@ -16,6 +16,7 @@ + @@ -36,6 +37,7 @@ + @@ -89,6 +91,14 @@ Photos +