Amélioration de la vue playlist

This commit is contained in:
2025-12-17 15:25:02 +01:00
parent 0264e0c2e1
commit 3f9798be9b
10 changed files with 895 additions and 250 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ export interface AudioCacheMetadata {
export interface AudioCacheEntry {
pk: string;
lazy_pk?: string | null;
id: string | null;
hits: number;
last_used: string | null;

View File

@@ -3,16 +3,24 @@ export interface PlaylistSummary {
title: string;
role: string;
persistent: boolean;
cover_pk?: string | null;
cover_url?: string | null;
track_count: number;
max_size?: number | null;
default_ttl_secs?: number | null;
last_change: string;
}
import type { AudioCacheMetadata } from "./audioCache";
export interface PlaylistTrack {
cache_pk: string;
added_at: string;
ttl_secs?: number | null;
lazy_pk?: string | null;
metadata?: AudioCacheMetadata | null;
cover_url?: string | null;
cover_source?: string | null;
}
export interface PlaylistDetail {
@@ -29,6 +37,7 @@ export interface CreatePlaylistPayload {
id: string;
title?: string;
role?: string;
cover_pk?: string;
persistent?: boolean;
max_size?: number;
default_ttl_secs?: number;
@@ -39,6 +48,7 @@ export interface UpdatePlaylistPayload {
role?: string;
max_size?: number | null;
default_ttl_secs?: number | null;
cover_pk?: string | null;
}
export interface AddTracksPayload {

View File

@@ -24,6 +24,9 @@ pub struct CacheEntry {
/// Clé primaire unique de l'élément (hash SHA1 de l'URL)
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
pub pk: String,
/// Lazy PK historique associé (si l'élément provient d'un téléchargement différé)
#[cfg_attr(feature = "openapi", schema(example = "L:QOBUZ:123456"))]
pub lazy_pk: Option<String>,
/// URL source de l'élément
#[cfg_attr(feature = "openapi", schema(example = "https://example.com/resource"))]
pub id: Option<String>,
@@ -486,17 +489,18 @@ impl DB {
let mut entry = {
let conn = self.lock_conn("get");
conn.query_row(
"SELECT pk, id, collection, hits, last_used \
"SELECT pk, lazy_pk, id, collection, hits, last_used \
FROM asset \
WHERE pk = ?1",
[pk],
|row| {
Ok(CacheEntry {
pk: row.get(0)?,
id: row.get::<_, Option<String>>(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
lazy_pk: row.get::<_, Option<String>>(1)?,
id: row.get::<_, Option<String>>(2)?,
collection: row.get(3)?,
hits: row.get(4)?,
last_used: row.get(5)?,
metadata: None,
})
},
@@ -526,17 +530,18 @@ impl DB {
let mut entry = {
let conn = self.lock_conn("get_from_id");
conn.query_row(
"SELECT pk, id, collection, hits, last_used \
"SELECT pk, lazy_pk, id, collection, hits, last_used \
FROM asset \
WHERE collection = ?1 AND id = ?2",
params![collection, id],
|row| {
Ok(CacheEntry {
pk: row.get(0)?,
id: row.get::<_, Option<String>>(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
lazy_pk: row.get::<_, Option<String>>(1)?,
id: row.get::<_, Option<String>>(2)?,
collection: row.get(3)?,
hits: row.get(4)?,
last_used: row.get(5)?,
metadata: None,
})
},
@@ -627,7 +632,7 @@ impl DB {
let conn = self.lock_conn("get_all");
let mut stmt = conn.prepare(
"SELECT pk, id, collection, hits, last_used
"SELECT pk, lazy_pk, id, collection, hits, last_used
FROM asset
ORDER BY hits DESC",
)?;
@@ -635,10 +640,11 @@ impl DB {
let rows = stmt.query_map([], |row| {
Ok(CacheEntry {
pk: row.get(0)?,
id: row.get::<_, Option<String>>(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
lazy_pk: row.get::<_, Option<String>>(1)?,
id: row.get::<_, Option<String>>(2)?,
collection: row.get(3)?,
hits: row.get(4)?,
last_used: row.get(5)?,
metadata: None,
})
})?;
@@ -670,17 +676,18 @@ impl DB {
let conn = self.lock_conn("get_by_collection");
let mut stmt = conn.prepare(
"SELECT pk, id, collection, hits, last_used
"SELECT pk, lazy_pk, id, collection, hits, last_used
FROM asset
WHERE collection = ?1 ORDER BY hits DESC",
)?;
let rows = stmt.query_map([collection], |row| {
Ok(CacheEntry {
pk: row.get(0)?,
id: row.get::<_, Option<String>>(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
lazy_pk: row.get::<_, Option<String>>(1)?,
id: row.get::<_, Option<String>>(2)?,
collection: row.get(3)?,
hits: row.get(4)?,
last_used: row.get(5)?,
metadata: None,
})
})?;
@@ -741,7 +748,7 @@ impl DB {
let conn = self.lock_conn("get_oldest");
let mut stmt = conn.prepare(
"SELECT pk, id, collection, hits, last_used
"SELECT pk, lazy_pk, id, collection, hits, last_used
FROM asset
ORDER BY last_used ASC, hits ASC
LIMIT ?1",
@@ -751,10 +758,11 @@ impl DB {
.query_map([limit], |row| {
Ok(CacheEntry {
pk: row.get(0)?,
id: row.get::<_, Option<String>>(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
lazy_pk: row.get::<_, Option<String>>(1)?,
id: row.get::<_, Option<String>>(2)?,
collection: row.get(3)?,
hits: row.get(4)?,
last_used: row.get(5)?,
metadata: None,
})
})?

View File

@@ -1,5 +1,6 @@
//! API REST pour la gestion des playlists.
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use axum::{
@@ -11,6 +12,7 @@ use axum::{
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use utoipa::ToSchema;
use crate::manager::{PlaylistOverview, PlaylistSnapshot, PlaylistTrackSnapshot};
@@ -41,6 +43,10 @@ pub struct PlaylistSummaryResponse {
#[schema(value_type = String)]
pub role: PlaylistRole,
pub persistent: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_pk: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_url: Option<String>,
pub track_count: usize,
pub max_size: Option<usize>,
pub default_ttl_secs: Option<u64>,
@@ -62,6 +68,14 @@ pub struct PlaylistTrackResponse {
pub cache_pk: String,
pub added_at: DateTime<Utc>,
pub ttl_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lazy_pk: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_source: Option<String>,
}
/// Requête pour créer une playlist persistante/éphémère.
@@ -71,6 +85,8 @@ pub struct CreatePlaylistRequest {
pub title: Option<String>,
#[schema(value_type = String)]
pub role: Option<PlaylistRole>,
#[schema(example = "abc123")]
pub cover_pk: Option<String>,
#[schema(example = true)]
pub persistent: Option<bool>,
pub max_size: Option<usize>,
@@ -85,6 +101,8 @@ pub struct UpdatePlaylistRequest {
pub role: Option<PlaylistRole>,
pub max_size: Option<Option<usize>>,
pub default_ttl_secs: Option<Option<u64>>,
/// Utiliser `null` explicite pour supprimer la cover, ou omettre pour ne pas modifier.
pub cover_pk: Option<Option<String>>,
}
/// Requête pour ajouter des morceaux dans une playlist.
@@ -153,6 +171,7 @@ pub async fn create_playlist(Json(req): Json<CreatePlaylistRequest>) -> Response
let result = async move {
let id = req.id.clone();
let normalized_cover_pk = normalize_cover_pk(req.cover_pk.clone());
if persistent {
let writer = manager
.create_persistent_playlist_with_role(id.clone(), role)
@@ -164,6 +183,9 @@ pub async fn create_playlist(Json(req): Json<CreatePlaylistRequest>) -> Response
req.default_ttl_secs,
)
.await?;
if let Some(cover_pk) = normalized_cover_pk.clone() {
writer.set_cover_pk(Some(cover_pk)).await?;
}
} else {
let writer = manager.get_write_handle(id.clone()).await?;
if let Some(role) = requested_role {
@@ -176,6 +198,9 @@ pub async fn create_playlist(Json(req): Json<CreatePlaylistRequest>) -> Response
req.default_ttl_secs,
)
.await?;
if let Some(cover_pk) = normalized_cover_pk {
writer.set_cover_pk(Some(cover_pk)).await?;
}
}
manager.playlist_snapshot(&req.id).await
@@ -236,6 +261,7 @@ pub async fn update_playlist(
role,
max_size,
default_ttl_secs,
cover_pk,
} = req;
let manager = crate::manager::PlaylistManager();
@@ -256,6 +282,13 @@ pub async fn update_playlist(
if let Some(ttl) = default_ttl_secs {
writer.set_default_ttl(ttl.map(Duration::from_secs)).await?;
}
if let Some(cover_pk) = cover_pk {
let normalized = match cover_pk {
Some(value) => normalize_cover_pk(Some(value)),
None => None,
};
writer.set_cover_pk(normalized).await?;
}
manager.playlist_snapshot(&playlist_id).await
}
@@ -440,21 +473,78 @@ fn apply_metadata_updates(
}
}
fn playlist_track_to_response(track: &PlaylistTrackSnapshot) -> PlaylistTrackResponse {
PlaylistTrackResponse {
fn playlist_track_to_response(
track: &PlaylistTrackSnapshot,
audio_cache: Option<&Arc<pmoaudiocache::Cache>>,
) -> PlaylistTrackResponse {
let mut response = 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()),
lazy_pk: None,
metadata: None,
cover_url: None,
cover_source: None,
};
if let Some(cache) = audio_cache {
if let Ok(mut entry) = cache.db.get(&track.cache_pk, true) {
if let Some(lazy_pk) = entry.lazy_pk.take() {
if lazy_pk != response.cache_pk {
response.lazy_pk = Some(lazy_pk);
}
}
if let Some(metadata) = entry.metadata {
if let Some((url, source)) = resolve_cover_from_metadata(&metadata) {
response.cover_url = Some(url);
response.cover_source = Some(source);
}
response.metadata = Some(metadata);
}
}
}
response
}
fn cover_url_from_pk(pk: &str) -> String {
format!("/covers/image/{}/256", pk)
}
fn normalize_cover_pk(input: Option<String>) -> Option<String> {
input.and_then(|pk| {
let trimmed = pk.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn resolve_cover_from_metadata(metadata: &Value) -> Option<(String, String)> {
if let Some(cover_pk) = metadata.get("cover_pk").and_then(Value::as_str) {
return Some((cover_url_from_pk(cover_pk), "cover_pk".to_string()));
}
if let Some(url) = metadata.get("cover_url").and_then(Value::as_str) {
return Some((url.to_string(), "cover_url".to_string()));
}
None
}
impl From<PlaylistOverview> for PlaylistSummaryResponse {
fn from(value: PlaylistOverview) -> Self {
let cover_pk = value.cover_pk.clone();
Self {
id: value.id,
title: value.title,
role: value.role,
persistent: value.persistent,
cover_pk: cover_pk.clone(),
cover_url: cover_pk.as_deref().map(cover_url_from_pk),
track_count: value.track_count,
max_size: value.max_size,
default_ttl_secs: value.default_ttl.map(|ttl| ttl.as_secs()),
@@ -466,10 +556,11 @@ impl From<PlaylistOverview> for PlaylistSummaryResponse {
impl From<PlaylistSnapshot> for PlaylistDetailResponse {
fn from(value: PlaylistSnapshot) -> Self {
let summary = PlaylistSummaryResponse::from(value.overview);
let audio_cache = crate::manager::audio_cache().ok();
let tracks = value
.tracks
.iter()
.map(playlist_track_to_response)
.map(|track| playlist_track_to_response(track, audio_cache.as_ref()))
.collect();
Self { summary, tracks }
}

View File

@@ -68,12 +68,14 @@ impl ReadHandle {
if let Some(persistence) = crate::manager::PlaylistManager().persistence() {
let title = self.playlist.title().await;
let role = self.playlist.role().await;
let cover_pk = self.playlist.cover_pk().await;
let core = self.playlist.core.read().await;
let _ = persistence
.save_playlist(
&self.playlist.id,
&title,
&role,
cover_pk.as_deref(),
&core.config,
&core.tracks,
)

View File

@@ -292,6 +292,23 @@ impl WriteHandle {
Ok(())
}
/// Met à jour la cover (cover_pk) associée à la playlist.
pub async fn set_cover_pk(&self, cover_pk: Option<String>) -> Result<()> {
if !self.playlist.is_alive() {
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
}
self.playlist.set_cover_pk(cover_pk).await;
if self.playlist.persistent {
self.save_to_db().await?;
}
crate::manager::PlaylistManager().notify_playlist_changed(&self.playlist.id);
Ok(())
}
/// Vérifie si la playlist contient déjà un pk
pub async fn contains_pk(&self, cache_pk: &str) -> Result<bool> {
if !self.playlist.is_alive() {
@@ -530,8 +547,17 @@ impl WriteHandle {
let config = &core.config;
let tracks = &core.tracks;
let cover_pk = self.playlist.cover_pk().await;
persistence
.save_playlist(&self.playlist.id, &title, &role, config, tracks)
.save_playlist(
&self.playlist.id,
&title,
&role,
cover_pk.as_deref(),
config,
tracks,
)
.await
}
}

View File

@@ -67,6 +67,7 @@ pub struct PlaylistOverview {
pub title: String,
pub role: PlaylistRole,
pub persistent: bool,
pub cover_pk: Option<String>,
pub track_count: usize,
pub max_size: Option<usize>,
pub default_ttl: Option<Duration>,
@@ -184,6 +185,7 @@ impl PlaylistManager {
PlaylistConfig::default(),
true, // persistent
role,
None,
));
// Acqu<71>rir le write lock
@@ -199,9 +201,17 @@ impl PlaylistManager {
if let Some(persistence) = &self.inner.persistence {
let title = playlist.title().await;
let role = playlist.role().await;
let cover_pk = playlist.cover_pk().await;
let core = playlist.core.read().await;
persistence
.save_playlist(&playlist.id, &title, &role, &core.config, &core.tracks)
.save_playlist(
&playlist.id,
&title,
&role,
cover_pk.as_deref(),
&core.config,
&core.tracks,
)
.await?;
}
@@ -436,6 +446,7 @@ impl PlaylistManager {
PlaylistConfig::default(),
false, // éphémère
PlaylistRole::User,
None,
));
let write_token = playlist
@@ -471,12 +482,20 @@ impl PlaylistManager {
// Pas en mémoire, essayer de charger depuis la DB
if let Some(persistence) = &self.inner.persistence {
if let Some((title, role, config, tracks)) = persistence.load_playlist(&id).await? {
if let Some((title, role, config, cover_pk, tracks)) =
persistence.load_playlist(&id).await?
{
// Reconstruire la playlist
let mut playlists = self.inner.playlists.write().await;
let playlist =
Arc::new(Playlist::new(id.clone(), title.clone(), config, true, role));
let playlist = Arc::new(Playlist::new(
id.clone(),
title.clone(),
config,
true,
role,
cover_pk,
));
// Restaurer les tracks
{
@@ -519,7 +538,9 @@ impl PlaylistManager {
// Pas en m<>moire, essayer de ressusciter depuis la DB
if let Some(persistence) = &self.inner.persistence {
if let Some((title, role, config, tracks)) = persistence.load_playlist(id).await? {
if let Some((title, role, config, cover_pk, tracks)) =
persistence.load_playlist(id).await?
{
// Reconstruire la playlist
let mut playlists = self.inner.playlists.write().await;
@@ -529,6 +550,7 @@ impl PlaylistManager {
config,
true,
role,
cover_pk,
));
// Restaurer les tracks
@@ -587,6 +609,7 @@ impl PlaylistManager {
let playlist = self.ensure_playlist_loaded(id).await?;
let title = playlist.title().await;
let role = playlist.role().await;
let cover_pk = playlist.cover_pk().await;
let persistent = playlist.persistent;
let last_change = playlist.last_change().await;
let core = playlist.core.read().await;
@@ -598,6 +621,7 @@ impl PlaylistManager {
title,
role,
persistent,
cover_pk,
track_count,
max_size: config.max_size,
default_ttl: config.default_ttl,
@@ -916,9 +940,17 @@ impl PlaylistManager {
if let Some(persistence) = &self.inner.persistence {
let title = playlist.title().await;
let role = playlist.role().await;
let cover_pk = playlist.cover_pk().await;
let core = playlist.core.read().await;
let _ = persistence
.save_playlist(&playlist.id, &title, &role, &core.config, &core.tracks)
.save_playlist(
&playlist.id,
&title,
&role,
cover_pk.as_deref(),
&core.config,
&core.tracks,
)
.await;
}
}

View File

@@ -36,6 +36,7 @@ impl PersistenceManager {
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
role TEXT NOT NULL,
cover_pk TEXT,
max_size INTEGER,
default_ttl_secs INTEGER,
created_at INTEGER NOT NULL,
@@ -84,6 +85,7 @@ impl PersistenceManager {
id: &str,
title: &str,
role: &PlaylistRole,
cover_pk: Option<&str>,
config: &PlaylistConfig,
tracks: &VecDeque<Arc<Record>>,
) -> Result<()> {
@@ -96,19 +98,21 @@ impl PersistenceManager {
// Upsert playlist metadata
conn.execute(
"INSERT OR REPLACE INTO playlists (id, title, role, max_size, default_ttl_secs, created_at, last_modified)
VALUES (?1, ?2, ?3, ?4, ?5,
COALESCE((SELECT created_at FROM playlists WHERE id = ?1), ?6),
?6)",
"INSERT OR REPLACE INTO playlists (id, title, role, cover_pk, max_size, default_ttl_secs, created_at, last_modified)
VALUES (?1, ?2, ?3, ?4, ?5, ?6,
COALESCE((SELECT created_at FROM playlists WHERE id = ?1), ?7),
?7)",
params![
id,
title,
role.as_str(),
cover_pk,
config.max_size.map(|s| s as i64),
config.default_ttl.map(|d| d.as_secs() as i64),
now_nanos,
],
).map_err(|e| crate::Error::PersistenceError(format!("Failed to save playlist: {}", e)))?;
)
.map_err(|e| crate::Error::PersistenceError(format!("Failed to save playlist: {}", e)))?;
// Supprimer les anciens tracks
conn.execute("DELETE FROM tracks WHERE playlist_id = ?1", params![id])
@@ -140,21 +144,31 @@ impl PersistenceManager {
pub async fn load_playlist(
&self,
id: &str,
) -> Result<Option<(String, PlaylistRole, PlaylistConfig, VecDeque<Arc<Record>>)>> {
) -> Result<
Option<(
String,
PlaylistRole,
PlaylistConfig,
Option<String>,
VecDeque<Arc<Record>>,
)>,
> {
let conn = self.conn.lock().unwrap();
// Charger les métadonnées
let mut stmt = conn
.prepare("SELECT title, role, max_size, default_ttl_secs FROM playlists WHERE id = ?1")
.map_err(|e| {
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
})?;
let mut stmt = conn.prepare(
"SELECT title, role, cover_pk, max_size, default_ttl_secs FROM playlists WHERE id = ?1",
)
.map_err(|e| {
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
})?;
let result = stmt.query_row(params![id], |row| {
let title: String = row.get(0)?;
let role_raw: String = row.get(1)?;
let max_size: Option<i64> = row.get(2)?;
let default_ttl_secs: Option<i64> = row.get(3)?;
let cover_pk: Option<String> = row.get(2)?;
let max_size: Option<i64> = row.get(3)?;
let default_ttl_secs: Option<i64> = row.get(4)?;
Ok((
title,
@@ -164,10 +178,11 @@ impl PersistenceManager {
max_size: max_size.map(|s| s as usize),
default_ttl: default_ttl_secs.map(|s| Duration::from_secs(s as u64)),
},
cover_pk,
))
});
let (title, role, config) = match result {
let (title, role, config, cover_pk) = match result {
Ok(data) => data,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
Err(e) => {
@@ -210,7 +225,7 @@ impl PersistenceManager {
tracks.push_back(Arc::new(record));
}
Ok(Some((title, role, config, tracks)))
Ok(Some((title, role, config, cover_pk, tracks)))
}
/// Supprime une playlist

View File

@@ -34,6 +34,7 @@ pub struct Playlist {
pub id: String,
title: RwLock<String>,
role: RwLock<PlaylistRole>,
cover_pk: RwLock<Option<String>>,
state: Arc<AtomicU8>,
pub core: Arc<RwLock<PlaylistCore>>,
pub persistent: bool,
@@ -49,11 +50,13 @@ impl Playlist {
config: PlaylistConfig,
persistent: bool,
role: PlaylistRole,
cover_pk: Option<String>,
) -> Self {
Self {
id,
title: RwLock::new(title),
role: RwLock::new(role),
cover_pk: RwLock::new(cover_pk),
state: Arc::new(AtomicU8::new(PlaylistState::Active as u8)),
core: Arc::new(RwLock::new(PlaylistCore::new(config))),
persistent,
@@ -100,6 +103,17 @@ impl Playlist {
self.touch().await;
}
/// Retourne la cover associée à la playlist.
pub async fn cover_pk(&self) -> Option<String> {
self.cover_pk.read().await.clone()
}
/// Modifie la cover (PK) de la playlist.
pub async fn set_cover_pk(&self, value: Option<String>) {
*self.cover_pk.write().await = value;
self.touch().await;
}
/// Timestamp du dernier changement
pub async fn last_change(&self) -> SystemTime {
*self.last_change.read().await