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