Une API Rest pour pmoplaylist
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
<router-link to="/debug/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
|
<router-link to="/debug/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
|
||||||
<router-link to="/debug/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
|
<router-link to="/debug/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
|
||||||
<router-link to="/debug/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link>
|
<router-link to="/debug/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link>
|
||||||
|
<router-link to="/debug/playlists" @click="showDebugMenu = false">🗂️ Playlists</router-link>
|
||||||
<router-link to="/debug/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
|
<router-link to="/debug/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
|
||||||
|
|
||||||
<div class="submenu-divider">Sources</div>
|
<div class="submenu-divider">Sources</div>
|
||||||
|
|||||||
1151
pmoapp/webapp/src/components/PlayListManager.vue
Normal file
1151
pmoapp/webapp/src/components/PlayListManager.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
|
|||||||
import LogView from "../components/LogView.vue";
|
import LogView from "../components/LogView.vue";
|
||||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||||
import AudioCacheManager from "../components/AudioCacheManager.vue";
|
import AudioCacheManager from "../components/AudioCacheManager.vue";
|
||||||
|
import PlayListManager from "../components/PlayListManager.vue";
|
||||||
import UpnpExplorer from "../components/UpnpExplorer.vue";
|
import UpnpExplorer from "../components/UpnpExplorer.vue";
|
||||||
import APIDashboard from "../components/APIDashboard.vue";
|
import APIDashboard from "../components/APIDashboard.vue";
|
||||||
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
||||||
@@ -53,6 +54,11 @@ const routes = [
|
|||||||
name: "AudioCache",
|
name: "AudioCache",
|
||||||
component: AudioCacheManager,
|
component: AudioCacheManager,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/debug/playlists",
|
||||||
|
name: "PlaylistsManager",
|
||||||
|
component: PlayListManager,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/debug/upnp",
|
path: "/debug/upnp",
|
||||||
name: "UpnpExplorer",
|
name: "UpnpExplorer",
|
||||||
|
|||||||
153
pmoapp/webapp/src/services/playlists.ts
Normal file
153
pmoapp/webapp/src/services/playlists.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
export interface PlaylistSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
role: string;
|
||||||
|
persistent: boolean;
|
||||||
|
track_count: number;
|
||||||
|
max_size?: number | null;
|
||||||
|
default_ttl_secs?: number | null;
|
||||||
|
last_change: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistTrack {
|
||||||
|
cache_pk: string;
|
||||||
|
added_at: string;
|
||||||
|
ttl_secs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistDetail {
|
||||||
|
summary: PlaylistSummary;
|
||||||
|
tracks: PlaylistTrack[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorPayload {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePlaylistPayload {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
role?: string;
|
||||||
|
persistent?: boolean;
|
||||||
|
max_size?: number;
|
||||||
|
default_ttl_secs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePlaylistPayload {
|
||||||
|
title?: string;
|
||||||
|
role?: string;
|
||||||
|
max_size?: number | null;
|
||||||
|
default_ttl_secs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddTracksPayload {
|
||||||
|
cache_pks: string[];
|
||||||
|
ttl_secs?: number;
|
||||||
|
lazy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJsonOrThrow<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `HTTP ${response.status}`;
|
||||||
|
try {
|
||||||
|
const error: ApiErrorPayload = await response.json();
|
||||||
|
if (error?.message) {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parsing errors and keep default message
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSuccess(response: Response): Promise<void> {
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `HTTP ${response.status}`;
|
||||||
|
try {
|
||||||
|
const error: ApiErrorPayload = await response.json();
|
||||||
|
if (error?.message) {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPlaylists(): Promise<PlaylistSummary[]> {
|
||||||
|
const response = await fetch("/api/playlists");
|
||||||
|
return parseJsonOrThrow(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlaylistDetail(id: string): Promise<PlaylistDetail> {
|
||||||
|
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}`);
|
||||||
|
return parseJsonOrThrow(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPlaylist(body: CreatePlaylistPayload): Promise<PlaylistDetail> {
|
||||||
|
const response = await fetch("/api/playlists", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return parseJsonOrThrow(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlaylist(
|
||||||
|
id: string,
|
||||||
|
body: UpdatePlaylistPayload
|
||||||
|
): Promise<PlaylistDetail> {
|
||||||
|
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return parseJsonOrThrow(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePlaylist(id: string): Promise<void> {
|
||||||
|
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
await ensureSuccess(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTracksToPlaylist(
|
||||||
|
id: string,
|
||||||
|
payload: AddTracksPayload
|
||||||
|
): Promise<PlaylistDetail> {
|
||||||
|
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}/tracks`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return parseJsonOrThrow(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushPlaylist(id: string): Promise<PlaylistDetail> {
|
||||||
|
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}/tracks`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
return parseJsonOrThrow(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTrackFromPlaylist(id: string, cachePk: string): Promise<PlaylistDetail> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/playlists/${encodeURIComponent(id)}/tracks/${encodeURIComponent(cachePk)}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return parseJsonOrThrow(response);
|
||||||
|
}
|
||||||
@@ -114,9 +114,21 @@ impl RadioParadisePlaylistFeeder {
|
|||||||
collection: Option<String>,
|
collection: Option<String>,
|
||||||
) -> Result<(Self, ReadHandle)> {
|
) -> Result<(Self, ReadHandle)> {
|
||||||
let manager = PlaylistManager::get();
|
let manager = PlaylistManager::get();
|
||||||
let write_handle = manager
|
let mut write_handle = manager.get_write_handle(playlist_id.clone()).await?;
|
||||||
.create_persistent_playlist_with_role(playlist_id.clone(), PlaylistRole::Radio)
|
|
||||||
.await?;
|
// Assure-toi que les playlists Live ne deviennent jamais persistantes.
|
||||||
|
if write_handle.is_persistent() {
|
||||||
|
tracing::warn!(
|
||||||
|
"RadioParadisePlaylistFeeder: playlist {} was persistent, recreating as transient",
|
||||||
|
playlist_id
|
||||||
|
);
|
||||||
|
write_handle.delete().await?;
|
||||||
|
write_handle = manager.get_write_handle(playlist_id.clone()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force the logical role to 'Radio' for better visibility/debug.
|
||||||
|
write_handle.set_role(PlaylistRole::Radio).await?;
|
||||||
|
|
||||||
let read_handle = manager.get_read_handle(&playlist_id).await?;
|
let read_handle = manager.get_read_handle(&playlist_id).await?;
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
|
|||||||
517
pmoplaylist/src/api.rs
Normal file
517
pmoplaylist/src/api.rs
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
//! API REST pour la gestion des playlists.
|
||||||
|
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::Path,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::manager::{PlaylistOverview, PlaylistSnapshot, PlaylistTrackSnapshot};
|
||||||
|
use crate::PlaylistRole;
|
||||||
|
|
||||||
|
/// Router `/api/playlists` combinant les différents endpoints REST.
|
||||||
|
pub fn playlist_api_router() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(list_playlists).post(create_playlist))
|
||||||
|
.route(
|
||||||
|
"/{playlist_id}",
|
||||||
|
get(get_playlist)
|
||||||
|
.patch(update_playlist)
|
||||||
|
.delete(delete_playlist),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{playlist_id}/tracks",
|
||||||
|
post(add_tracks).delete(flush_tracks),
|
||||||
|
)
|
||||||
|
.route("/{playlist_id}/tracks/{cache_pk}", delete(remove_track))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Résumé d'une playlist (utilisé dans les listings).
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct PlaylistSummaryResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub role: PlaylistRole,
|
||||||
|
pub persistent: bool,
|
||||||
|
pub track_count: usize,
|
||||||
|
pub max_size: Option<usize>,
|
||||||
|
pub default_ttl_secs: Option<u64>,
|
||||||
|
pub last_change: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse détaillée pour une playlist (inclut les tracks).
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct PlaylistDetailResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
#[schema(inline)]
|
||||||
|
pub summary: PlaylistSummaryResponse,
|
||||||
|
pub tracks: Vec<PlaylistTrackResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track référencé dans une playlist.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct PlaylistTrackResponse {
|
||||||
|
pub cache_pk: String,
|
||||||
|
pub added_at: DateTime<Utc>,
|
||||||
|
pub ttl_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requête pour créer une playlist persistante/éphémère.
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct CreatePlaylistRequest {
|
||||||
|
pub id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub role: Option<PlaylistRole>,
|
||||||
|
#[schema(example = true)]
|
||||||
|
pub persistent: Option<bool>,
|
||||||
|
pub max_size: Option<usize>,
|
||||||
|
pub default_ttl_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requête pour mettre à jour les métadonnées/config d'une playlist.
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdatePlaylistRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub role: Option<PlaylistRole>,
|
||||||
|
pub max_size: Option<Option<usize>>,
|
||||||
|
pub default_ttl_secs: Option<Option<u64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requête pour ajouter des morceaux dans une playlist.
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct AddTracksRequest {
|
||||||
|
pub cache_pks: Vec<String>,
|
||||||
|
#[schema(example = 3600)]
|
||||||
|
pub ttl_secs: Option<u64>,
|
||||||
|
#[schema(example = false)]
|
||||||
|
pub lazy: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse d'erreur REST générique.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/playlists",
|
||||||
|
tag = "playlists",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Liste de toutes les playlists", body = [PlaylistSummaryResponse])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn list_playlists() -> Response {
|
||||||
|
let manager = crate::manager::PlaylistManager();
|
||||||
|
match manager.all_playlist_overviews().await {
|
||||||
|
Ok(overviews) => {
|
||||||
|
let payload: Vec<PlaylistSummaryResponse> = overviews
|
||||||
|
.into_iter()
|
||||||
|
.map(PlaylistSummaryResponse::from)
|
||||||
|
.collect();
|
||||||
|
(StatusCode::OK, Json(payload)).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => map_error(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/playlists",
|
||||||
|
tag = "playlists",
|
||||||
|
request_body = CreatePlaylistRequest,
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "Playlist créée", body = PlaylistDetailResponse),
|
||||||
|
(status = 400, description = "Requête invalide", body = ErrorResponse),
|
||||||
|
(status = 409, description = "Playlist déjà existante", body = ErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn create_playlist(Json(req): Json<CreatePlaylistRequest>) -> Response {
|
||||||
|
if req.id.trim().is_empty() {
|
||||||
|
return map_status(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"INVALID_ID",
|
||||||
|
"Playlist id cannot be empty",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let manager = crate::manager::PlaylistManager();
|
||||||
|
let persistent = req.persistent.unwrap_or(true);
|
||||||
|
let requested_role = req.role.clone();
|
||||||
|
let role = requested_role.clone().unwrap_or_else(PlaylistRole::user);
|
||||||
|
|
||||||
|
let result = async move {
|
||||||
|
let id = req.id.clone();
|
||||||
|
if persistent {
|
||||||
|
let writer = manager
|
||||||
|
.create_persistent_playlist_with_role(id.clone(), role)
|
||||||
|
.await?;
|
||||||
|
apply_metadata_updates(
|
||||||
|
&writer,
|
||||||
|
req.title.clone(),
|
||||||
|
req.max_size,
|
||||||
|
req.default_ttl_secs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
let writer = manager.get_write_handle(id.clone()).await?;
|
||||||
|
if let Some(role) = requested_role {
|
||||||
|
writer.set_role(role).await?;
|
||||||
|
}
|
||||||
|
apply_metadata_updates(
|
||||||
|
&writer,
|
||||||
|
req.title.clone(),
|
||||||
|
req.max_size,
|
||||||
|
req.default_ttl_secs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.playlist_snapshot(&req.id).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(snapshot) => (
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(PlaylistDetailResponse::from(snapshot)),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(err) => map_error(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/playlists/{playlist_id}",
|
||||||
|
tag = "playlists",
|
||||||
|
params(
|
||||||
|
("playlist_id" = String, Path, description = "Identifiant de la playlist")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Playlist détaillée", body = PlaylistDetailResponse),
|
||||||
|
(status = 404, description = "Playlist introuvable", body = ErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn get_playlist(Path(playlist_id): Path<String>) -> Response {
|
||||||
|
let manager = crate::manager::PlaylistManager();
|
||||||
|
match manager.playlist_snapshot(&playlist_id).await {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
(StatusCode::OK, Json(PlaylistDetailResponse::from(snapshot))).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => map_error(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
patch,
|
||||||
|
path = "/api/playlists/{playlist_id}",
|
||||||
|
tag = "playlists",
|
||||||
|
params(
|
||||||
|
("playlist_id" = String, Path, description = "Identifiant de la playlist")
|
||||||
|
),
|
||||||
|
request_body = UpdatePlaylistRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Playlist mise à jour", body = PlaylistDetailResponse),
|
||||||
|
(status = 404, description = "Playlist introuvable", body = ErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn update_playlist(
|
||||||
|
Path(playlist_id): Path<String>,
|
||||||
|
Json(req): Json<UpdatePlaylistRequest>,
|
||||||
|
) -> Response {
|
||||||
|
let UpdatePlaylistRequest {
|
||||||
|
title,
|
||||||
|
role,
|
||||||
|
max_size,
|
||||||
|
default_ttl_secs,
|
||||||
|
} = req;
|
||||||
|
|
||||||
|
let manager = crate::manager::PlaylistManager();
|
||||||
|
let result = async move {
|
||||||
|
// S'assurer que la playlist existe
|
||||||
|
manager.get_read_handle(&playlist_id).await?;
|
||||||
|
let writer = manager.get_write_handle(playlist_id.clone()).await?;
|
||||||
|
|
||||||
|
if let Some(title) = title {
|
||||||
|
writer.set_title(title).await?;
|
||||||
|
}
|
||||||
|
if let Some(role) = role {
|
||||||
|
writer.set_role(role).await?;
|
||||||
|
}
|
||||||
|
if let Some(capacity) = max_size {
|
||||||
|
writer.set_capacity(capacity).await?;
|
||||||
|
}
|
||||||
|
if let Some(ttl) = default_ttl_secs {
|
||||||
|
writer.set_default_ttl(ttl.map(Duration::from_secs)).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.playlist_snapshot(&playlist_id).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
(StatusCode::OK, Json(PlaylistDetailResponse::from(snapshot))).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => map_error(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/playlists/{playlist_id}",
|
||||||
|
tag = "playlists",
|
||||||
|
params(
|
||||||
|
("playlist_id" = String, Path, description = "Identifiant de la playlist")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "Playlist supprimée"),
|
||||||
|
(status = 404, description = "Playlist introuvable", body = ErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn delete_playlist(Path(playlist_id): Path<String>) -> Response {
|
||||||
|
let manager = crate::manager::PlaylistManager();
|
||||||
|
match manager.delete_playlist(&playlist_id).await {
|
||||||
|
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(err) => map_error(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/playlists/{playlist_id}/tracks",
|
||||||
|
tag = "playlists",
|
||||||
|
params(
|
||||||
|
("playlist_id" = String, Path, description = "Identifiant de la playlist")
|
||||||
|
),
|
||||||
|
request_body = AddTracksRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Morceaux ajoutés", body = PlaylistDetailResponse),
|
||||||
|
(status = 400, description = "Requête invalide", body = ErrorResponse),
|
||||||
|
(status = 404, description = "Playlist introuvable", body = ErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn add_tracks(
|
||||||
|
Path(playlist_id): Path<String>,
|
||||||
|
Json(req): Json<AddTracksRequest>,
|
||||||
|
) -> Response {
|
||||||
|
if req.cache_pks.is_empty() {
|
||||||
|
return map_status(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"EMPTY_PAYLOAD",
|
||||||
|
"cache_pks cannot be empty",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let manager = crate::manager::PlaylistManager();
|
||||||
|
let result = async {
|
||||||
|
manager.get_read_handle(&playlist_id).await?;
|
||||||
|
let writer = manager.get_write_handle(playlist_id.clone()).await?;
|
||||||
|
let ttl = req.ttl_secs.map(Duration::from_secs);
|
||||||
|
let use_lazy = req.lazy.unwrap_or(false);
|
||||||
|
|
||||||
|
if use_lazy {
|
||||||
|
if req.cache_pks.len() == 1 {
|
||||||
|
writer.push_lazy(req.cache_pks[0].clone()).await?;
|
||||||
|
} else {
|
||||||
|
writer.push_lazy_batch(req.cache_pks.clone()).await?;
|
||||||
|
}
|
||||||
|
} else if let Some(ttl) = ttl {
|
||||||
|
for pk in &req.cache_pks {
|
||||||
|
writer.push_with_ttl(pk.clone(), ttl).await?;
|
||||||
|
}
|
||||||
|
} else if req.cache_pks.len() == 1 {
|
||||||
|
writer.push(req.cache_pks[0].clone()).await?;
|
||||||
|
} else {
|
||||||
|
writer.push_set(req.cache_pks.clone()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.playlist_snapshot(&playlist_id).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
(StatusCode::OK, Json(PlaylistDetailResponse::from(snapshot))).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => map_error(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/playlists/{playlist_id}/tracks",
|
||||||
|
tag = "playlists",
|
||||||
|
params(
|
||||||
|
("playlist_id" = String, Path, description = "Identifiant de la playlist")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Playlist vidée", body = PlaylistDetailResponse),
|
||||||
|
(status = 404, description = "Playlist introuvable", body = ErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn flush_tracks(Path(playlist_id): Path<String>) -> Response {
|
||||||
|
let manager = crate::manager::PlaylistManager();
|
||||||
|
let result = async {
|
||||||
|
manager.get_read_handle(&playlist_id).await?;
|
||||||
|
let writer = manager.get_write_handle(playlist_id.clone()).await?;
|
||||||
|
writer.flush().await?;
|
||||||
|
manager.playlist_snapshot(&playlist_id).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
(StatusCode::OK, Json(PlaylistDetailResponse::from(snapshot))).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => map_error(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/playlists/{playlist_id}/tracks/{cache_pk}",
|
||||||
|
tag = "playlists",
|
||||||
|
params(
|
||||||
|
("playlist_id" = String, Path, description = "Identifiant de la playlist"),
|
||||||
|
("cache_pk" = String, Path, description = "PK à retirer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Track retiré", body = PlaylistDetailResponse),
|
||||||
|
(status = 404, description = "Playlist ou track introuvable", body = ErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn remove_track(Path((playlist_id, cache_pk)): Path<(String, String)>) -> Response {
|
||||||
|
let manager = crate::manager::PlaylistManager();
|
||||||
|
let result = async {
|
||||||
|
manager.get_read_handle(&playlist_id).await?;
|
||||||
|
let writer = manager.get_write_handle(playlist_id.clone()).await?;
|
||||||
|
if !writer.remove_track(&cache_pk).await? {
|
||||||
|
return Err(crate::Error::CacheEntryNotFound(cache_pk));
|
||||||
|
}
|
||||||
|
manager.playlist_snapshot(&playlist_id).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
(StatusCode::OK, Json(PlaylistDetailResponse::from(snapshot))).into_response()
|
||||||
|
}
|
||||||
|
Err(crate::Error::CacheEntryNotFound(pk)) => map_status(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"TRACK_NOT_FOUND",
|
||||||
|
&format!("Track '{}' not found in playlist", pk),
|
||||||
|
),
|
||||||
|
Err(err) => map_error(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_metadata_updates(
|
||||||
|
writer: &crate::handle::WriteHandle,
|
||||||
|
title: Option<String>,
|
||||||
|
max_size: Option<usize>,
|
||||||
|
default_ttl_secs: Option<u64>,
|
||||||
|
) -> impl std::future::Future<Output = Result<(), crate::Error>> + '_ {
|
||||||
|
async move {
|
||||||
|
if let Some(title) = title {
|
||||||
|
writer.set_title(title).await?;
|
||||||
|
}
|
||||||
|
if let Some(max) = max_size {
|
||||||
|
writer.set_capacity(Some(max)).await?;
|
||||||
|
}
|
||||||
|
if let Some(ttl) = default_ttl_secs {
|
||||||
|
writer
|
||||||
|
.set_default_ttl(Some(Duration::from_secs(ttl)))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playlist_track_to_response(track: &PlaylistTrackSnapshot) -> PlaylistTrackResponse {
|
||||||
|
PlaylistTrackResponse {
|
||||||
|
cache_pk: track.cache_pk.clone(),
|
||||||
|
added_at: system_time_to_datetime(track.added_at),
|
||||||
|
ttl_secs: track.ttl.map(|ttl| ttl.as_secs()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PlaylistOverview> for PlaylistSummaryResponse {
|
||||||
|
fn from(value: PlaylistOverview) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
title: value.title,
|
||||||
|
role: value.role,
|
||||||
|
persistent: value.persistent,
|
||||||
|
track_count: value.track_count,
|
||||||
|
max_size: value.max_size,
|
||||||
|
default_ttl_secs: value.default_ttl.map(|ttl| ttl.as_secs()),
|
||||||
|
last_change: system_time_to_datetime(value.last_change),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PlaylistSnapshot> for PlaylistDetailResponse {
|
||||||
|
fn from(value: PlaylistSnapshot) -> Self {
|
||||||
|
let summary = PlaylistSummaryResponse::from(value.overview);
|
||||||
|
let tracks = value
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.map(playlist_track_to_response)
|
||||||
|
.collect();
|
||||||
|
Self { summary, tracks }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_time_to_datetime(time: SystemTime) -> DateTime<Utc> {
|
||||||
|
DateTime::<Utc>::from(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_status<S: Into<String>>(status: StatusCode, error: &str, message: S) -> Response {
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: error.to_string(),
|
||||||
|
message: message.into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_error(error: crate::Error) -> Response {
|
||||||
|
let status = match error {
|
||||||
|
crate::Error::PlaylistNotFound(_) | crate::Error::PlaylistDeleted(_) => {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
|
crate::Error::PlaylistAlreadyExists(_) | crate::Error::WriteLockHeld(_) => {
|
||||||
|
StatusCode::CONFLICT
|
||||||
|
}
|
||||||
|
crate::Error::CacheEntryNotFound(_) => StatusCode::BAD_REQUEST,
|
||||||
|
crate::Error::PlaylistNotPersistent(_) => StatusCode::BAD_REQUEST,
|
||||||
|
crate::Error::CacheError(_)
|
||||||
|
| crate::Error::PersistenceError(_)
|
||||||
|
| crate::Error::ManagerNotInitialized
|
||||||
|
| crate::Error::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("{:?}", error),
|
||||||
|
message: error.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
@@ -158,6 +158,85 @@ impl WriteHandle {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Supprime un morceau par sa cache_pk. Retourne true si un élément a été retiré.
|
||||||
|
pub async fn remove_track(&self, cache_pk: &str) -> Result<bool> {
|
||||||
|
if !self.playlist.is_alive() {
|
||||||
|
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut core = self.playlist.core.write().await;
|
||||||
|
let removed = core.remove_by_cache_pk(cache_pk);
|
||||||
|
let snapshot = core.snapshot();
|
||||||
|
drop(core);
|
||||||
|
|
||||||
|
if removed {
|
||||||
|
self.playlist.touch().await;
|
||||||
|
if self.playlist.persistent {
|
||||||
|
self.save_to_db().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manager = crate::manager::PlaylistManager();
|
||||||
|
manager
|
||||||
|
.rebuild_track_index(&self.playlist.id, &snapshot)
|
||||||
|
.await;
|
||||||
|
manager.notify_playlist_changed(&self.playlist.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour la capacité maximale.
|
||||||
|
pub async fn set_capacity(&self, max_size: Option<usize>) -> Result<()> {
|
||||||
|
if !self.playlist.is_alive() {
|
||||||
|
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut core = self.playlist.core.write().await;
|
||||||
|
core.set_capacity(max_size);
|
||||||
|
let snapshot = core.snapshot();
|
||||||
|
drop(core);
|
||||||
|
|
||||||
|
self.playlist.touch().await;
|
||||||
|
|
||||||
|
if self.playlist.persistent {
|
||||||
|
self.save_to_db().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manager = crate::manager::PlaylistManager();
|
||||||
|
manager
|
||||||
|
.rebuild_track_index(&self.playlist.id, &snapshot)
|
||||||
|
.await;
|
||||||
|
manager.notify_playlist_changed(&self.playlist.id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour le TTL par défaut.
|
||||||
|
pub async fn set_default_ttl(&self, ttl: Option<Duration>) -> Result<()> {
|
||||||
|
if !self.playlist.is_alive() {
|
||||||
|
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut core = self.playlist.core.write().await;
|
||||||
|
core.set_default_ttl(ttl);
|
||||||
|
let snapshot = core.snapshot();
|
||||||
|
drop(core);
|
||||||
|
|
||||||
|
self.playlist.touch().await;
|
||||||
|
|
||||||
|
if self.playlist.persistent {
|
||||||
|
self.save_to_db().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manager = crate::manager::PlaylistManager();
|
||||||
|
manager
|
||||||
|
.rebuild_track_index(&self.playlist.id, &snapshot)
|
||||||
|
.await;
|
||||||
|
manager.notify_playlist_changed(&self.playlist.id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Supprime la playlist définitivement
|
/// Supprime la playlist définitivement
|
||||||
pub async fn delete(self) -> Result<()> {
|
pub async fn delete(self) -> Result<()> {
|
||||||
if !self.playlist.is_alive() {
|
if !self.playlist.is_alive() {
|
||||||
@@ -213,32 +292,6 @@ impl WriteHandle {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Change la capacité maximale
|
|
||||||
pub async fn set_capacity(&self, max_size: Option<usize>) -> Result<()> {
|
|
||||||
if !self.playlist.is_alive() {
|
|
||||||
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut core = self.playlist.core.write().await;
|
|
||||||
core.set_capacity(max_size);
|
|
||||||
let snapshot = core.snapshot();
|
|
||||||
drop(core);
|
|
||||||
|
|
||||||
self.playlist.touch().await;
|
|
||||||
|
|
||||||
if self.playlist.persistent {
|
|
||||||
self.save_to_db().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let manager = crate::manager::PlaylistManager();
|
|
||||||
manager
|
|
||||||
.rebuild_track_index(&self.playlist.id, &snapshot)
|
|
||||||
.await;
|
|
||||||
manager.notify_playlist_changed(&self.playlist.id);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifie si la playlist contient déjà un pk
|
/// Vérifie si la playlist contient déjà un pk
|
||||||
pub async fn contains_pk(&self, cache_pk: &str) -> Result<bool> {
|
pub async fn contains_pk(&self, cache_pk: &str) -> Result<bool> {
|
||||||
if !self.playlist.is_alive() {
|
if !self.playlist.is_alive() {
|
||||||
@@ -249,32 +302,6 @@ impl WriteHandle {
|
|||||||
Ok(core.tracks.iter().any(|record| record.cache_pk == cache_pk))
|
Ok(core.tracks.iter().any(|record| record.cache_pk == cache_pk))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Change le TTL par défaut
|
|
||||||
pub async fn set_default_ttl(&self, ttl: Option<Duration>) -> Result<()> {
|
|
||||||
if !self.playlist.is_alive() {
|
|
||||||
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut core = self.playlist.core.write().await;
|
|
||||||
core.set_default_ttl(ttl);
|
|
||||||
let snapshot = core.snapshot();
|
|
||||||
drop(core);
|
|
||||||
|
|
||||||
self.playlist.touch().await;
|
|
||||||
|
|
||||||
if self.playlist.persistent {
|
|
||||||
self.save_to_db().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let manager = crate::manager::PlaylistManager();
|
|
||||||
manager
|
|
||||||
.rebuild_track_index(&self.playlist.id, &snapshot)
|
|
||||||
.await;
|
|
||||||
manager.notify_playlist_changed(&self.playlist.id);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clone vers une nouvelle playlist persistante
|
/// Clone vers une nouvelle playlist persistante
|
||||||
pub async fn clone_as_persistent(&self, new_id: String) -> Result<WriteHandle> {
|
pub async fn clone_as_persistent(&self, new_id: String) -> Result<WriteHandle> {
|
||||||
if !self.playlist.is_alive() {
|
if !self.playlist.is_alive() {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
//! # }
|
//! # }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub mod api;
|
||||||
mod error;
|
mod error;
|
||||||
mod handle;
|
mod handle;
|
||||||
mod manager;
|
mod manager;
|
||||||
@@ -59,6 +61,8 @@ mod track;
|
|||||||
mod config_ext;
|
mod config_ext;
|
||||||
|
|
||||||
// Réexports publics
|
// Réexports publics
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub use api::playlist_api_router;
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
pub use handle::{ReadHandle, WriteHandle};
|
pub use handle::{ReadHandle, WriteHandle};
|
||||||
pub use manager::{register_audio_cache, PlaylistManager, PlaylistManager as Manager};
|
pub use manager::{register_audio_cache, PlaylistManager, PlaylistManager as Manager};
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ use crate::playlist::{Playlist, PlaylistRole};
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use pmocache::{CacheBroadcastEvent, CacheEvent, CacheSubscription};
|
use pmocache::{CacheBroadcastEvent, CacheEvent, CacheSubscription};
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::RwLock as StdRwLock;
|
use std::sync::RwLock as StdRwLock;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, SystemTime};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
@@ -60,6 +60,34 @@ pub struct PlaylistEventEnvelope {
|
|||||||
pub source_client: Option<String>,
|
pub source_client: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Métadonnées de synthèse d'une playlist.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlaylistOverview {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub role: PlaylistRole,
|
||||||
|
pub persistent: bool,
|
||||||
|
pub track_count: usize,
|
||||||
|
pub max_size: Option<usize>,
|
||||||
|
pub default_ttl: Option<Duration>,
|
||||||
|
pub last_change: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Informations détaillées sur un track référencé par une playlist.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlaylistTrackSnapshot {
|
||||||
|
pub cache_pk: String,
|
||||||
|
pub added_at: SystemTime,
|
||||||
|
pub ttl: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot complet d'une playlist (métadonnées + tracks).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlaylistSnapshot {
|
||||||
|
pub overview: PlaylistOverview,
|
||||||
|
pub tracks: Vec<PlaylistTrackSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Gestionnaire central de playlists
|
/// Gestionnaire central de playlists
|
||||||
pub struct PlaylistManager {
|
pub struct PlaylistManager {
|
||||||
inner: Arc<ManagerInner>,
|
inner: Arc<ManagerInner>,
|
||||||
@@ -554,6 +582,104 @@ impl PlaylistManager {
|
|||||||
self.inner.playlists.read().await.contains_key(id)
|
self.inner.playlists.read().await.contains_key(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retourne les métadonnées complètes d'une playlist (charge depuis la DB si nécessaire).
|
||||||
|
pub async fn playlist_overview(&self, id: &str) -> Result<PlaylistOverview> {
|
||||||
|
let playlist = self.ensure_playlist_loaded(id).await?;
|
||||||
|
let title = playlist.title().await;
|
||||||
|
let role = playlist.role().await;
|
||||||
|
let persistent = playlist.persistent;
|
||||||
|
let last_change = playlist.last_change().await;
|
||||||
|
let core = playlist.core.read().await;
|
||||||
|
let track_count = core.len();
|
||||||
|
let config = core.config.clone();
|
||||||
|
|
||||||
|
Ok(PlaylistOverview {
|
||||||
|
id: playlist.id.clone(),
|
||||||
|
title,
|
||||||
|
role,
|
||||||
|
persistent,
|
||||||
|
track_count,
|
||||||
|
max_size: config.max_size,
|
||||||
|
default_ttl: config.default_ttl,
|
||||||
|
last_change,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne un snapshot complet (tracks inclus).
|
||||||
|
pub async fn playlist_snapshot(&self, id: &str) -> Result<PlaylistSnapshot> {
|
||||||
|
let overview = self.playlist_overview(id).await?;
|
||||||
|
let playlist = self.ensure_playlist_loaded(id).await?;
|
||||||
|
let core = playlist.core.read().await;
|
||||||
|
let snapshot = core.snapshot();
|
||||||
|
drop(core);
|
||||||
|
|
||||||
|
let tracks = snapshot
|
||||||
|
.into_iter()
|
||||||
|
.map(|record| PlaylistTrackSnapshot {
|
||||||
|
cache_pk: record.cache_pk.clone(),
|
||||||
|
added_at: record.added_at,
|
||||||
|
ttl: record.ttl,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(PlaylistSnapshot { overview, tracks })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne les métadonnées de toutes les playlists connues (en mémoire + persistantes).
|
||||||
|
pub async fn all_playlist_overviews(&self) -> Result<Vec<PlaylistOverview>> {
|
||||||
|
let ids = self.collect_all_playlist_ids().await?;
|
||||||
|
let mut overviews = Vec::with_capacity(ids.len());
|
||||||
|
|
||||||
|
for id in ids {
|
||||||
|
match self.playlist_overview(&id).await {
|
||||||
|
Ok(info) => overviews.push(info),
|
||||||
|
Err(crate::Error::PlaylistNotFound(_)) | Err(crate::Error::PlaylistDeleted(_)) => {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overviews.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
Ok(overviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_all_playlist_ids(&self) -> Result<Vec<String>> {
|
||||||
|
let mut ids: HashSet<String> = {
|
||||||
|
let playlists = self.inner.playlists.read().await;
|
||||||
|
playlists.keys().cloned().collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(persistence) = &self.inner.persistence {
|
||||||
|
for id in persistence.list_playlist_ids().await? {
|
||||||
|
ids.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ids.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_playlist_loaded(&self, id: &str) -> Result<Arc<Playlist>> {
|
||||||
|
{
|
||||||
|
let playlists = self.inner.playlists.read().await;
|
||||||
|
if let Some(playlist) = playlists.get(id) {
|
||||||
|
if !playlist.is_alive() {
|
||||||
|
return Err(crate::Error::PlaylistDeleted(id.to_string()));
|
||||||
|
}
|
||||||
|
return Ok(playlist.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger la playlist depuis la persistance si possible
|
||||||
|
self.get_read_handle(id).await?;
|
||||||
|
|
||||||
|
let playlists = self.inner.playlists.read().await;
|
||||||
|
playlists
|
||||||
|
.get(id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| crate::Error::PlaylistNotFound(id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Retourne la r<>f<EFBFBD>rence au PersistenceManager
|
/// Retourne la r<>f<EFBFBD>rence au PersistenceManager
|
||||||
pub(crate) fn persistence(&self) -> Option<&Arc<PersistenceManager>> {
|
pub(crate) fn persistence(&self) -> Option<&Arc<PersistenceManager>> {
|
||||||
self.inner.persistence.as_ref()
|
self.inner.persistence.as_ref()
|
||||||
|
|||||||
@@ -7,10 +7,25 @@ use utoipa::OpenApi;
|
|||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
|
crate::api::list_playlists,
|
||||||
|
crate::api::create_playlist,
|
||||||
|
crate::api::get_playlist,
|
||||||
|
crate::api::update_playlist,
|
||||||
|
crate::api::delete_playlist,
|
||||||
|
crate::api::add_tracks,
|
||||||
|
crate::api::flush_tracks,
|
||||||
|
crate::api::remove_track,
|
||||||
crate::sse::playlist_events_sse,
|
crate::sse::playlist_events_sse,
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
|
crate::api::PlaylistSummaryResponse,
|
||||||
|
crate::api::PlaylistDetailResponse,
|
||||||
|
crate::api::PlaylistTrackResponse,
|
||||||
|
crate::api::CreatePlaylistRequest,
|
||||||
|
crate::api::UpdatePlaylistRequest,
|
||||||
|
crate::api::AddTracksRequest,
|
||||||
|
crate::api::ErrorResponse,
|
||||||
crate::sse::EventPayload,
|
crate::sse::EventPayload,
|
||||||
crate::sse::EventsQuery,
|
crate::sse::EventsQuery,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use tokio_stream::StreamExt;
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[cfg_attr(feature = "pmoserver", derive(utoipa::IntoParams, utoipa::ToSchema))]
|
#[cfg_attr(feature = "pmoserver", derive(utoipa::IntoParams, utoipa::ToSchema))]
|
||||||
pub struct EventsQuery {
|
pub struct EventsQuery {
|
||||||
|
|||||||
16
pmoupnp/.pmomusic/config.yaml
Normal file
16
pmoupnp/.pmomusic/config.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
host:
|
||||||
|
http_port: '8080'
|
||||||
|
cover_cache:
|
||||||
|
directory: cache_covers
|
||||||
|
size: 2000
|
||||||
|
audio_cache:
|
||||||
|
directory: cache_audio
|
||||||
|
size: 500
|
||||||
|
logger:
|
||||||
|
buffer_capacity: 200
|
||||||
|
enable_console: true
|
||||||
|
min_level: INFO
|
||||||
|
devices:
|
||||||
|
mediarenderer:
|
||||||
|
testdevice:
|
||||||
|
udn: 32779579-36ed-4001-a7c4-b19fccbe2009
|
||||||
@@ -25,15 +25,15 @@ use std::sync::RwLock;
|
|||||||
use pmoserver::Server;
|
use pmoserver::Server;
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
use crate::UpnpModel;
|
|
||||||
use crate::devices::errors::DeviceError;
|
use crate::devices::errors::DeviceError;
|
||||||
use crate::devices::{Device, DeviceInstance, DeviceRegistry};
|
use crate::devices::{Device, DeviceInstance, DeviceRegistry};
|
||||||
use crate::ssdp::SsdpServer;
|
use crate::ssdp::SsdpServer;
|
||||||
use crate::upnp_api::UpnpApiExt;
|
use crate::upnp_api::UpnpApiExt;
|
||||||
|
use crate::UpnpModel;
|
||||||
|
|
||||||
use pmoaudiocache::Cache as AudioCache;
|
use pmoaudiocache::Cache as AudioCache;
|
||||||
use pmocovers::Cache as CoverCache;
|
use pmocovers::Cache as CoverCache;
|
||||||
use pmoutils::{TransportProtocol, find_process_using_port};
|
use pmoutils::{find_process_using_port, TransportProtocol};
|
||||||
|
|
||||||
/// Registre de devices global et thread-safe.
|
/// Registre de devices global et thread-safe.
|
||||||
///
|
///
|
||||||
@@ -318,10 +318,10 @@ impl UpnpServerExt for Server {
|
|||||||
// API playlists (SSE + OpenAPI)
|
// API playlists (SSE + OpenAPI)
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
use pmoplaylist::{openapi::ApiDoc, playlist_events_router};
|
use pmoplaylist::{openapi::ApiDoc, playlist_api_router, playlist_events_router};
|
||||||
// SSE /api/playlists/events
|
// API REST + SSE sous /api/playlists
|
||||||
self.add_router("/api/playlists", playlist_events_router())
|
let router = playlist_api_router().merge(playlist_events_router());
|
||||||
.await;
|
self.add_router("/api/playlists", router).await;
|
||||||
// OpenAPI pour playlists
|
// OpenAPI pour playlists
|
||||||
let openapi = ApiDoc::openapi();
|
let openapi = ApiDoc::openapi();
|
||||||
self.add_openapi(axum::Router::new(), openapi, "playlists")
|
self.add_openapi(axum::Router::new(), openapi, "playlists")
|
||||||
|
|||||||
Reference in New Issue
Block a user