Music Player & Playlist Manager

This commit is contained in:
Andrey Tkachenko
2026-04-08 15:14:03 +03:00
parent 2e8e0ef6c5
commit da066f47fa
53 changed files with 6043 additions and 23 deletions
Generated
+55 -13
View File
@@ -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]]
+3
View File
@@ -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"
@@ -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';
+1
View File
@@ -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;
+224
View File
@@ -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<String>,
pub owner_id: String,
pub is_public: bool,
pub cover_file_id: Option<String>,
pub track_count: Option<i64>,
pub total_duration_secs: Option<i32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
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<Playlist> 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<Utc>,
pub file_name: Option<String>,
pub file_size: Option<i64>,
pub mime_type: Option<String>,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub duration_secs: Option<i32>,
}
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<PlaylistItem> 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<String>,
pub is_public: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdatePlaylistDto {
pub name: Option<String>,
pub description: Option<String>,
pub is_public: Option<bool>,
pub cover_file_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AddTracksDto {
pub file_ids: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ReorderTracksDto {
pub item_ids: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SharePlaylistDto {
pub user_id: String,
pub can_write: Option<bool>,
}
#[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<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub album_artist: Option<String>,
pub genre: Option<String>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub year: Option<i32>,
pub duration_secs: i32,
pub bitrate: Option<i32>,
pub sample_rate: Option<i32>,
pub channels: Option<i16>,
pub format: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
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<AudioFileMetadata> 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<bool>,
pub include_public: Option<bool>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
+1
View File
@@ -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;
+158
View File
@@ -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<PlaylistDto, DomainError>;
async fn update_playlist(
&self,
playlist_id: &str,
dto: UpdatePlaylistDto,
user_id: Uuid,
) -> Result<PlaylistDto, DomainError>;
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<PlaylistDto, DomainError>;
async fn list_playlists(
&self,
query: PlaylistQueryDto,
user_id: Uuid,
) -> Result<Vec<PlaylistDto>, DomainError>;
async fn add_tracks(
&self,
playlist_id: &str,
dto: AddTracksDto,
user_id: Uuid,
) -> Result<Vec<PlaylistItemDto>, 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<Vec<PlaylistItemDto>, 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<Vec<PlaylistShareInfoDto>, DomainError>;
async fn get_audio_metadata(
&self,
file_id: &str,
user_id: Uuid,
) -> Result<Option<AudioMetadataDto>, DomainError>;
}
pub trait MusicStoragePort: Send + Sync {
async fn create_playlist(
&self,
dto: CreatePlaylistDto,
user_id: Uuid,
) -> Result<PlaylistDto, DomainError>;
async fn update_playlist(
&self,
playlist_id: &str,
dto: UpdatePlaylistDto,
) -> Result<PlaylistDto, DomainError>;
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), DomainError>;
async fn get_playlist(&self, playlist_id: &str) -> Result<Option<PlaylistDto>, DomainError>;
async fn list_playlists_by_owner(
&self,
owner_id: Uuid,
) -> Result<Vec<PlaylistDto>, DomainError>;
async fn list_shared_with_user(&self, user_id: Uuid) -> Result<Vec<PlaylistDto>, DomainError>;
async fn list_public_playlists(
&self,
limit: i64,
offset: i64,
) -> Result<Vec<PlaylistDto>, DomainError>;
async fn user_has_access(&self, playlist_id: &str, user_id: Uuid) -> Result<bool, DomainError>;
async fn user_can_write(&self, playlist_id: &str, user_id: Uuid) -> Result<bool, DomainError>;
async fn add_tracks(
&self,
playlist_id: &Uuid,
file_ids: &[Uuid],
) -> Result<Vec<PlaylistItemDto>, 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<Vec<PlaylistItemDto>, 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<Vec<(Uuid, bool)>, DomainError>;
async fn get_audio_metadata(
&self,
file_id: &Uuid,
) -> Result<Option<AudioMetadataDto>, DomainError>;
}
+1
View File
@@ -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;
+384
View File
@@ -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<MusicStorageAdapter>,
}
impl MusicService {
pub fn new(storage: Arc<MusicStorageAdapter>) -> Self {
Self { storage }
}
}
impl MusicUseCase for MusicService {
async fn create_playlist(
&self,
dto: CreatePlaylistDto,
user_id: Uuid,
) -> Result<PlaylistDto, DomainError> {
self.storage.create_playlist(dto, user_id).await
}
async fn update_playlist(
&self,
playlist_id: &str,
dto: UpdatePlaylistDto,
user_id: Uuid,
) -> Result<PlaylistDto, DomainError> {
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<PlaylistDto, DomainError> {
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<Vec<PlaylistDto>, 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<Vec<PlaylistItemDto>, 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<Vec<Uuid>, _> =
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<Vec<Uuid>, _> =
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<Vec<PlaylistItemDto>, 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<Vec<PlaylistShareInfoDto>, 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<Option<AudioMetadataDto>, 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
}
}
+8
View File
@@ -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::<bool>())
&& 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::<usize>())
&& let Ok(val) = max_upload
+48 -3
View File
@@ -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<Arc<TrashService>>,
db_pool: &Arc<PgPool>,
) -> 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<PgPool>,
) -> Option<Arc<AudioMetadataService>> {
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<PlaylistPgRepository> =
Arc::new(PlaylistPgRepository::new(pool.clone()));
let item_repo: Arc<PlaylistItemPgRepository> =
Arc::new(PlaylistItemPgRepository::new(pool.clone()));
let audio_metadata_repo: Arc<AudioMetadataPgRepository> =
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<Arc<ShareService>>,
pub favorites_service: Option<Arc<FavoritesService>>,
pub recent_service: Option<Arc<RecentService>>,
pub audio_metadata_service: Option<Arc<AudioMetadataService>>,
}
/// Container for authentication services
@@ -889,6 +933,7 @@ pub struct AppState {
pub calendar_use_case: Option<Arc<CalendarService>>,
pub addressbook_use_case: Option<Arc<ContactStorageAdapter>>,
pub contact_use_case: Option<Arc<ContactStorageAdapter>>,
pub music_service: Option<Arc<MusicService>>,
pub wopi_token_service:
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
pub wopi_lock_service:
+1
View File
@@ -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;
+470
View File
@@ -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<String>,
owner_id: Uuid,
is_public: bool,
cover_file_id: Option<Uuid>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl Playlist {
pub fn new(name: String, owner_id: Uuid, description: Option<String>) -> Result<Self> {
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<String>,
owner_id: Uuid,
is_public: bool,
cover_file_id: Option<Uuid>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
) -> Result<Self> {
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<Utc> {
&self.created_at
}
pub fn updated_at(&self) -> &DateTime<Utc> {
&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<String>) {
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<Uuid>) {
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<Utc>,
}
impl PlaylistItem {
pub fn new(playlist_id: Uuid, file_id: Uuid, position: i32) -> Result<Self> {
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<Utc>,
) -> Result<Self> {
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<Utc> {
&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<String>,
artist: Option<String>,
album: Option<String>,
album_artist: Option<String>,
genre: Option<String>,
track_number: Option<i32>,
disc_number: Option<i32>,
year: Option<i32>,
duration_secs: i32,
bitrate: Option<i32>,
sample_rate: Option<i32>,
channels: Option<i16>,
format: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
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<String>,
artist: Option<String>,
album: Option<String>,
album_artist: Option<String>,
genre: Option<String>,
track_number: Option<i32>,
disc_number: Option<i32>,
year: Option<i32>,
duration_secs: i32,
bitrate: Option<i32>,
sample_rate: Option<i32>,
channels: Option<i16>,
format: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
) -> 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<i32> {
self.track_number
}
pub fn disc_number(&self) -> Option<i32> {
self.disc_number
}
pub fn year(&self) -> Option<i32> {
self.year
}
pub fn duration_secs(&self) -> i32 {
self.duration_secs
}
pub fn bitrate(&self) -> Option<i32> {
self.bitrate
}
pub fn sample_rate(&self) -> Option<i32> {
self.sample_rate
}
pub fn channels(&self) -> Option<i16> {
self.channels
}
pub fn format(&self) -> Option<&str> {
self.format.as_deref()
}
pub fn created_at(&self) -> &DateTime<Utc> {
&self.created_at
}
pub fn updated_at(&self) -> &DateTime<Utc> {
&self.updated_at
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
self.updated_at = Utc::now();
}
pub fn set_artist(&mut self, artist: Option<String>) {
self.artist = artist;
self.updated_at = Utc::now();
}
pub fn set_album(&mut self, album: Option<String>) {
self.album = album;
self.updated_at = Utc::now();
}
pub fn set_album_artist(&mut self, album_artist: Option<String>) {
self.album_artist = album_artist;
self.updated_at = Utc::now();
}
pub fn set_genre(&mut self, genre: Option<String>) {
self.genre = genre;
self.updated_at = Utc::now();
}
pub fn set_track_number(&mut self, track_number: Option<i32>) {
self.track_number = track_number;
self.updated_at = Utc::now();
}
pub fn set_disc_number(&mut self, disc_number: Option<i32>) {
self.disc_number = disc_number;
self.updated_at = Utc::now();
}
pub fn set_year(&mut self, year: Option<i32>) {
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());
}
}
+1
View File
@@ -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;
@@ -0,0 +1,115 @@
use crate::common::errors::DomainError;
use crate::domain::entities::playlist::{AudioFileMetadata, Playlist, PlaylistItem};
use uuid::Uuid;
pub type PlaylistRepositoryResult<T> = Result<T, DomainError>;
pub trait PlaylistRepository: Send + Sync + 'static {
async fn create_playlist(&self, playlist: Playlist) -> PlaylistRepositoryResult<Playlist>;
async fn update_playlist(&self, playlist: Playlist) -> PlaylistRepositoryResult<Playlist>;
async fn delete_playlist(&self, id: &Uuid) -> PlaylistRepositoryResult<()>;
async fn find_playlist_by_id(&self, id: &Uuid) -> PlaylistRepositoryResult<Playlist>;
async fn list_playlists_by_owner(
&self,
owner_id: Uuid,
) -> PlaylistRepositoryResult<Vec<Playlist>>;
async fn list_public_playlists(
&self,
limit: i64,
offset: i64,
) -> PlaylistRepositoryResult<Vec<Playlist>>;
async fn list_shared_with_user(&self, user_id: Uuid)
-> PlaylistRepositoryResult<Vec<Playlist>>;
async fn user_has_access(
&self,
playlist_id: &Uuid,
user_id: Uuid,
) -> PlaylistRepositoryResult<bool>;
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<Vec<(Uuid, bool)>>;
}
pub type PlaylistItemRepositoryResult<T> = Result<T, DomainError>;
pub trait PlaylistItemRepository: Send + Sync + 'static {
async fn add_item(&self, item: PlaylistItem) -> PlaylistItemRepositoryResult<PlaylistItem>;
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<PlaylistItem>;
async fn list_items_in_playlist(
&self,
playlist_id: &Uuid,
) -> PlaylistItemRepositoryResult<Vec<PlaylistItem>>;
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<i64>;
async fn get_max_position(&self, playlist_id: &Uuid) -> PlaylistItemRepositoryResult<i32>;
}
pub type AudioMetadataRepositoryResult<T> = Result<T, DomainError>;
pub trait AudioMetadataRepository: Send + Sync + 'static {
async fn create_or_update(
&self,
metadata: AudioFileMetadata,
) -> AudioMetadataRepositoryResult<AudioFileMetadata>;
async fn find_by_file_id(
&self,
file_id: &Uuid,
) -> AudioMetadataRepositoryResult<Option<AudioFileMetadata>>;
async fn delete(&self, file_id: &Uuid) -> AudioMetadataRepositoryResult<()>;
async fn list_by_artist(
&self,
artist: &str,
) -> AudioMetadataRepositoryResult<Vec<AudioFileMetadata>>;
async fn list_by_album(
&self,
album: &str,
) -> AudioMetadataRepositoryResult<Vec<AudioFileMetadata>>;
async fn list_by_genre(
&self,
genre: &str,
) -> AudioMetadataRepositoryResult<Vec<AudioFileMetadata>>;
}
+2
View File
@@ -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;
@@ -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<PlaylistPgRepository>,
item_repository: Arc<PlaylistItemPgRepository>,
audio_metadata_repository: Arc<AudioMetadataPgRepository>,
}
impl MusicStorageAdapter {
pub fn new(
playlist_repository: Arc<PlaylistPgRepository>,
item_repository: Arc<PlaylistItemPgRepository>,
audio_metadata_repository: Arc<AudioMetadataPgRepository>,
) -> Self {
Self {
playlist_repository,
item_repository,
audio_metadata_repository,
}
}
}
impl MusicStoragePort for MusicStorageAdapter {
async fn create_playlist(
&self,
dto: CreatePlaylistDto,
user_id: Uuid,
) -> Result<PlaylistDto, DomainError> {
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<PlaylistDto, DomainError> {
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<Option<PlaylistDto>, 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<Vec<PlaylistDto>, 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<Vec<PlaylistDto>, 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<Vec<PlaylistDto>, 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<bool, DomainError> {
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<bool, DomainError> {
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<Vec<PlaylistItemDto>, 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<Vec<PlaylistItemDto>, DomainError> {
let enriched_items = self
.item_repository
.list_items_in_playlist_enriched(playlist_id)
.await?;
let items: Vec<PlaylistItemDto> = 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<Vec<(Uuid, bool)>, DomainError> {
self.playlist_repository.get_shares(playlist_id).await
}
async fn get_audio_metadata(
&self,
file_id: &Uuid,
) -> Result<Option<AudioMetadataDto>, 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<i64, DomainError> {
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)
}
}
@@ -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;
@@ -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<String>,
owner_id: Uuid,
is_public: bool,
cover_file_id: Option<Uuid>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(FromRow)]
struct PlaylistItemRow {
id: Uuid,
playlist_id: Uuid,
file_id: Uuid,
position: i32,
added_at: DateTime<Utc>,
}
#[derive(FromRow)]
struct AudioMetadataRow {
file_id: Uuid,
title: Option<String>,
artist: Option<String>,
album: Option<String>,
album_artist: Option<String>,
genre: Option<String>,
track_number: Option<i32>,
disc_number: Option<i32>,
year: Option<i32>,
duration_secs: i32,
bitrate: Option<i32>,
sample_rate: Option<i32>,
channels: Option<i16>,
format: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(FromRow)]
pub struct PlaylistItemEnrichedRow {
pub id: Uuid,
pub playlist_id: Uuid,
pub file_id: Uuid,
pub position: i32,
pub added_at: DateTime<Utc>,
pub file_name: Option<String>,
pub file_size: Option<i64>,
pub mime_type: Option<String>,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub duration_secs: Option<i32>,
}
pub struct PlaylistPgRepository {
pool: Arc<PgPool>,
}
impl PlaylistPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
pub fn pool(&self) -> &PgPool {
&self.pool
}
}
impl PlaylistRepository for PlaylistPgRepository {
async fn create_playlist(&self, playlist: Playlist) -> PlaylistRepositoryResult<Playlist> {
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<Playlist> {
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<Playlist> {
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<Vec<Playlist>> {
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<Vec<Playlist>> {
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<Vec<Playlist>> {
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<bool> {
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<Vec<(Uuid, bool)>> {
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<PgPool>,
}
impl PlaylistItemPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
pub async fn list_items_in_playlist_enriched(
&self,
playlist_id: &Uuid,
) -> PlaylistItemRepositoryResult<Vec<PlaylistItemEnrichedRow>> {
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<PlaylistItem> {
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<PlaylistItem> {
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<Vec<PlaylistItem>> {
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<i64> {
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<i32> {
let max_pos = sqlx::query_scalar::<_, Option<i32>>(
"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<PgPool>,
}
impl AudioMetadataPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
impl AudioMetadataRepository for AudioMetadataPgRepository {
async fn create_or_update(
&self,
metadata: AudioFileMetadata,
) -> AudioMetadataRepositoryResult<AudioFileMetadata> {
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<Option<AudioFileMetadata>> {
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<Vec<AudioFileMetadata>> {
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<Vec<AudioFileMetadata>> {
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<Vec<AudioFileMetadata>> {
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())
}
}
@@ -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<PgPool>,
blob_root: PathBuf,
}
impl AudioMetadataService {
pub fn new(pool: Arc<PgPool>, 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<Self>, 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<Self>,
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<i32> = tag.track().map(|n| n as i32);
let disc_number: Option<i32> = tag.disc().map(|n| n as i32);
let year: Option<i32> = 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<MetadataExtractionResult, DomainError> {
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,
}
+1
View File
@@ -1,3 +1,4 @@
pub mod audio_metadata_service;
pub mod chunked_upload_service;
pub mod compression_service;
pub mod dedup_service;
@@ -39,6 +39,8 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
// 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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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,
})))
}
@@ -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()
}
+1
View File
@@ -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;
@@ -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<i64>,
pub offset: Option<i64>,
}
pub async fn create_playlist(
State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser,
Json(dto): Json<CreatePlaylistDto>,
) -> 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<Arc<MusicService>>,
auth_user: AuthUser,
Path(playlist_id): Path<String>,
) -> 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<Arc<MusicService>>,
auth_user: AuthUser,
Query(query): Query<PlaylistQueryDto>,
) -> 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<bool>,
pub include_public: Option<bool>,
}
pub async fn update_playlist(
State(music_service): State<Arc<MusicService>>,
auth_user: AuthUser,
Path(playlist_id): Path<String>,
Json(dto): Json<UpdatePlaylistDto>,
) -> 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<Arc<MusicService>>,
auth_user: AuthUser,
Path(playlist_id): Path<String>,
) -> 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<Arc<MusicService>>,
auth_user: AuthUser,
Path(playlist_id): Path<String>,
Json(dto): Json<AddTracksDto>,
) -> 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<Arc<MusicService>>,
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<Arc<MusicService>>,
auth_user: AuthUser,
Path(playlist_id): Path<String>,
Json(dto): Json<ReorderTracksDto>,
) -> 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<Arc<MusicService>>,
auth_user: AuthUser,
Path(playlist_id): Path<String>,
) -> 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<Arc<MusicService>>,
auth_user: AuthUser,
Path(playlist_id): Path<String>,
Json(dto): Json<SharePlaylistDto>,
) -> 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<Arc<MusicService>>,
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<Arc<MusicService>>,
auth_user: AuthUser,
Path(playlist_id): Path<String>,
) -> 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<Arc<MusicService>>,
auth_user: AuthUser,
Path(file_id): Path<String>,
) -> 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(),
}
}
+20 -4
View File
@@ -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
+38
View File
@@ -354,6 +354,44 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
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.
@@ -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)
};
@@ -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))
+7
View File
@@ -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;
+1
View File
@@ -148,6 +148,7 @@
}
.nav-item i,
.nav-item .nav-icon,
.nav-item .oxi-icon {
margin-right: 14px;
width: 20px;
+896
View File
@@ -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;
}
}
+10
View File
@@ -16,6 +16,7 @@
<link rel="stylesheet" href="/css/views/trash.css">
<link rel="stylesheet" href="/css/views/photos.css">
<link rel="stylesheet" href="/css/views/photosLightbox.css">
<link rel="stylesheet" href="/css/views/music.css">
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
<script defer src="/js/core/i18n.js"></script>
@@ -36,6 +37,7 @@
<script defer src="/js/features/library/recent.js"></script>
<script defer src="/js/features/library/photos.js"></script>
<script defer src="/js/features/library/photosLightbox.js"></script>
<script defer src="/js/features/library/music.js"></script>
<script defer src="/js/features/sharing/fileSharing.js"></script>
<script defer src="/js/views/shared/sharedView.js"></script>
<script defer src="/js/features/files/inlineViewer.js"></script>
@@ -89,6 +91,14 @@
<i class="fas fa-images"></i>
<span data-i18n="nav.photos">Photos</span>
</div>
<div class="nav-item" id="nav-music">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/>
</svg>
<span data-i18n="nav.music">Music</span>
</div>
<div class="nav-item">
<i class="fas fa-trash"></i>
<span data-i18n="nav.trash">Trash</span>
+5 -1
View File
@@ -618,7 +618,11 @@ function setupEventListeners() {
switchToPhotosSection();
break;
case 'nav.trash':
case 'nav.music':
switchToMusicSection();
break;
case 'nav.trash':
switchToTrashSection();
break;
+35 -1
View File
@@ -108,7 +108,8 @@ const VIEW_FLAGS = {
recent: 'isRecentView',
favorites: 'isFavoritesView',
trash: 'isTrashView',
photos: 'isPhotosView'
photos: 'isPhotosView',
music: 'isMusicView'
};
/**
@@ -158,6 +159,11 @@ function setCurrentSection(section) {
window.photosView.hide();
}
// Hide musicView when switching to any other section
if (section !== 'music' && window.musicView) {
window.musicView.hide();
}
return true;
}
@@ -323,10 +329,38 @@ function switchToTrashSection() {
if (window.multiSelect) window.multiSelect.clear();
}
function switchToMusicSection() {
if (!setCurrentSection('music')) return;
// Hide breadcrumb
const breadcrumb = document.querySelector('.breadcrumb');
breadcrumb?.classList.add("hidden");
// Hide file containers
toggleFileContainer(false);
// Hide actions-bar
window.setActionsBarMode('hidden');
// Reset files view + remove any error
window.ui.resetFilesList();
// Hide list header (created by resetFilesList)
const listHeader = document.querySelector('.list-header');
listHeader?.classList.add('hidden');
// Show music view
if (window.musicView) {
window.musicView.show();
}
if (window.multiSelect) window.multiSelect.clear();
}
window.switchToFilesSection = switchToFilesSection;
window.switchToSharedSection = switchToSharedSection;
window.switchToFavoritesSection = switchToFavoritesSection;
window.switchToRecentFilesSection = switchToRecentFilesSection;
window.switchToPhotosSection = switchToPhotosSection;
window.switchToTrashSection = switchToTrashSection;
window.switchToMusicSection = switchToMusicSection;
window.syncViewContainers = syncViewContainers;
+39 -1
View File
@@ -69,6 +69,9 @@ const ui = {
<div class="context-menu-item" id="share-file-option">
<i class="fas fa-share-alt"></i> <span data-i18n="actions.share">Share</span>
</div>
<div class="context-menu-item hidden" id="add-to-playlist-option">
<i class="fas fa-compact-disc"></i> <span data-i18n="music.add_to_playlist">Add to Playlist</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" id="rename-file-option">
<i class="fas fa-pen"></i> <span data-i18n="actions.rename">Rename</span>
@@ -271,6 +274,33 @@ const ui = {
});
}
// Playlist selection dialog
if (!document.getElementById('playlist-dialog')) {
const playlistDialog = document.createElement('div');
playlistDialog.className = 'share-dialog';
playlistDialog.id = 'playlist-dialog';
playlistDialog.innerHTML = `
<div class="share-dialog-content">
<div class="share-dialog-header">
<i class="fas fa-music dialog-header-icon"></i>
<span data-i18n="music.add_to_playlist">Add to Playlist</span>
</div>
<div id="playlist-dialog-files-info" class="shared-item-info"></div>
<div id="playlist-select-container" class="folder-select-container">
</div>
<div class="rename-dialog-buttons">
<button class="btn btn-secondary" id="playlist-cancel-btn" data-i18n="actions.cancel">Cancel</button>
<button class="btn btn-primary" id="playlist-add-btn" data-i18n="music.add">Add</button>
</div>
</div>
`;
document.body.appendChild(playlistDialog);
document.getElementById('playlist-cancel-btn').addEventListener('click', () => {
if (window.contextMenus) window.contextMenus.closePlaylistDialog();
});
}
// Assign events to menu items
if (window.contextMenus) {
window.contextMenus.assignMenuEvents();
@@ -855,10 +885,12 @@ const ui = {
parent_id: card.dataset.parentId || ''
};
} else {
const fileData = info.data || self._items.get(info.id);
window.app.contextMenuTargetFile = {
id: info.id,
name: card.dataset.fileName,
folder_id: card.dataset.folderId || ''
folder_id: card.dataset.folderId || '',
mime_type: fileData?.mime_type || null
};
}
};
@@ -936,6 +968,9 @@ const ui = {
if (window.contextMenus && typeof window.contextMenus.syncWopiOptionVisibility === 'function') {
window.contextMenus.syncWopiOptionVisibility().catch(() => {});
}
if (window.contextMenus && typeof window.contextMenus.syncAddToPlaylistOption === 'function') {
window.contextMenus.syncAddToPlaylistOption();
}
menu.style.left = `${e.pageX}px`;
menu.style.top = `${e.pageY}px`;
menu.style.display = 'block';
@@ -1406,6 +1441,9 @@ function showContextMenuAtElement(triggerElement, menuId) {
if (window.contextMenus && typeof window.contextMenus.syncWopiOptionVisibility === 'function') {
window.contextMenus.syncWopiOptionVisibility().catch(() => {});
}
if (window.contextMenus && typeof window.contextMenus.syncAddToPlaylistOption === 'function') {
window.contextMenus.syncAddToPlaylistOption();
}
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
+28
View File
@@ -253,6 +253,34 @@ const _ICONS = {
384,
'M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z'
],
pause: [
384,
'M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z'
],
shuffle: [
512,
'M403.8 34.4c12-5 25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64c-9.2 9.2-22.9 11.9-34.9 6.9S384 204.9 384 192l0-32-32 0c-10.1 0-19.6 4.7-25.6 12.8l-32.4 43.2-40-53.3 21.2-28.3C293.3 110.2 321.8 96 352 96l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6zM154 296l40 53.3-21.2 28.3C154.7 401.8 126.2 416 96 416l-64 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l64 0c10.1 0 19.6-4.7 25.6-12.8L154 296zM438.6 470.6c-9.2 9.2-22.9 11.9-34.9 6.9S384 460.9 384 448l0-32-32 0c-30.2 0-58.7-14.2-76.8-38.4L121.6 172.8c-6-8.1-15.5-12.8-25.6-12.8l-64 0c-17.7 0-32-14.3-32-32S14.3 96 32 96l64 0c30.2 0 58.7 14.2 76.8 38.4L326.4 339.2c6 8.1 15.5 12.8 25.6 12.8l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64z'
],
repeat: [
512,
'M470.6 118.6c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S352 19.1 352 32l0 32-160 0C86 64 0 150 0 256 0 273.7 14.3 288 32 288s32-14.3 32-32c0-70.7 57.3-128 128-128l160 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64zM41.4 393.4c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9S160 492.9 160 480l0-32 160 0c106 0 192-86 192-192 0-17.7-14.3-32-32-32s-32 14.3-32 32c0 70.7-57.3 128-128 128l-160 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64z'
],
forward: [
512,
'M371.7 43.1C360.1 32 343 28.9 328.3 35.2S304 56 304 72l0 136.3-172.3-165.1C120.1 32 103 28.9 88.3 35.2S64 56 64 72l0 368c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9L304 303.7 304 440c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9l192-184c7.9-7.5 12.3-18 12.3-28.9s-4.5-21.3-12.3-28.9l-192-184z'
],
backward: [
512,
'M204.3 43.1C215.9 32 233 28.9 247.7 35.2S272 56 272 72l0 136.3 172.3-165.1C455.9 32 473 28.9 487.7 35.2S512 56 512 72l0 368c0 16-9.6 30.5-24.3 36.8s-31.8 3.2-43.4-7.9L272 303.7 272 440c0 16-9.6 30.5-24.3 36.8s-31.8 3.2-43.4-7.9l-192-184C4.5 277.3 0 266.9 0 256s4.5-21.3 12.3-28.9l192-184z'
],
volume: [
512,
'M48 352l48 0 134.1 119.2c6.4 5.7 14.6 8.8 23.1 8.8 19.2 0 34.8-15.6 34.8-34.8l0-378.4c0-19.2-15.6-34.8-34.8-34.8-8.5 0-16.7 3.1-23.1 8.8L96 160 48 160c-26.5 0-48 21.5-48 48l0 96c0 26.5 21.5 48 48 48zM441.1 107c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C443.3 170.7 464 210.9 464 256s-20.7 85.3-53.2 111.8c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5c43.2-35.2 70.9-88.9 70.9-149s-27.7-113.8-70.9-149zm-60.5 74.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C361.1 227.6 368 241 368 256s-6.9 28.4-17.7 37.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C402.1 312.9 416 286.1 416 256s-13.9-56.9-35.5-74.5z'
],
'compact-disc': [
512,
'M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zM88 256H56c0-105.9 86.1-192 192-192v32c-88.2 0-160 71.8-160 160zm160 96c-53 0-96-43-96-96s43-96 96-96 96 43 96 96-43 96-96 96zm0-128c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32z'
],
'question-circle': [
512,
'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM169.8 165.3c7.9-22.3 29.1-37.3 52.8-37.3l58.3 0c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24l0-13.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1l-58.3 0c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z'
+166
View File
@@ -51,6 +51,19 @@ const contextMenus = {
}
},
syncAddToPlaylistOption() {
const option = document.getElementById('add-to-playlist-option');
if (!option) return;
const targetFile = window.app && window.app.contextMenuTargetFile;
if (targetFile) {
const isAudio = targetFile.mime_type && targetFile.mime_type.startsWith('audio/');
option.classList.toggle('hidden', !isAudio);
} else {
option.classList.add('hidden');
}
},
/**
* Assign events to menu items and dialogs
*/
@@ -217,6 +230,18 @@ const contextMenus = {
window.ui.closeFileContextMenu();
});
document.getElementById('add-to-playlist-option').addEventListener('click', () => {
const file = window.app.contextMenuTargetFile;
if (file) {
this.showPlaylistDialog(file);
}
window.ui.closeFileContextMenu();
});
document.getElementById('playlist-add-btn').addEventListener('click', () => {
this.addSelectedFilesToPlaylist();
});
document.getElementById('delete-file-option').addEventListener('click', async () => {
const file = window.app.contextMenuTargetFile;
window.ui.closeFileContextMenu();
@@ -1049,6 +1074,147 @@ const contextMenus = {
closeNotificationDialog() {
document.getElementById('notification-dialog').style.display = 'none';
window.app.notificationShareUrl = null;
},
_selectedPlaylistId: null,
async showPlaylistDialog(file) {
const dialog = document.getElementById('playlist-dialog');
const container = document.getElementById('playlist-select-container');
const filesInfo = document.getElementById('playlist-dialog-files-info');
if (!dialog || !container) {
console.error('Playlist dialog elements not found');
return;
}
// Store the file(s) to add
window.app.playlistDialogFiles = [file];
// Update files info
if (filesInfo) {
filesInfo.innerHTML = `<strong>${window.i18n ? window.i18n.t('music.selected_files', 'Selected:') : 'Selected:'} </strong>${file.name}`;
}
// Reset selection
this._selectedPlaylistId = null;
container.innerHTML = '<div class="folder-select-loading"><i class="fas fa-spinner fa-spin"></i></div>';
// Reset add button state
const addBtn = document.getElementById('playlist-add-btn');
if (addBtn) addBtn.disabled = true;
// Show dialog
dialog.style.display = 'flex';
requestAnimationFrame(() => dialog.classList.add('active'));
// Load playlists
try {
const resp = await fetch('/api/playlists', { credentials: 'include' });
if (!resp.ok) throw new Error('Failed to load playlists');
const playlists = await resp.json();
this._renderPlaylistSelect(container, playlists);
} catch (err) {
console.error('Error loading playlists:', err);
container.innerHTML = `<div class="folder-select-empty">${window.i18n ? window.i18n.t('music.load_error', 'Error loading playlists') : 'Error loading playlists'}</div>`;
}
},
_renderPlaylistSelect(container, playlists) {
const t = (key, fallback) => window.i18n ? window.i18n.t(key, fallback) : fallback;
container.innerHTML = '';
if (playlists.length === 0) {
container.innerHTML = `<div class="folder-select-empty">${t('music.no_playlists', 'No playlists yet. Create one first!')}</div>`;
return;
}
playlists.forEach(playlist => {
const item = document.createElement('div');
item.className = 'folder-select-item';
item.dataset.id = playlist.id;
item.innerHTML = `
<i class="fas fa-list"></i>
<span>${this._escapeHtml(playlist.name)}</span>
<span class="playlist-track-count">${playlist.track_count || 0} ${t('music.tracks', 'tracks')}</span>
`;
item.addEventListener('click', () => {
container.querySelectorAll('.folder-select-item').forEach(el => el.classList.remove('selected'));
item.classList.add('selected');
this._selectedPlaylistId = playlist.id;
const addBtn = document.getElementById('playlist-add-btn');
if (addBtn) addBtn.disabled = false;
});
container.appendChild(item);
});
},
async addSelectedFilesToPlaylist() {
const playlistId = this._selectedPlaylistId;
const files = window.app.playlistDialogFiles || [];
if (!playlistId || files.length === 0) return;
const addBtn = document.getElementById('playlist-add-btn');
if (addBtn) addBtn.disabled = true;
try {
const resp = await fetch(`/api/playlists/${playlistId}/tracks`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...getCsrfHeaders()
},
body: JSON.stringify({ file_ids: files.map(f => f.id) })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || 'Failed to add tracks');
}
const result = await resp.json();
window.ui.showNotification(
window.i18n ? window.i18n.t('music.added', 'Added!') : 'Added!',
`${files.length} ${files.length === 1 ? 'track' : 'tracks'} ${window.i18n ? window.i18n.t('music.added_to_playlist', 'added to playlist') : 'added to playlist'}`
);
this.closePlaylistDialog();
// Refresh music view if open
if (window.musicView && window.musicView.playlists) {
window.musicView._loadPlaylists();
}
} catch (err) {
console.error('Error adding to playlist:', err);
window.ui.showNotification(
window.i18n ? window.i18n.t('music.error', 'Error') : 'Error',
err.message || window.i18n.t('music.add_error', 'Could not add tracks to playlist')
);
if (addBtn) addBtn.disabled = false;
}
},
closePlaylistDialog() {
const dialog = document.getElementById('playlist-dialog');
if (dialog) {
dialog.classList.remove('active');
setTimeout(() => {
dialog.style.display = 'none';
}, 200);
}
window.app.playlistDialogFiles = null;
this._selectedPlaylistId = null;
},
_escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
};
+980
View File
@@ -0,0 +1,980 @@
/**
* OxiCloud - Music Library View
* Playlist management with track listings and audio player
*/
const musicView = {
playlists: [],
currentPlaylist: null,
currentTracks: [],
loading: false,
_container: null,
_initialized: false,
selected: new Set(),
_headers(json = false) {
const h = typeof getCsrfHeaders === 'function' ? { ...getCsrfHeaders() } : {};
if (json) h['Content-Type'] = 'application/json';
return h;
},
init() {
if (!this._container) {
const contentArea = document.querySelector('.content-area');
if (!contentArea) return;
const el = document.createElement('div');
el.id = 'music-container';
el.className = 'music-container';
contentArea.appendChild(el);
this._container = el;
}
musicPlayer.init();
this._initialized = true;
},
show() {
this.init();
if (!this._container) return;
this._container.classList.add('active');
this.currentPlaylist = null;
this.currentTracks = [];
this._container.innerHTML = '';
this._renderPlaylists();
this._loadPlaylists();
},
hide() {
if (this._container) {
this._container.classList.remove('active');
}
this.currentPlaylist = null;
this.currentTracks = [];
},
async _loadPlaylists() {
if (this.loading) return;
this.loading = true;
this._showLoading(true);
try {
const resp = await fetch('/api/playlists', {
credentials: 'include',
headers: this._headers()
});
if (!resp.ok) throw new Error('Failed to load playlists');
this.playlists = await resp.json();
this._renderPlaylists();
} catch (err) {
console.error('Music load error:', err);
this._showError(err.message);
} finally {
this.loading = false;
this._showLoading(false);
}
},
_renderPlaylists() {
if (!this._container) return;
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
this._container.innerHTML = `
<div class="music-toolbar">
<button class="btn btn-primary" id="music-create-playlist-btn">
<i class="fas fa-plus"></i>
<span>${t('music.create_playlist', 'Create Playlist')}</span>
</button>
</div>
<div class="music-content">
<div class="music-sidebar">
<div class="music-sidebar-header">
<h3>${t('music.playlists', 'Playlists')}</h3>
</div>
<div class="music-playlist-list" id="music-playlist-list">
${this.playlists.length === 0 ? `
<div class="music-empty">
<i class="fas fa-music"></i>
<p>${t('music.no_playlists', 'No playlists yet')}</p>
</div>
` : ''}
</div>
</div>
<div class="music-main">
<div class="music-welcome">
<i class="fas fa-music"></i>
<h3>${t('music.select_playlist', 'Select a playlist')}</h3>
<p>${t('music.select_hint', 'Choose a playlist from the sidebar or create a new one')}</p>
</div>
<div class="music-playlist-detail hidden" id="music-playlist-detail">
<div class="music-playlist-header">
<div class="music-playlist-cover">
<i class="fas fa-music"></i>
</div>
<div class="music-playlist-info">
<h2 id="music-playlist-name"></h2>
<p id="music-playlist-meta"></p>
</div>
</div>
<div class="music-playlist-actions">
<button class="btn btn-secondary" id="music-play-all-btn">
<i class="fas fa-play"></i>
<span>${t('music.play_all', 'Play All')}</span>
</button>
<button class="btn btn-secondary" id="music-shuffle-btn">
<i class="fas fa-shuffle"></i>
</button>
<button class="btn btn-secondary" id="music-add-tracks-btn">
<i class="fas fa-plus"></i>
<span>${t('music.add_tracks', 'Add Tracks')}</span>
</button>
<button class="btn btn-secondary" id="music-edit-playlist-btn">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-secondary" id="music-share-playlist-btn">
<i class="fas fa-share"></i>
</button>
<button class="btn btn-secondary" id="music-delete-playlist-btn">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="music-track-list" id="music-track-list"></div>
</div>
</div>
</div>
`;
this._bindPlaylistEvents();
this._renderPlaylistList();
},
_renderPlaylistList() {
const listEl = document.getElementById('music-playlist-list');
if (!listEl) return;
if (this.playlists.length === 0) {
listEl.innerHTML = `
<div class="music-empty">
<i class="fas fa-music"></i>
<p>No playlists yet</p>
</div>
`;
return;
}
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
listEl.innerHTML = this.playlists.map(p => `
<div class="music-playlist-item" data-id="${p.id}">
<div class="music-playlist-icon">
<i class="fas fa-list"></i>
</div>
<div class="music-playlist-item-info">
<span class="music-playlist-item-name">${this._escapeHtml(p.name)}</span>
<span class="music-playlist-item-count">${p.track_count || 0} ${t('music.tracks', 'tracks')}</span>
</div>
</div>
`).join('');
listEl.querySelectorAll('.music-playlist-item').forEach(item => {
item.addEventListener('click', () => {
const id = item.dataset.id;
this._selectPlaylist(id);
});
});
},
_bindPlaylistEvents() {
const createBtn = document.getElementById('music-create-playlist-btn');
if (createBtn) {
createBtn.addEventListener('click', () => this._showCreatePlaylistDialog());
}
const playAllBtn = document.getElementById('music-play-all-btn');
if (playAllBtn) {
playAllBtn.addEventListener('click', () => this._playAll());
}
const shuffleBtn = document.getElementById('music-shuffle-btn');
if (shuffleBtn) {
shuffleBtn.addEventListener('click', () => this._shufflePlay());
}
const deleteBtn = document.getElementById('music-delete-playlist-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => this._deletePlaylist());
}
},
async _selectPlaylist(playlistId) {
const playlist = this.playlists.find(p => p.id === playlistId);
if (!playlist) return;
this.currentPlaylist = playlist;
const detailEl = document.getElementById('music-playlist-detail');
const welcomeEl = this._container.querySelector('.music-welcome');
const nameEl = document.getElementById('music-playlist-name');
const metaEl = document.getElementById('music-playlist-meta');
if (welcomeEl) welcomeEl.classList.add('hidden');
if (detailEl) detailEl.classList.remove('hidden');
if (nameEl) nameEl.textContent = playlist.name;
if (metaEl) {
metaEl.textContent = `${playlist.track_count || 0} tracks`;
}
document.querySelectorAll('.music-playlist-item').forEach(item => {
item.classList.toggle('active', item.dataset.id === playlistId);
});
await this._loadPlaylistTracks(playlistId);
},
async _loadPlaylistTracks(playlistId) {
const trackListEl = document.getElementById('music-track-list');
if (!trackListEl) return;
trackListEl.innerHTML = '<div class="music-loading"><i class="fas fa-spinner fa-spin"></i></div>';
try {
const resp = await fetch(`/api/playlists/${playlistId}/tracks`, {
credentials: 'include',
headers: this._headers()
});
if (!resp.ok) throw new Error('Failed to load tracks');
this.currentTracks = await resp.json();
this._renderTracks();
} catch (err) {
console.error('Track load error:', err);
trackListEl.innerHTML = `<div class="music-error">${err.message}</div>`;
}
},
_renderTracks() {
const trackListEl = document.getElementById('music-track-list');
if (!trackListEl) return;
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
if (this.currentTracks.length === 0) {
trackListEl.innerHTML = `
<div class="music-empty">
<i class="fas fa-music"></i>
<p>${t('music.no_tracks', 'No tracks in this playlist')}</p>
</div>
`;
return;
}
trackListEl.innerHTML = `
<div class="music-track-header">
<span class="music-track-col music-track-num">#</span>
<span class="music-track-col music-track-title">${t('music.title', 'Title')}</span>
<span class="music-track-col music-track-artist">${t('music.artist', 'Artist')}</span>
<span class="music-track-col music-track-album">${t('music.album', 'Album')}</span>
<span class="music-track-col music-track-duration"><i class="far fa-clock"></i></span>
</div>
${this.currentTracks.map((track, idx) => `
<div class="music-track ${musicPlayer.currentTrack?.id === track.id ? 'playing' : ''}" data-idx="${idx}" data-id="${track.id}" data-file-id="${track.file_id}">
<span class="music-track-col music-track-num">
<span class="track-num-text">${idx + 1}</span>
<i class="fas fa-play track-play-icon hidden"></i>
</span>
<span class="music-track-col music-track-title">
<i class="fas fa-music music-track-icon"></i>
<span class="music-track-name">${this._escapeHtml(track.title || track.file_name || t('music.unknown_title', 'Unknown'))}</span>
</span>
<span class="music-track-col music-track-artist">${this._escapeHtml(track.artist || t('music.unknown_artist', 'Unknown Artist'))}</span>
<span class="music-track-col music-track-album">${this._escapeHtml(track.album || '-')}</span>
<span class="music-track-col music-track-duration">${this._formatDuration(track.duration_secs)}</span>
</div>
`).join('')}
`;
const self = this;
trackListEl.querySelectorAll('.music-track').forEach(row => {
row.addEventListener('click', () => {
const idx = parseInt(row.dataset.idx);
self._playTrack(idx);
});
row.addEventListener('dblclick', () => {
const idx = parseInt(row.dataset.idx);
self._playTrack(idx);
});
});
},
_playTrack(idx) {
if (!this.currentTracks[idx]) return;
musicPlayer.setQueue(this.currentTracks, this.currentPlaylist?.name || 'Playlist');
musicPlayer.playTrack(idx);
},
_playAll() {
if (this.currentTracks.length > 0) {
this._playTrack(0);
}
},
_shufflePlay() {
if (this.currentTracks.length > 0) {
const shuffled = [...this.currentTracks].sort(() => Math.random() - 0.5);
musicPlayer.setQueue(shuffled, this.currentPlaylist?.name || 'Shuffle');
musicPlayer.playTrack(0);
}
},
_showCreatePlaylistDialog() {
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
const name = prompt(t('music.playlist_name', 'Playlist name:'));
if (!name || !name.trim()) return;
this._createPlaylist(name.trim());
},
async _createPlaylist(name) {
try {
const resp = await fetch('/api/playlists', {
method: 'POST',
credentials: 'include',
headers: this._headers(true),
body: JSON.stringify({ name, description: null })
});
if (!resp.ok) throw new Error('Failed to create playlist');
const playlist = await resp.json();
this.playlists.unshift(playlist);
this._renderPlaylistList();
this._selectPlaylist(playlist.id);
} catch (err) {
console.error('Create playlist error:', err);
alert('Failed to create playlist: ' + err.message);
}
},
async _deletePlaylist() {
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
if (!this.currentPlaylist) return;
if (!confirm(t('music.confirm_delete', 'Delete this playlist?'))) return;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
method: 'DELETE',
credentials: 'include',
headers: this._headers()
});
if (!resp.ok) throw new Error('Failed to delete playlist');
this.playlists = this.playlists.filter(p => p.id !== this.currentPlaylist.id);
this.currentPlaylist = null;
this.currentTracks = [];
this._renderPlaylists();
} catch (err) {
console.error('Delete playlist error:', err);
alert('Failed to delete playlist: ' + err.message);
}
},
_formatDuration(secs) {
if (!secs) return '-';
const mins = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${mins}:${s.toString().padStart(2, '0')}`;
},
_escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
_showLoading(show) {
const existing = this._container?.querySelector('.music-loading');
if (show && !existing) {
const loading = document.createElement('div');
loading.className = 'music-loading';
loading.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
this._container?.appendChild(loading);
} else if (!show && existing) {
existing.remove();
}
},
_showError(message) {
if (!this._container) return;
this._container.innerHTML = `
<div class="music-error">
<i class="fas fa-exclamation-circle"></i>
<p>${this._escapeHtml(message)}</p>
</div>
`;
}
};
/**
* Music Player Module
* Handles audio playback, queue, and controls
*/
const musicPlayer = {
audio: null,
queue: [],
currentIndex: -1,
currentTrack: null,
isPlaying: false,
volume: 0.7,
isMuted: false,
shuffle: false,
repeat: 'none',
playlistName: '',
_initialized: false,
init() {
if (this._initialized) return;
this.audio = new Audio();
this.audio.volume = this.volume;
this.audio.addEventListener('ended', () => this._onEnded());
this.audio.addEventListener('timeupdate', () => this._onTimeUpdate());
this.audio.addEventListener('loadedmetadata', () => this._onLoadedMetadata());
this.audio.addEventListener('play', () => this._onPlay());
this.audio.addEventListener('pause', () => this._onPause());
this.audio.addEventListener('error', (e) => this._onError(e));
this.audio.addEventListener('canplay', () => this._onCanPlay());
this._createPlayerUI();
this._bindEvents();
this._initialized = true;
},
_createPlayerUI() {
if (document.getElementById('music-player')) return;
const playerEl = document.createElement('div');
playerEl.id = 'music-player';
playerEl.className = 'music-player';
playerEl.innerHTML = `
<div class="player-track-info">
<div class="player-album-art">
<i class="fas fa-music"></i>
</div>
<div class="player-track-details">
<span class="player-track-name">${i18n?.t('music.not_playing', 'Not playing') || 'Not playing'}</span>
<span class="player-track-artist"></span>
</div>
</div>
<div class="player-controls">
<div class="player-buttons">
<button class="player-btn" id="player-shuffle-btn" title="${i18n?.t('music.shuffle', 'Shuffle') || 'Shuffle'}">
<i class="fas fa-shuffle"></i>
</button>
<button class="player-btn" id="player-prev-btn" title="${i18n?.t('music.previous', 'Previous') || 'Previous'}">
<i class="fas fa-backward"></i>
</button>
<button class="player-btn player-btn-main" id="player-play-btn" title="${i18n?.t('music.play', 'Play') || 'Play'}">
<i class="fas fa-play"></i>
</button>
<button class="player-btn" id="player-next-btn" title="${i18n?.t('music.next', 'Next') || 'Next'}">
<i class="fas fa-forward"></i>
</button>
<button class="player-btn" id="player-repeat-btn" title="${i18n?.t('music.repeat', 'Repeat') || 'Repeat'}">
<i class="fas fa-repeat"></i>
</button>
</div>
<div class="player-progress">
<span class="player-time player-time-current" id="player-current-time">0:00</span>
<div class="player-progress-bar" id="player-progress-bar">
<div class="player-progress-fill" id="player-progress-fill"></div>
<div class="player-progress-handle" id="player-progress-handle"></div>
</div>
<span class="player-time player-time-total" id="player-total-time">0:00</span>
</div>
</div>
<div class="player-extra">
<button class="player-btn player-btn-small" id="player-playlist-btn" title="${i18n?.t('music.queue', 'Queue') || 'Queue'}">
<i class="fas fa-list"></i>
</button>
<button class="player-btn player-btn-small" id="player-vol-btn" title="${i18n?.t('music.volume', 'Volume') || 'Volume'}">
<i class="fas fa-volume"></i>
</button>
<div class="player-volume-slider" id="player-volume-slider">
<input type="range" min="0" max="100" value="70" id="player-volume-input">
</div>
</div>
<div class="player-queue hidden" id="player-queue">
<div class="player-queue-header">
<h3>${i18n?.t('music.queue', 'Queue') || 'Queue'}</h3>
<button class="player-btn player-btn-small" id="player-close-queue-btn">
<i class="fas fa-times"></i>
</button>
</div>
<div class="player-queue-list" id="player-queue-list"></div>
</div>
`;
document.body.appendChild(playerEl);
},
_bindEvents() {
const playBtn = document.getElementById('player-play-btn');
const prevBtn = document.getElementById('player-prev-btn');
const nextBtn = document.getElementById('player-next-btn');
const shuffleBtn = document.getElementById('player-shuffle-btn');
const repeatBtn = document.getElementById('player-repeat-btn');
const progressBar = document.getElementById('player-progress-bar');
const volumeInput = document.getElementById('player-volume-input');
const volBtn = document.getElementById('player-vol-btn');
const playlistBtn = document.getElementById('player-playlist-btn');
const closeQueueBtn = document.getElementById('player-close-queue-btn');
const queue = document.getElementById('player-queue');
if (playBtn) {
playBtn.addEventListener('click', () => this.togglePlay());
}
if (prevBtn) {
prevBtn.addEventListener('click', () => this.prev());
}
if (nextBtn) {
nextBtn.addEventListener('click', () => this.next());
}
if (shuffleBtn) {
shuffleBtn.addEventListener('click', () => this.toggleShuffle());
}
if (repeatBtn) {
repeatBtn.addEventListener('click', () => this.toggleRepeat());
}
if (progressBar) {
progressBar.addEventListener('click', (e) => this._seek(e));
}
if (volumeInput) {
volumeInput.addEventListener('input', (e) => {
this.setVolume(e.target.value / 100);
});
}
if (volBtn) {
volBtn.addEventListener('click', () => this.toggleMute());
}
if (playlistBtn) {
playlistBtn.addEventListener('click', () => this._toggleQueue());
}
if (closeQueueBtn) {
closeQueueBtn.addEventListener('click', () => this._toggleQueue(false));
}
if (queue) {
queue.addEventListener('click', (e) => {
if (e.target === queue) {
this._toggleQueue(false);
}
});
}
},
setQueue(tracks, playlistName = '') {
this.queue = [...tracks];
this.playlistName = playlistName;
this._updateQueueUI();
},
playTrack(index) {
if (index < 0 || index >= this.queue.length) return;
this.currentIndex = index;
this.currentTrack = this.queue[index];
this._loadAndPlay();
},
_loadAndPlay() {
if (!this.currentTrack) return;
const fileId = this.currentTrack.file_id;
this.audio.src = `/api/files/${fileId}`;
this.audio.play().catch(err => {
console.error('Playback error:', err);
});
this.isPlaying = true;
this._updateUI();
},
togglePlay() {
if (!this.currentTrack) {
if (this.queue.length > 0) {
this.playTrack(0);
}
return;
}
if (this.isPlaying) {
this.audio.pause();
} else {
this.audio.play();
}
},
play() {
if (this.currentTrack && !this.isPlaying) {
this.audio.play();
}
},
pause() {
if (this.isPlaying) {
this.audio.pause();
}
},
next() {
if (this.queue.length === 0) return;
let nextIndex;
if (this.shuffle) {
nextIndex = Math.floor(Math.random() * this.queue.length);
} else {
nextIndex = this.currentIndex + 1;
if (nextIndex >= this.queue.length) {
nextIndex = 0;
}
}
this.playTrack(nextIndex);
},
prev() {
if (this.queue.length === 0) return;
if (this.audio.currentTime > 3) {
this.audio.currentTime = 0;
return;
}
let prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
prevIndex = this.queue.length - 1;
}
this.playTrack(prevIndex);
},
toggleShuffle() {
this.shuffle = !this.shuffle;
const btn = document.getElementById('player-shuffle-btn');
if (btn) {
btn.classList.toggle('active', this.shuffle);
}
},
toggleRepeat() {
const modes = ['none', 'all', 'one'];
const currentIdx = modes.indexOf(this.repeat);
this.repeat = modes[(currentIdx + 1) % modes.length];
const btn = document.getElementById('player-repeat-btn');
if (btn) {
btn.classList.remove('active', 'repeat-one');
if (this.repeat === 'all') {
btn.classList.add('active');
} else if (this.repeat === 'one') {
btn.classList.add('active', 'repeat-one');
}
}
},
setVolume(vol) {
this.volume = Math.max(0, Math.min(1, vol));
this.audio.volume = this.volume;
this.isMuted = this.volume === 0;
this._updateVolumeIcon();
const input = document.getElementById('player-volume-input');
if (input) {
input.value = this.volume * 100;
}
},
toggleMute() {
this.isMuted = !this.isMuted;
this.audio.muted = this.isMuted;
this._updateVolumeIcon();
},
_updateVolumeIcon() {
const btn = document.getElementById('player-vol-btn');
if (!btn) return;
let icon;
if (this.isMuted || this.volume === 0) {
icon = 'fa-volume-mute';
} else if (this.volume < 0.5) {
icon = 'fa-volume-down';
} else {
icon = 'fa-volume-up';
}
btn.querySelector('i').className = `fas ${icon}`;
},
_seek(e) {
const bar = document.getElementById('player-progress-bar');
if (!bar) return;
const rect = bar.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
if (this.audio.duration) {
this.audio.currentTime = percent * this.audio.duration;
}
},
_onEnded() {
if (this.repeat === 'one') {
this.audio.currentTime = 0;
this.audio.play();
} else if (this.repeat === 'all' || this.currentIndex < this.queue.length - 1) {
this.next();
} else {
this.isPlaying = false;
this._updateUI();
}
},
_onTimeUpdate() {
if (!this.audio.duration) return;
const current = this.audio.currentTime;
const total = this.audio.duration;
const percent = (current / total) * 100;
const fill = document.getElementById('player-progress-fill');
const handle = document.getElementById('player-progress-handle');
const currentTimeEl = document.getElementById('player-current-time');
if (fill) fill.style.width = `${percent}%`;
if (handle) handle.style.left = `${percent}%`;
if (currentTimeEl) currentTimeEl.textContent = this._formatTime(current);
},
_onLoadedMetadata() {
const totalTimeEl = document.getElementById('player-total-time');
if (totalTimeEl) {
totalTimeEl.textContent = this._formatTime(this.audio.duration);
}
if (this.currentTrack && this.audio.duration) {
this.currentTrack.duration_secs = Math.round(this.audio.duration);
const trackDurationEl = document.querySelector(`.music-track[data-idx="${this.currentIndex}"] .music-track-duration`);
if (trackDurationEl) {
trackDurationEl.textContent = this._formatDuration(this.audio.duration);
}
const queueDurationEl = document.querySelector(`.queue-item[data-idx="${this.currentIndex}"] .queue-item-duration`);
if (queueDurationEl) {
queueDurationEl.textContent = this._formatDuration(this.audio.duration);
}
}
},
_onPlay() {
this.isPlaying = true;
this._updateUI();
},
_onPause() {
this.isPlaying = false;
this._updateUI();
},
_onCanPlay() {
this._updateUI();
},
_onError(e) {
console.error('Audio error:', e);
this.isPlaying = false;
this._updateUI();
},
_updateUI() {
const playBtn = document.getElementById('player-play-btn');
const trackName = document.querySelector('.player-track-name');
const trackArtist = document.querySelector('.player-track-artist');
if (playBtn) {
const icon = playBtn.querySelector('i') || playBtn.querySelector('svg');
if (icon) {
const iconName = this.isPlaying ? 'pause' : 'play';
const extraClass = 'player-btn-main';
if (window.oxiIcon) {
icon.outerHTML = window.oxiIcon(iconName, extraClass);
} else {
icon.className = `fas fa-${iconName} ${extraClass}`;
}
}
}
if (trackName) {
trackName.textContent = this.currentTrack
? (this.currentTrack.title || this.currentTrack.file_name || 'Unknown')
: (i18n?.t('music.not_playing', 'Not playing') || 'Not playing');
}
if (trackArtist) {
trackArtist.textContent = this.currentTrack?.artist || '';
}
if (musicView.currentTracks.length > 0) {
document.querySelectorAll('.music-track').forEach(row => {
const idx = parseInt(row.dataset.idx);
row.classList.toggle('playing', idx === this.currentIndex && this.isPlaying);
const numText = row.querySelector('.track-num-text');
const playIcon = row.querySelector('.track-play-icon');
if (idx === this.currentIndex) {
if (numText) numText.classList.add('hidden');
if (playIcon) playIcon.classList.remove('hidden');
if (playIcon) {
const iconName = this.isPlaying ? 'pause' : 'play';
if (window.oxiIcon) {
playIcon.outerHTML = window.oxiIcon(iconName, 'track-play-icon');
} else {
playIcon.className = `fas fa-${iconName} track-play-icon`;
}
}
} else {
if (numText) numText.classList.remove('hidden');
if (playIcon) playIcon.classList.add('hidden');
}
});
}
const player = document.getElementById('music-player');
if (player) {
player.classList.toggle('has-track', !!this.currentTrack);
}
},
_updateQueueUI() {
const queueList = document.getElementById('player-queue-list');
if (!queueList) return;
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
if (this.queue.length === 0) {
queueList.innerHTML = `
<div class="player-queue-empty">
<i class="fas fa-music"></i>
<p>${t('music.queue_empty', 'Queue is empty')}</p>
</div>
`;
return;
}
queueList.innerHTML = this.queue.map((track, idx) => `
<div class="player-queue-item ${idx === this.currentIndex ? 'active' : ''}" data-idx="${idx}">
<span class="queue-item-num">${idx + 1}</span>
<span class="queue-item-info">
<span class="queue-item-name">${this._escapeHtml(track.title || track.file_name || t('music.unknown_title', 'Unknown'))}</span>
<span class="queue-item-artist">${this._escapeHtml(track.artist || t('music.unknown_artist', 'Unknown Artist'))}</span>
</span>
<span class="queue-item-duration">${this._formatDuration(track.duration_secs)}</span>
<button class="queue-item-remove" data-idx="${idx}">
<i class="fas fa-times"></i>
</button>
</div>
`).join('');
queueList.querySelectorAll('.player-queue-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.queue-item-remove')) return;
const idx = parseInt(item.dataset.idx);
this.playTrack(idx);
});
});
queueList.querySelectorAll('.queue-item-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
this._removeFromQueue(idx);
});
});
},
_removeFromQueue(idx) {
if (idx === this.currentIndex) {
if (this.queue.length === 1) {
this.audio.pause();
this.currentTrack = null;
this.currentIndex = -1;
} else {
this.next();
}
}
this.queue.splice(idx, 1);
if (idx < this.currentIndex) {
this.currentIndex--;
}
this._updateQueueUI();
this._updateUI();
},
_toggleQueue(show) {
const queue = document.getElementById('player-queue');
if (queue) {
if (show === undefined) {
queue.classList.toggle('hidden');
} else {
queue.classList.toggle('hidden', !show);
}
}
},
_formatTime(secs) {
if (!secs || isNaN(secs)) return '0:00';
const mins = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${mins}:${s.toString().padStart(2, '0')}`;
},
_formatDuration(secs) {
return this._formatTime(secs);
},
_escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
};
document.addEventListener('DOMContentLoaded', () => {
musicPlayer.init();
});
window.musicView = musicView;
+45
View File
@@ -9,6 +9,7 @@
"recent": "الأخيرة",
"favorites": "المفضلة",
"photos": "الصور",
"music": "الموسيقى",
"trash": "سلة المهملات"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "شهر",
"view_yearly": "سنة"
},
"music": {
"create_playlist": "إنشاء قائمة تشغيل",
"playlists": "قوائم التشغيل",
"no_playlists": "لا توجد قوائم تشغيل بعد",
"select_playlist": "اختر قائمة تشغيل",
"select_hint": "اختر قائمة تشغيل من الشريط الجانبي أو أنشئ واحدة جديدة",
"add_tracks": "إضافة مقاطع",
"no_tracks": "لا توجد مقاطع في هذه القائمة",
"unknown_artist": "فنان غير معروف",
"unknown_title": "غير معروف",
"confirm_delete": "هل تريد حذف هذه القائمة؟",
"playlist_name": "اسم القائمة",
"create": "إنشاء",
"delete": "حذف",
"share": "مشاركة",
"edit": "تعديل",
"play_all": "تشغيل الكل",
"shuffle": "عشوائي",
"repeat": "تكرار",
"repeat_one": "تكرار واحد",
"queue": "قائمة الانتظار",
"queue_empty": "قائمة الانتظار فارغة",
"not_playing": "لا يتم التشغيل",
"play": "تشغيل",
"pause": "إيقاف مؤقت",
"previous": "السابق",
"next": "التالي",
"volume": "مستوى الصوت",
"mute": "كتم",
"unmute": "إلغاء الكتم",
"title": "العنوان",
"artist": "الفنان",
"album": "الألبوم",
"tracks": "مقاطع",
"add": "إضافة",
"added": "تمت الإضافة!",
"added_to_playlist": "تمت إضافته إلى القائمة",
"add_to_playlist": "إضافة إلى القائمة",
"load_error": "خطأ في تحميل القوائم",
"add_error": "تعذر إضافة المقاطع",
"no_playlists_yet": "لا توجد قوائم بعد. أنشئ واحدة أولاً!",
"selected_files": "محدد:",
"error": "خطأ"
},
"actions": {
"search": "البحث في الملفات...",
"new_folder": "مجلد جديد",
+45
View File
@@ -9,6 +9,7 @@
"recent": "Zuletzt verwendet",
"favorites": "Favoriten",
"photos": "Fotos",
"music": "Musik",
"trash": "Papierkorb"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "Monat",
"view_yearly": "Jahr"
},
"music": {
"create_playlist": "Playlist erstellen",
"playlists": "Playlists",
"no_playlists": "Noch keine Playlists",
"select_playlist": "Playlist auswählen",
"select_hint": "Wählen Sie eine Playlist aus der Seitenleiste oder erstellen Sie eine neue",
"add_tracks": "Titel hinzufügen",
"no_tracks": "Keine Titel in dieser Playlist",
"unknown_artist": "Unbekannter Künstler",
"unknown_title": "Unbekannt",
"confirm_delete": "Diese Playlist löschen?",
"playlist_name": "Playlist-Name",
"create": "Erstellen",
"delete": "Löschen",
"share": "Teilen",
"edit": "Bearbeiten",
"play_all": "Alle abspielen",
"shuffle": "Zufällig",
"repeat": "Wiederholen",
"repeat_one": "Einen wiederholen",
"queue": "Warteschlange",
"queue_empty": "Warteschlange ist leer",
"not_playing": "Nicht abspielend",
"play": "Abspielen",
"pause": "Pause",
"previous": "Zurück",
"next": "Weiter",
"volume": "Lautstärke",
"mute": "Stumm",
"unmute": "Ton ein",
"title": "Titel",
"artist": "Künstler",
"album": "Album",
"tracks": "Titel",
"add": "Hinzufügen",
"added": "Hinzugefügt!",
"added_to_playlist": "zur Playlist hinzugefügt",
"add_to_playlist": "Zur Playlist hinzufügen",
"load_error": "Fehler beim Laden der Playlists",
"add_error": "Tracks konnten nicht hinzugefügt werden",
"no_playlists_yet": "Noch keine Playlists. Erstellen Sie zuerst eine!",
"selected_files": "Ausgewählt:",
"error": "Fehler"
},
"actions": {
"search": "Dateien suchen...",
"new_folder": "Neuer Ordner",
+45
View File
@@ -9,6 +9,7 @@
"recent": "Recent",
"favorites": "Favorites",
"photos": "Photos",
"music": "Music",
"trash": "Trash"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "Month",
"view_yearly": "Year"
},
"music": {
"create_playlist": "Create Playlist",
"playlists": "Playlists",
"no_playlists": "No playlists yet",
"select_playlist": "Select a playlist",
"select_hint": "Choose a playlist from the sidebar or create a new one",
"add_tracks": "Add Tracks",
"add_to_playlist": "Add to Playlist",
"add": "Add",
"added": "Added!",
"added_to_playlist": "added to playlist",
"load_error": "Error loading playlists",
"add_error": "Could not add tracks to playlist",
"no_playlists_yet": "No playlists yet. Create one first!",
"selected_files": "Selected:",
"no_tracks": "No tracks in this playlist",
"unknown_artist": "Unknown Artist",
"unknown_title": "Unknown",
"confirm_delete": "Delete this playlist?",
"playlist_name": "Playlist name",
"create": "Create",
"delete": "Delete",
"share": "Share",
"edit": "Edit",
"play_all": "Play All",
"shuffle": "Shuffle",
"repeat": "Repeat",
"repeat_one": "Repeat One",
"queue": "Queue",
"queue_empty": "Queue is empty",
"not_playing": "Not playing",
"play": "Play",
"pause": "Pause",
"previous": "Previous",
"next": "Next",
"volume": "Volume",
"mute": "Mute",
"unmute": "Unmute",
"title": "Title",
"artist": "Artist",
"album": "Album",
"tracks": "tracks",
"error": "Error"
},
"actions": {
"search": "Search files...",
"new_folder": "New folder",
+45
View File
@@ -9,6 +9,7 @@
"recent": "Recientes",
"favorites": "Favoritos",
"photos": "Fotos",
"music": "Música",
"trash": "Papelera"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "Mes",
"view_yearly": "Año"
},
"music": {
"create_playlist": "Crear Lista",
"playlists": "Listas",
"no_playlists": "Sin listas aún",
"select_playlist": "Selecciona una lista",
"select_hint": "Elige una lista de la barra lateral o crea una nueva",
"add_tracks": "Añadir Pistas",
"no_tracks": "No hay pistas en esta lista",
"unknown_artist": "Artista Desconocido",
"unknown_title": "Desconocido",
"confirm_delete": "¿Eliminar esta lista?",
"playlist_name": "Nombre de la lista",
"create": "Crear",
"delete": "Eliminar",
"share": "Compartir",
"edit": "Editar",
"play_all": "Reproducir Todo",
"shuffle": "Aleatorio",
"repeat": "Repetir",
"repeat_one": "Repetir Una",
"queue": "Cola",
"queue_empty": "Cola vacía",
"not_playing": "No reproduciendo",
"play": "Reproducir",
"pause": "Pausar",
"previous": "Anterior",
"next": "Siguiente",
"volume": "Volumen",
"mute": "Silenciar",
"unmute": "Activar sonido",
"title": "Título",
"artist": "Artista",
"album": "Álbum",
"tracks": "pistas",
"add": "Añadir",
"added": "¡Añadido!",
"added_to_playlist": "añadido a la lista",
"add_to_playlist": "Añadir a playlist",
"load_error": "Error al cargar listas",
"add_error": "No se pudieron añadir las pistas",
"no_playlists_yet": "No hay listas aún. ¡Crea una primero!",
"selected_files": "Seleccionados:",
"error": "Error"
},
"share": {
"dialogTitle": "Compartir Enlace",
"linkLabel": "Enlace compartido:",
+58
View File
@@ -9,6 +9,7 @@
"recent": "اخیر",
"favorites": "موردعلاقه‌ها",
"photos": "عکس‌ها",
"music": "موسیقی",
"trash": "سطل زباله"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "ماه",
"view_yearly": "سال"
},
"music": {
"create_playlist": "ایجاد فهرست پخش",
"playlists": "فهرست‌های پخش",
"no_playlists": "هنوز فهرست پخشی نیست",
"select_playlist": "یک فهرست پخش انتخاب کنید",
"select_hint": "از نوار کناری یک فهرست پخش انتخاب کنید یا یکی جدید بسازید",
"add_tracks": "افزودن آهنگ‌ها",
"no_tracks": "هیچ آهنگی در این فهرست پخش نیست",
"unknown_artist": "هنرمند ناشناس",
"unknown_title": "ناشناس",
"confirm_delete": "این فهرست پخش حذف شود؟",
"playlist_name": "نام فهرست پخش",
"create": "ایجاد",
"delete": "حذف",
"share": "هم‌رسانی",
"edit": "ویرایش",
"play_all": "پخش همه",
"shuffle": "تصادفی",
"repeat": "تکرار",
"repeat_one": "تکرار یک",
"queue": "صف",
"queue_empty": "صف خالی است",
"not_playing": "در حال پخش نیست",
"play": "پخش",
"pause": "توقف",
"previous": "قبلی",
"next": "بعدی",
"volume": "صدا",
"mute": "بی‌صدا",
"unmute": "صدا فعال",
"title": "عنوان",
"artist": "هنرمند",
"album": "آلبوم",
"tracks": "آهنگ",
"add": "افزودن",
"added": "افزوده شد!",
"added_to_playlist": "به فهرست پخش افزوده شد",
"add_to_playlist": "افزودن به فهرست پخش",
"load_error": "خطا در بارگیری فهرست پخش",
"add_error": "امکان افزودن آهنگ‌ها به فهرست پخش نیست",
"no_playlists_yet": "فهرست پخشی وجود ندارد. اول یکی بسازید!",
"selected_files": "انتخاب شده:",
"error": "خطا"
},
"actions": {
"search": "جست‌و‌جوی پرونده‌ها..",
"new_folder": "پوشهٔ جدید",
@@ -523,5 +568,18 @@
"error_create_pw": "ایجاد رمز ناموفق بود",
"confirm_revoke": "رمز «{{label}}» ابطال شود؟ کلاینت‌ها از کار می‌افتند.",
"error_revoke": "ابطال ناموفق بود"
},
"notifications": {
"file_renamed": "فایل تغییر نام داد",
"file_renamed_to": "فایل به \"{{name}}\" تغییر نام داد",
"folder_renamed": "پوشه تغییر نام داد",
"folder_renamed_to": "پوشه به \"{{name}}\" تغییر نام داد",
"file_uploaded": "فایل آپلود شد",
"file_deleted": "فایل به زباله‌دان منتقل شد",
"folder_deleted": "پوشه به زباله‌دان منتقل شد",
"item_deleted_permanently": "آیتم برای همیشه حذف شد",
"trash_emptied": "زباله‌دان خالی شد",
"title": "اعلان‌ها",
"empty": "بدون اعلان"
}
}
+45
View File
@@ -9,6 +9,7 @@
"recent": "Récents",
"favorites": "Favoris",
"photos": "Photos",
"music": "Musique",
"trash": "Corbeille"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "Mois",
"view_yearly": "Année"
},
"music": {
"create_playlist": "Créer une Playlist",
"playlists": "Playlists",
"no_playlists": "Aucune playlist",
"select_playlist": "Sélectionnez une playlist",
"select_hint": "Choisissez une playlist dans la barre latérale ou créez-en une nouvelle",
"add_tracks": "Ajouter des Pistes",
"no_tracks": "Aucune piste dans cette playlist",
"unknown_artist": "Artiste Inconnu",
"unknown_title": "Inconnu",
"confirm_delete": "Supprimer cette playlist ?",
"playlist_name": "Nom de la playlist",
"create": "Créer",
"delete": "Supprimer",
"share": "Partager",
"edit": "Modifier",
"play_all": "Tout Lire",
"shuffle": "Aléatoire",
"repeat": "Répéter",
"repeat_one": "Répéter Une",
"queue": "File d'attente",
"queue_empty": "File d'attente vide",
"not_playing": "Pas en lecture",
"play": "Lecture",
"pause": "Pause",
"previous": "Précédent",
"next": "Suivant",
"volume": "Volume",
"mute": "Muet",
"unmute": "Activer le son",
"title": "Titre",
"artist": "Artiste",
"album": "Album",
"tracks": "pistes",
"add": "Ajouter",
"added": "Ajouté !",
"added_to_playlist": "ajouté à la playlist",
"add_to_playlist": "Ajouter à la playlist",
"load_error": "Erreur de chargement des playlists",
"add_error": "Impossible d'ajouter les pistes",
"no_playlists_yet": "Pas encore de playlists. Créez-en une d'abord !",
"selected_files": "Sélectionnés :",
"error": "Erreur"
},
"actions": {
"search": "Rechercher des fichiers...",
"new_folder": "Nouveau dossier",
+45
View File
@@ -9,6 +9,7 @@
"recent": "हाल ही में",
"favorites": "पसंदीदा",
"photos": "फ़ोटो",
"music": "संगीत",
"trash": "रद्दी"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "महीना",
"view_yearly": "वर्ष"
},
"music": {
"create_playlist": "प्लेलिस्ट बनाएँ",
"playlists": "प्लेलिस्ट",
"no_playlists": "अभी कोई प्लेलिस्ट नहीं",
"select_playlist": "प्लेलिस्ट चुनें",
"select_hint": "साइडबार से प्लेलिस्ट चुनें या नई बनाएँ",
"add_tracks": "ट्रैक जोड़ें",
"no_tracks": "इस प्लेलिस्ट में कोई ट्रैक नहीं",
"unknown_artist": "अज्ञात कलाकार",
"unknown_title": "अज्ञात",
"confirm_delete": "इस प्लेलिस्ट को हटाएँ?",
"playlist_name": "प्लेलिस्ट का नाम",
"create": "बनाएँ",
"delete": "हटाएँ",
"share": "साझा करें",
"edit": "संपादित करें",
"play_all": "सभी चलाएँ",
"shuffle": "शफल",
"repeat": "दोहराएँ",
"repeat_one": "एक दोहराएँ",
"queue": "कतार",
"queue_empty": "कतार खाली है",
"not_playing": "नहीं चल रहा",
"play": "चलाएँ",
"pause": "रोकें",
"previous": "पिछला",
"next": "अगला",
"volume": "आवाज़",
"mute": "म्यूट",
"unmute": "अनम्यूट",
"title": "शीर्षक",
"artist": "कलाकार",
"album": "एल्बम",
"tracks": "ट्रैक",
"add": "जोड़ें",
"added": "जोड़ा गया!",
"added_to_playlist": "प्लेलिस्ट में जोड़ा गया",
"add_to_playlist": "प्लेलिस्ट में जोड़ें",
"load_error": "प्लेलिस्ट लोड करने में त्रुटि",
"add_error": "प्लेलिस्ट में ट्रैक नहीं जोड़े जा सके",
"no_playlists_yet": "अभी तक कोई प्लेलिस्ट नहीं। पहले एक बनाएं!",
"selected_files": "चयनित:",
"error": "त्रुटि"
},
"actions": {
"search": "फ़ाइलें खोजें...",
"new_folder": "नया फ़ोल्डर",
+45
View File
@@ -9,6 +9,7 @@
"recent": "Recenti",
"favorites": "Preferiti",
"photos": "Foto",
"music": "Musica",
"trash": "Cestino"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "Mese",
"view_yearly": "Anno"
},
"music": {
"create_playlist": "Crea Playlist",
"playlists": "Playlist",
"no_playlists": "Nessuna playlist",
"select_playlist": "Seleziona una playlist",
"select_hint": "Scegli una playlist dalla barra laterale o creane una nuova",
"add_tracks": "Aggiungi Tracce",
"no_tracks": "Nessuna traccia in questa playlist",
"unknown_artist": "Artista Sconosciuto",
"unknown_title": "Sconosciuto",
"confirm_delete": "Eliminare questa playlist?",
"playlist_name": "Nome playlist",
"create": "Crea",
"delete": "Elimina",
"share": "Condividi",
"edit": "Modifica",
"play_all": "Riproduci Tutto",
"shuffle": "Casuale",
"repeat": "Ripeti",
"repeat_one": "Ripeti Una",
"queue": "Coda",
"queue_empty": "Coda vuota",
"not_playing": "Non in riproduzione",
"play": "Riproduci",
"pause": "Pausa",
"previous": "Precedente",
"next": "Successivo",
"volume": "Volume",
"mute": "Muto",
"unmute": "Attiva audio",
"title": "Titolo",
"artist": "Artista",
"album": "Album",
"tracks": "tracce",
"add": "Aggiungi",
"added": "Aggiunto!",
"added_to_playlist": "aggiunto alla playlist",
"add_to_playlist": "Aggiungi alla playlist",
"load_error": "Errore nel caricamento delle playlist",
"add_error": "Impossibile aggiungere le tracce",
"no_playlists_yet": "Nessuna playlist ancora. Creane una prima!",
"selected_files": "Selezionati:",
"error": "Errore"
},
"actions": {
"search": "Cerca file...",
"new_folder": "Nuova cartella",
+45
View File
@@ -9,6 +9,7 @@
"recent": "最近",
"favorites": "お気に入り",
"photos": "写真",
"music": "音楽",
"trash": "ゴミ箱"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "月",
"view_yearly": "年"
},
"music": {
"create_playlist": "プレイリストを作成",
"playlists": "プレイリスト",
"no_playlists": "プレイリストがありません",
"select_playlist": "プレイリストを選択",
"select_hint": "サイドバーからプレイリストを選択するか、新しいものを作成してください",
"add_tracks": "トラックを追加",
"no_tracks": "このプレイリストにトラックがありません",
"unknown_artist": "不明なアーティスト",
"unknown_title": "不明",
"confirm_delete": "このプレイリストを削除しますか?",
"playlist_name": "プレイリスト名",
"create": "作成",
"delete": "削除",
"share": "共有",
"edit": "編集",
"play_all": "すべて再生",
"shuffle": "シャッフル",
"repeat": "リピート",
"repeat_one": "1曲リピート",
"queue": "キュー",
"queue_empty": "キューが空です",
"not_playing": "再生していません",
"play": "再生",
"pause": "一時停止",
"previous": "前へ",
"next": "次へ",
"volume": "音量",
"mute": "ミュート",
"unmute": "ミュート解除",
"title": "タイトル",
"artist": "アーティスト",
"album": "アルバム",
"tracks": "曲",
"add": "追加",
"added": "追加しました!",
"added_to_playlist": "プレイリストに追加しました",
"add_to_playlist": "プレイリストに追加",
"load_error": "プレイリストの読み込みエラー",
"add_error": "曲を追加できませんでした",
"no_playlists_yet": "プレイリストがありません。最初に作成してください!",
"selected_files": "選択中:",
"error": "エラー"
},
"actions": {
"search": "ファイルを検索...",
"new_folder": "新しいフォルダ",
+45
View File
@@ -9,6 +9,7 @@
"recent": "최근",
"favorites": "즐겨찾기",
"photos": "사진",
"music": "음악",
"trash": "휴지통"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "월",
"view_yearly": "년"
},
"music": {
"create_playlist": "재생목록 만들기",
"playlists": "재생목록",
"no_playlists": "아직 재생목록이 없습니다",
"select_playlist": "재생목록 선택",
"select_hint": "사이드바에서 재생목록을 선택하거나 새로 만드세요",
"add_tracks": "트랙 추가",
"no_tracks": "이 재생목록에 트랙이 없습니다",
"unknown_artist": "알 수 없는 아티스트",
"unknown_title": "알 수 없음",
"confirm_delete": "이 재생목록을 삭제하시겠습니까?",
"playlist_name": "재생목록 이름",
"create": "만들기",
"delete": "삭제",
"share": "공유",
"edit": "편집",
"play_all": "전체 재생",
"shuffle": "셔플",
"repeat": "반복",
"repeat_one": "한 곡 반복",
"queue": "대기열",
"queue_empty": "대기열이 비어 있습니다",
"not_playing": "재생 중이 아닙니다",
"play": "재생",
"pause": "일시정지",
"previous": "이전",
"next": "다음",
"volume": "볼륨",
"mute": "음소거",
"unmute": "음소거 해제",
"title": "제목",
"artist": "아티스트",
"album": "앨범",
"tracks": "개 트랙",
"add": "추가",
"added": "추가됨!",
"added_to_playlist": "플레이리스트에 추가됨",
"add_to_playlist": "플레이리스트에 추가",
"load_error": "플레이리스트 로드 오류",
"add_error": "트랙을 플레이리스트에 추가할 수 없습니다",
"no_playlists_yet": "플레이리스트가 없습니다. 먼저 하나를 만드세요!",
"selected_files": "선택됨:",
"error": "오류"
},
"actions": {
"search": "파일 검색...",
"new_folder": "새 폴더",
+45
View File
@@ -9,6 +9,7 @@
"recent": "Recente",
"favorites": "Favorieten",
"photos": "Foto's",
"music": "Muziek",
"trash": "Prullenbak"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "Maand",
"view_yearly": "Jaar"
},
"music": {
"create_playlist": "Afspeellijst Maken",
"playlists": "Afspeellijsten",
"no_playlists": "Nog geen afspeellijsten",
"select_playlist": "Selecteer een afspeellijst",
"select_hint": "Kies een afspeellijst uit de zijbalk of maak een nieuwe",
"add_tracks": "Tracks Toevoegen",
"no_tracks": "Geen tracks in deze afspeellijst",
"unknown_artist": "Onbekende Artiest",
"unknown_title": "Onbekend",
"confirm_delete": "Deze afspeellijst verwijderen?",
"playlist_name": "Naam afspeellijst",
"create": "Maken",
"delete": "Verwijderen",
"share": "Delen",
"edit": "Bewerken",
"play_all": "Alles Afspelen",
"shuffle": "Shuffle",
"repeat": "Herhalen",
"repeat_one": "Een Herhalen",
"queue": "Wachtrij",
"queue_empty": "Wachtrij is leeg",
"not_playing": "Niet afspelend",
"play": "Afspelen",
"pause": "Pauzeren",
"previous": "Vorige",
"next": "Volgende",
"volume": "Volume",
"mute": "Dempen",
"unmute": "Geluid aan",
"title": "Titel",
"artist": "Artiest",
"album": "Album",
"tracks": "tracks",
"add": "Toevoegen",
"added": "Toegevoegd!",
"added_to_playlist": "toegevoegd aan playlist",
"add_to_playlist": "Aan playlist toevoegen",
"load_error": "Fout bij laden van playlists",
"add_error": "Kon tracks niet toevoegen aan playlist",
"no_playlists_yet": "Nog geen playlists. Maak er eerst een!",
"selected_files": "Geselecteerd:",
"error": "Fout"
},
"actions": {
"search": "Zoek bestanden...",
"new_folder": "Nieuwe map",
+45
View File
@@ -9,6 +9,7 @@
"recent": "Recentes",
"favorites": "Favoritos",
"photos": "Fotos",
"music": "Música",
"trash": "Lixeira"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "Mês",
"view_yearly": "Ano"
},
"music": {
"create_playlist": "Criar Playlist",
"playlists": "Playlists",
"no_playlists": "Nenhuma playlist ainda",
"select_playlist": "Selecione uma playlist",
"select_hint": "Escolha uma playlist na barra lateral ou crie uma nova",
"add_tracks": "Adicionar Faixas",
"no_tracks": "Nenhuma faixa nesta playlist",
"unknown_artist": "Artista Desconhecido",
"unknown_title": "Desconhecido",
"confirm_delete": "Excluir esta playlist?",
"playlist_name": "Nome da playlist",
"create": "Criar",
"delete": "Excluir",
"share": "Compartilhar",
"edit": "Editar",
"play_all": "Reproduzir Tudo",
"shuffle": "Aleatório",
"repeat": "Repetir",
"repeat_one": "Repetir Uma",
"queue": "Fila",
"queue_empty": "Fila vazia",
"not_playing": "Não reproduzindo",
"play": "Reproduzir",
"pause": "Pausar",
"previous": "Anterior",
"next": "Próximo",
"volume": "Volume",
"mute": "Mudo",
"unmute": "Ativar som",
"title": "Título",
"artist": "Artista",
"album": "Álbum",
"tracks": "faixas",
"add": "Adicionar",
"added": "Adicionado!",
"added_to_playlist": "adicionado à playlist",
"add_to_playlist": "Adicionar à playlist",
"load_error": "Erro ao carregar playlists",
"add_error": "Não foi possível adicionar as faixas",
"no_playlists_yet": "Nenhuma playlist ainda. Crie uma primeiro!",
"selected_files": "Selecionados:",
"error": "Erro"
},
"actions": {
"search": "Pesquisar arquivos...",
"new_folder": "Nova pasta",
+45
View File
@@ -9,6 +9,7 @@
"recent": "Недавние",
"favorites": "Избранное",
"photos": "Фото",
"music": "Музыка",
"trash": "Корзина"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "Месяц",
"view_yearly": "Год"
},
"music": {
"create_playlist": "Создать плейлист",
"playlists": "Плейлисты",
"no_playlists": "Плейлистов пока нет",
"select_playlist": "Выберите плейлист",
"select_hint": "Выберите плейлист на панели слева или создайте новый",
"add_tracks": "Добавить треки",
"no_tracks": "В этом плейлисте нет треков",
"unknown_artist": "Неизвестный исполнитель",
"unknown_title": "Неизвестно",
"confirm_delete": "Удалить этот плейлист?",
"playlist_name": "Название плейлиста",
"create": "Создать",
"delete": "Удалить",
"share": "Поделиться",
"edit": "Редактировать",
"play_all": "Воспроизвести все",
"shuffle": "Перемешать",
"repeat": "Повтор",
"repeat_one": "Повторять один",
"queue": "Очередь",
"queue_empty": "Очередь пуста",
"not_playing": "Ничего не играет",
"play": "Воспроизвести",
"pause": "Пауза",
"previous": "Предыдущий",
"next": "Следующий",
"volume": "Громкость",
"mute": "Выключить звук",
"unmute": "Включить звук",
"title": "Название",
"artist": "Исполнитель",
"album": "Альбом",
"tracks": "треков",
"add": "Добавить",
"added": "Добавлено!",
"added_to_playlist": "добавлен в плейлист",
"add_to_playlist": "Добавить в плейлист",
"load_error": "Ошибка загрузки плейлистов",
"add_error": "Не удалось добавить треки в плейлист",
"no_playlists_yet": "Плейлистов пока нет. Создайте сначала!",
"selected_files": "Выбрано:",
"error": "Ошибка"
},
"actions": {
"search": "Поиск файлов...",
"new_folder": "Новая папка",
+58
View File
@@ -9,6 +9,7 @@
"recent": "最近",
"favorites": "收藏",
"photos": "照片",
"music": "音乐",
"trash": "回收站"
},
"photos": {
@@ -19,6 +20,50 @@
"view_monthly": "月",
"view_yearly": "年"
},
"music": {
"create_playlist": "创建播放列表",
"playlists": "播放列表",
"no_playlists": "还没有播放列表",
"select_playlist": "选择一个播放列表",
"select_hint": "从侧边栏选择播放列表或创建新播放列表",
"add_tracks": "添加曲目",
"no_tracks": "此播放列表中没有曲目",
"unknown_artist": "未知艺术家",
"unknown_title": "未知",
"confirm_delete": "删除此播放列表?",
"playlist_name": "播放列表名称",
"create": "创建",
"delete": "删除",
"share": "分享",
"edit": "编辑",
"play_all": "全部播放",
"shuffle": "随机播放",
"repeat": "重复",
"repeat_one": "单曲循环",
"queue": "播放队列",
"queue_empty": "播放队列为空",
"not_playing": "未播放",
"play": "播放",
"pause": "暂停",
"previous": "上一首",
"next": "下一首",
"volume": "音量",
"mute": "静音",
"unmute": "取消静音",
"title": "标题",
"artist": "艺术家",
"album": "专辑",
"tracks": "首曲目",
"add": "添加",
"added": "已添加!",
"added_to_playlist": "已添加到播放列表",
"add_to_playlist": "添加到播放列表",
"load_error": "加载播放列表出错",
"add_error": "无法将曲目添加到播放列表",
"no_playlists_yet": "暂无播放列表。请先创建一个!",
"selected_files": "已选择:",
"error": "错误"
},
"actions": {
"search": "搜索文件...",
"new_folder": "新建文件夹",
@@ -485,5 +530,18 @@
"error_create_pw": "创建应用密码失败",
"confirm_revoke": "撤销应用密码\"{{label}}\"?使用此密码的客户端将停止工作。",
"error_revoke": "撤销失败"
},
"notifications": {
"file_renamed": "文件已重命名",
"file_renamed_to": "文件已重命名为\"{{name}}\"",
"folder_renamed": "文件夹已重命名",
"folder_renamed_to": "文件夹已重命名为\"{{name}}\"",
"file_uploaded": "文件已上传",
"file_deleted": "文件已移至回收站",
"folder_deleted": "文件夹已移至回收站",
"item_deleted_permanently": "项目已永久删除",
"trash_emptied": "回收站已清空",
"title": "通知",
"empty": "暂无通知"
}
}