Music Player & Playlist Manager

This commit is contained in:
Andrey Tkachenko
2026-04-08 15:14:03 +03:00
parent 2e8e0ef6c5
commit da066f47fa
53 changed files with 6043 additions and 23 deletions
+1
View File
@@ -6,6 +6,7 @@ pub mod device_code;
pub mod entity_errors;
pub mod file;
pub mod folder;
pub mod playlist;
pub mod session;
pub mod share;
pub mod trashed_item;
+470
View File
@@ -0,0 +1,470 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::common::errors::{DomainError, ErrorKind, Result};
#[derive(Debug, Clone)]
pub struct Playlist {
id: Uuid,
name: String,
description: Option<String>,
owner_id: Uuid,
is_public: bool,
cover_file_id: Option<Uuid>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl Playlist {
pub fn new(name: String, owner_id: Uuid, description: Option<String>) -> Result<Self> {
if name.is_empty() {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"Playlist",
"Playlist name cannot be empty",
));
}
let now = Utc::now();
Ok(Self {
id: Uuid::new_v4(),
name,
description,
owner_id,
is_public: false,
cover_file_id: None,
created_at: now,
updated_at: now,
})
}
#[allow(clippy::too_many_arguments)]
pub fn with_id(
id: Uuid,
name: String,
description: Option<String>,
owner_id: Uuid,
is_public: bool,
cover_file_id: Option<Uuid>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
) -> Result<Self> {
if name.is_empty() {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"Playlist",
"Playlist name cannot be empty",
));
}
Ok(Self {
id,
name,
description,
owner_id,
is_public,
cover_file_id,
created_at,
updated_at,
})
}
pub fn id(&self) -> &Uuid {
&self.id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn owner_id(&self) -> &Uuid {
&self.owner_id
}
pub fn is_public(&self) -> bool {
self.is_public
}
pub fn cover_file_id(&self) -> Option<&Uuid> {
self.cover_file_id.as_ref()
}
pub fn created_at(&self) -> &DateTime<Utc> {
&self.created_at
}
pub fn updated_at(&self) -> &DateTime<Utc> {
&self.updated_at
}
pub fn update_name(&mut self, name: String) -> Result<()> {
if name.is_empty() {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"Playlist",
"Playlist name cannot be empty",
));
}
self.name = name;
self.updated_at = Utc::now();
Ok(())
}
pub fn update_description(&mut self, description: Option<String>) {
self.description = description;
self.updated_at = Utc::now();
}
pub fn set_public(&mut self, is_public: bool) {
self.is_public = is_public;
self.updated_at = Utc::now();
}
pub fn set_cover(&mut self, cover_file_id: Option<Uuid>) {
self.cover_file_id = cover_file_id;
self.updated_at = Utc::now();
}
pub fn belongs_to(&self, user_id: &Uuid) -> bool {
self.owner_id == *user_id
}
pub fn touch(&mut self) {
self.updated_at = Utc::now();
}
}
#[derive(Debug, Clone)]
pub struct PlaylistItem {
id: Uuid,
playlist_id: Uuid,
file_id: Uuid,
position: i32,
added_at: DateTime<Utc>,
}
impl PlaylistItem {
pub fn new(playlist_id: Uuid, file_id: Uuid, position: i32) -> Result<Self> {
if position < 0 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"PlaylistItem",
"Position cannot be negative",
));
}
Ok(Self {
id: Uuid::new_v4(),
playlist_id,
file_id,
position,
added_at: Utc::now(),
})
}
pub fn with_id(
id: Uuid,
playlist_id: Uuid,
file_id: Uuid,
position: i32,
added_at: DateTime<Utc>,
) -> Result<Self> {
if position < 0 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"PlaylistItem",
"Position cannot be negative",
));
}
Ok(Self {
id,
playlist_id,
file_id,
position,
added_at,
})
}
pub fn id(&self) -> &Uuid {
&self.id
}
pub fn playlist_id(&self) -> &Uuid {
&self.playlist_id
}
pub fn file_id(&self) -> &Uuid {
&self.file_id
}
pub fn position(&self) -> i32 {
self.position
}
pub fn added_at(&self) -> &DateTime<Utc> {
&self.added_at
}
pub fn set_position(&mut self, position: i32) -> Result<()> {
if position < 0 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"PlaylistItem",
"Position cannot be negative",
));
}
self.position = position;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct AudioFileMetadata {
file_id: Uuid,
title: Option<String>,
artist: Option<String>,
album: Option<String>,
album_artist: Option<String>,
genre: Option<String>,
track_number: Option<i32>,
disc_number: Option<i32>,
year: Option<i32>,
duration_secs: i32,
bitrate: Option<i32>,
sample_rate: Option<i32>,
channels: Option<i16>,
format: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl AudioFileMetadata {
pub fn new(file_id: Uuid, duration_secs: i32) -> Self {
let now = Utc::now();
Self {
file_id,
title: None,
artist: None,
album: None,
album_artist: None,
genre: None,
track_number: None,
disc_number: None,
year: None,
duration_secs,
bitrate: None,
sample_rate: None,
channels: None,
format: None,
created_at: now,
updated_at: now,
}
}
#[allow(clippy::too_many_arguments)]
pub fn with_all_fields(
file_id: Uuid,
title: Option<String>,
artist: Option<String>,
album: Option<String>,
album_artist: Option<String>,
genre: Option<String>,
track_number: Option<i32>,
disc_number: Option<i32>,
year: Option<i32>,
duration_secs: i32,
bitrate: Option<i32>,
sample_rate: Option<i32>,
channels: Option<i16>,
format: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
) -> Self {
Self {
file_id,
title,
artist,
album,
album_artist,
genre,
track_number,
disc_number,
year,
duration_secs,
bitrate,
sample_rate,
channels,
format,
created_at,
updated_at,
}
}
pub fn file_id(&self) -> &Uuid {
&self.file_id
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn artist(&self) -> Option<&str> {
self.artist.as_deref()
}
pub fn album(&self) -> Option<&str> {
self.album.as_deref()
}
pub fn album_artist(&self) -> Option<&str> {
self.album_artist.as_deref()
}
pub fn genre(&self) -> Option<&str> {
self.genre.as_deref()
}
pub fn track_number(&self) -> Option<i32> {
self.track_number
}
pub fn disc_number(&self) -> Option<i32> {
self.disc_number
}
pub fn year(&self) -> Option<i32> {
self.year
}
pub fn duration_secs(&self) -> i32 {
self.duration_secs
}
pub fn bitrate(&self) -> Option<i32> {
self.bitrate
}
pub fn sample_rate(&self) -> Option<i32> {
self.sample_rate
}
pub fn channels(&self) -> Option<i16> {
self.channels
}
pub fn format(&self) -> Option<&str> {
self.format.as_deref()
}
pub fn created_at(&self) -> &DateTime<Utc> {
&self.created_at
}
pub fn updated_at(&self) -> &DateTime<Utc> {
&self.updated_at
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
self.updated_at = Utc::now();
}
pub fn set_artist(&mut self, artist: Option<String>) {
self.artist = artist;
self.updated_at = Utc::now();
}
pub fn set_album(&mut self, album: Option<String>) {
self.album = album;
self.updated_at = Utc::now();
}
pub fn set_album_artist(&mut self, album_artist: Option<String>) {
self.album_artist = album_artist;
self.updated_at = Utc::now();
}
pub fn set_genre(&mut self, genre: Option<String>) {
self.genre = genre;
self.updated_at = Utc::now();
}
pub fn set_track_number(&mut self, track_number: Option<i32>) {
self.track_number = track_number;
self.updated_at = Utc::now();
}
pub fn set_disc_number(&mut self, disc_number: Option<i32>) {
self.disc_number = disc_number;
self.updated_at = Utc::now();
}
pub fn set_year(&mut self, year: Option<i32>) {
self.year = year;
self.updated_at = Utc::now();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_playlist() {
let owner_id = Uuid::new_v4();
let playlist = Playlist::new("My Playlist".to_string(), owner_id, None);
assert!(playlist.is_ok());
let playlist = playlist.unwrap();
assert_eq!(playlist.name(), "My Playlist");
assert!(!playlist.is_public());
}
#[test]
fn test_create_playlist_empty_name() {
let owner_id = Uuid::new_v4();
let playlist = Playlist::new("".to_string(), owner_id, None);
assert!(playlist.is_err());
}
#[test]
fn test_playlist_update() {
let owner_id = Uuid::new_v4();
let mut playlist = Playlist::new("My Playlist".to_string(), owner_id, None).unwrap();
playlist
.update_name("Updated Playlist".to_string())
.unwrap();
assert_eq!(playlist.name(), "Updated Playlist");
}
#[test]
fn test_create_playlist_item() {
let playlist_id = Uuid::new_v4();
let file_id = Uuid::new_v4();
let item = PlaylistItem::new(playlist_id, file_id, 0);
assert!(item.is_ok());
let item = item.unwrap();
assert_eq!(item.position(), 0);
}
#[test]
fn test_create_playlist_item_negative_position() {
let playlist_id = Uuid::new_v4();
let file_id = Uuid::new_v4();
let item = PlaylistItem::new(playlist_id, file_id, -1);
assert!(item.is_err());
}
#[test]
fn test_audio_metadata() {
let file_id = Uuid::new_v4();
let metadata = AudioFileMetadata::new(file_id, 180);
assert_eq!(metadata.duration_secs(), 180);
assert!(metadata.title().is_none());
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod calendar_repository;
pub mod contact_repository;
pub mod file_repository;
pub mod folder_repository;
pub mod playlist_repository;
pub mod session_repository;
pub mod settings_repository;
pub mod share_repository;
@@ -0,0 +1,115 @@
use crate::common::errors::DomainError;
use crate::domain::entities::playlist::{AudioFileMetadata, Playlist, PlaylistItem};
use uuid::Uuid;
pub type PlaylistRepositoryResult<T> = Result<T, DomainError>;
pub trait PlaylistRepository: Send + Sync + 'static {
async fn create_playlist(&self, playlist: Playlist) -> PlaylistRepositoryResult<Playlist>;
async fn update_playlist(&self, playlist: Playlist) -> PlaylistRepositoryResult<Playlist>;
async fn delete_playlist(&self, id: &Uuid) -> PlaylistRepositoryResult<()>;
async fn find_playlist_by_id(&self, id: &Uuid) -> PlaylistRepositoryResult<Playlist>;
async fn list_playlists_by_owner(
&self,
owner_id: Uuid,
) -> PlaylistRepositoryResult<Vec<Playlist>>;
async fn list_public_playlists(
&self,
limit: i64,
offset: i64,
) -> PlaylistRepositoryResult<Vec<Playlist>>;
async fn list_shared_with_user(&self, user_id: Uuid)
-> PlaylistRepositoryResult<Vec<Playlist>>;
async fn user_has_access(
&self,
playlist_id: &Uuid,
user_id: Uuid,
) -> PlaylistRepositoryResult<bool>;
async fn share_playlist(
&self,
playlist_id: &Uuid,
user_id: Uuid,
can_write: bool,
) -> PlaylistRepositoryResult<()>;
async fn remove_share(&self, playlist_id: &Uuid, user_id: Uuid)
-> PlaylistRepositoryResult<()>;
async fn get_shares(&self, playlist_id: &Uuid) -> PlaylistRepositoryResult<Vec<(Uuid, bool)>>;
}
pub type PlaylistItemRepositoryResult<T> = Result<T, DomainError>;
pub trait PlaylistItemRepository: Send + Sync + 'static {
async fn add_item(&self, item: PlaylistItem) -> PlaylistItemRepositoryResult<PlaylistItem>;
async fn remove_item(&self, id: &Uuid) -> PlaylistItemRepositoryResult<()>;
async fn remove_item_by_playlist_and_file(
&self,
playlist_id: &Uuid,
file_id: &Uuid,
) -> PlaylistItemRepositoryResult<()>;
async fn find_item_by_id(&self, id: &Uuid) -> PlaylistItemRepositoryResult<PlaylistItem>;
async fn list_items_in_playlist(
&self,
playlist_id: &Uuid,
) -> PlaylistItemRepositoryResult<Vec<PlaylistItem>>;
async fn update_position(
&self,
id: &Uuid,
new_position: i32,
) -> PlaylistItemRepositoryResult<()>;
async fn reorder_items(
&self,
playlist_id: &Uuid,
item_ids: &[Uuid],
) -> PlaylistItemRepositoryResult<()>;
async fn get_item_count(&self, playlist_id: &Uuid) -> PlaylistItemRepositoryResult<i64>;
async fn get_max_position(&self, playlist_id: &Uuid) -> PlaylistItemRepositoryResult<i32>;
}
pub type AudioMetadataRepositoryResult<T> = Result<T, DomainError>;
pub trait AudioMetadataRepository: Send + Sync + 'static {
async fn create_or_update(
&self,
metadata: AudioFileMetadata,
) -> AudioMetadataRepositoryResult<AudioFileMetadata>;
async fn find_by_file_id(
&self,
file_id: &Uuid,
) -> AudioMetadataRepositoryResult<Option<AudioFileMetadata>>;
async fn delete(&self, file_id: &Uuid) -> AudioMetadataRepositoryResult<()>;
async fn list_by_artist(
&self,
artist: &str,
) -> AudioMetadataRepositoryResult<Vec<AudioFileMetadata>>;
async fn list_by_album(
&self,
album: &str,
) -> AudioMetadataRepositoryResult<Vec<AudioFileMetadata>>;
async fn list_by_genre(
&self,
genre: &str,
) -> AudioMetadataRepositoryResult<Vec<AudioFileMetadata>>;
}