Music Player & Playlist Manager
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user