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
+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
}
}