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