Première version testable avec la construction d'une image docker #22

Merged
eric merged 22 commits from push-snzspktvyxwn into main 2025-12-23 13:06:46 +01:00
5 changed files with 255 additions and 14 deletions
Showing only changes of commit d2d8111668 - Show all commits

View File

@@ -4,6 +4,7 @@
<h2>🎵 Audio Cache Manager</h2>
<div class="stats">
<span>{{ tracks.length }} tracks</span>
<span v-if="lazyTracksCount > 0">{{ lazyTracksCount }} lazy pending</span>
<span v-if="totalHits > 0">{{ totalHits }} hits</span>
</div>
</div>
@@ -85,7 +86,8 @@
/>
<div v-else class="music-icon">🎵</div>
<div class="track-overlay">
<span class="hits">{{ track.hits }} plays</span>
<span class="hits" v-if="!isLazyTrack(track)">{{ track.hits }} plays</span>
<span class="hits lazy" v-else>Lazy</span>
</div>
</div>
<div class="track-info">
@@ -98,7 +100,10 @@
<div class="track-album" v-if="track.metadata?.album">
{{ track.metadata.album }}
</div>
<div class="pk">{{ track.pk }}</div>
<div class="pk">
{{ track.pk }}
<span v-if="isLazyTrack(track)" class="lazy-tag">lazy</span>
</div>
<div class="meta">
<span v-if="durationMs(track) !== undefined">
{{ formatDuration(durationMs(track)!) }}
@@ -119,12 +124,15 @@
<div class="last-used" v-if="track.last_used">
Last used: {{ formatDate(track.last_used) }}
</div>
<div class="lazy-warning" v-if="isLazyTrack(track)">
Audio not downloaded yet. First playback (or forcing download) will fetch it automatically.
</div>
</div>
<div class="track-actions">
<button
@click.stop="playTrack(track.pk)"
class="btn-play"
title="Play"
:title="isLazyTrack(track) ? 'Trigger download & play once available' : 'Play'"
>
</button>
@@ -136,6 +144,30 @@
>
{{ deletingTracks.has(track.pk) ? "..." : "🗑️" }}
</button>
<button
@click.stop="downloadTrack(track.pk)"
class="btn-secondary"
:disabled="isLazyTrack(track)"
:title="
isLazyTrack(track)
? 'Download available once audio finished downloading'
: 'Download original file'
"
>
</button>
<button
@click.stop="copyTrackUrl(track.pk)"
class="btn-secondary"
:disabled="isLazyTrack(track)"
:title="
isLazyTrack(track)
? 'URL available after download completes'
: 'Copy stream URL'
"
>
📋
</button>
</div>
</div>
</div>
@@ -175,6 +207,7 @@
<div class="cache-section">
<h4>Cache Info</h4>
<p><strong>PK:</strong> {{ selectedTrack.pk }}</p>
<p><strong>Status:</strong> {{ trackStatusLabel(selectedTrack) }}</p>
<p v-if="resolveTrackOrigin(selectedTrack)">
<strong>Source URL:</strong>
<a :href="resolveTrackOrigin(selectedTrack)" target="_blank">{{ resolveTrackOrigin(selectedTrack) }}</a>
@@ -188,10 +221,18 @@
<button @click="playTrack(selectedTrack.pk)" class="btn-play">
Play
</button>
<button @click="downloadTrack(selectedTrack.pk)" class="btn-secondary">
<button
@click="downloadTrack(selectedTrack.pk)"
class="btn-secondary"
:disabled="isLazyTrack(selectedTrack)"
>
Download
</button>
<button @click="copyTrackUrl(selectedTrack.pk)" class="btn-secondary">
<button
@click="copyTrackUrl(selectedTrack.pk)"
class="btn-secondary"
:disabled="isLazyTrack(selectedTrack)"
>
📋 Copy URL
</button>
<button @click="handleDeleteTrack(selectedTrack.pk); selectedTrack = null" class="btn-danger">
@@ -244,6 +285,7 @@ const selectedTrack = ref<AudioCacheEntry | null>(null);
const isLoading = ref(false);
const sortBy = ref<"hits" | "last_used" | "recent">("hits");
const audioPlayer = ref<HTMLAudioElement | null>(null);
const LAZY_PREFIX = "L:";
// Formulaire d'ajout
const newTrackUrl = ref("");
@@ -266,6 +308,9 @@ const failedCovers = ref(new Set<string>());
// --- Computed ---
const totalHits = computed(() => tracks.value.reduce((sum, t) => sum + t.hits, 0));
const lazyTracksCount = computed(() =>
tracks.value.reduce((acc, track) => (isLazyTrack(track) ? acc + 1 : acc), 0)
);
const sortedTracks = computed(() => {
const arr = [...tracks.value];
@@ -410,10 +455,20 @@ function handleAudioError() {
}
function downloadTrack(pk: string) {
const track = getTrackByPk(pk);
if (track && isLazyTrack(track)) {
alert("This track is still in lazy cache. Play it once to download the audio before exporting.");
return;
}
window.open(getOriginalTrackUrl(pk), "_blank");
}
function copyTrackUrl(pk: string) {
const track = getTrackByPk(pk);
if (track && isLazyTrack(track)) {
alert("URL available after the lazy audio has been downloaded.");
return;
}
navigator.clipboard.writeText(window.location.origin + getTrackUrl(pk));
alert("✅ URL copied!");
}
@@ -476,6 +531,21 @@ function handleCoverError(pk: string) {
failedCovers.value.add(pk);
}
function isLazyTrack(track: AudioCacheEntry | null | undefined): boolean {
return !!track?.pk && track.pk.startsWith(LAZY_PREFIX);
}
function trackStatusLabel(track: AudioCacheEntry | null): string {
if (isLazyTrack(track)) {
return "Lazy (audio pending download)";
}
return "Cached";
}
function getTrackByPk(pk: string): AudioCacheEntry | undefined {
return tracks.value.find((t) => t.pk === pk);
}
onMounted(() => {
refreshTracks();
});
@@ -751,6 +821,14 @@ button:disabled {
font-size: 0.9rem;
}
.track-overlay .hits.lazy {
background: rgba(255, 152, 0, 0.85);
color: #000;
font-weight: bold;
padding: 0.2rem 0.5rem;
border-radius: 999px;
}
.track-info {
padding: 1rem;
flex: 1;
@@ -790,6 +868,18 @@ button:disabled {
margin-bottom: 0.5rem;
}
.lazy-tag {
display: inline-block;
margin-left: 0.5rem;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: #ff9800;
color: #000;
font-size: 0.65rem;
text-transform: uppercase;
font-weight: bold;
}
.meta {
display: flex;
gap: 0.75rem;
@@ -811,6 +901,16 @@ button:disabled {
margin-top: 0.5rem;
}
.lazy-warning {
margin-top: 0.75rem;
font-size: 0.85rem;
color: #ffb347;
background: rgba(255, 152, 0, 0.15);
border: 1px solid rgba(255, 152, 0, 0.3);
padding: 0.5rem;
border-radius: 6px;
}
.track-actions {
padding: 0 1rem 1rem;
display: flex;

View File

@@ -140,9 +140,20 @@ async fn serve_lazy_audio_file<C: CacheConfig>(
// Construire l'URL de redirection
let redirect_url = if param == C::default_param() {
format!("/cache/{}/{}", C::cache_type(), real_pk)
format!(
"/{}/{}/{}",
C::cache_name(),
C::cache_type(),
real_pk
)
} else {
format!("/cache/{}/{}/{}", C::cache_type(), real_pk, param)
format!(
"/{}/{}/{}/{}",
C::cache_name(),
C::cache_type(),
real_pk,
param
)
};
return Redirect::temporary(&redirect_url).into_response();
@@ -190,9 +201,20 @@ async fn serve_lazy_audio_file<C: CacheConfig>(
// 6. Rediriger vers la vraie URL
let redirect_url = if param == C::default_param() {
format!("/cache/{}/{}", C::cache_type(), real_pk)
format!(
"/{}/{}/{}",
C::cache_name(),
C::cache_type(),
real_pk
)
} else {
format!("/cache/{}/{}/{}", C::cache_type(), real_pk, param)
format!(
"/{}/{}/{}/{}",
C::cache_name(),
C::cache_type(),
real_pk,
param
)
};
tracing::debug!(

View File

@@ -16,8 +16,10 @@
//! cargo run -p pmoqobuz --example lazy_loading
//! ```
use pmoaudiocache::Cache as AudioCache;
use pmoaudiocache::{register_audio_cache, AudioCacheConfigExt, Cache as AudioCache};
use pmoconfig::get_config;
use pmocovers::Cache as CoverCache;
use pmocovers::CoverCacheConfigExt;
use pmoplaylist::PlaylistManager;
use pmoqobuz::{QobuzClient, QobuzSource};
use std::sync::Arc;
@@ -43,13 +45,41 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Step 2: Initialize caches
println!("💾 Initializing caches...");
let cover_cache = Arc::new(CoverCache::new("./cache/qobuz-covers", 500)?);
let audio_cache = Arc::new(AudioCache::new("./cache/qobuz-audio", 100)?);
let config = get_config();
let (cover_cache, audio_cache, using_global) =
match (config.create_cover_cache(), config.create_audio_cache()) {
(Ok(covers), Ok(audio)) => (covers, audio, true),
(covers_res, audio_res) => {
let err_cover = covers_res.err();
let err_audio = audio_res.err();
if let Some(err) = err_cover {
println!(
" ⚠️ Could not open configured cover cache: {}. Falling back to ./cache",
err
);
}
if let Some(err) = err_audio {
println!(
" ⚠️ Could not open configured audio cache: {}. Falling back to ./cache",
err
);
}
let cover_cache = Arc::new(CoverCache::new("./cache/qobuz-covers", 500)?);
let audio_cache = Arc::new(AudioCache::new("./cache/qobuz-audio", 100)?);
(cover_cache, audio_cache, false)
}
};
// IMPORTANT: Register audio cache globally so PlaylistManager can access it
register_audio_cache(audio_cache.clone());
pmoplaylist::register_audio_cache(audio_cache.clone());
println!("✅ Caches initialized\n");
if using_global {
println!("✅ Caches initialized from global configuration\n");
} else {
println!("✅ Local caches initialized in ./cache (global config unavailable)\n");
}
// Step 3: Create QobuzSource with caches
let source = QobuzSource::new(client, cover_cache.clone(), audio_cache.clone());

View File

@@ -11,6 +11,7 @@ use pmocovers::Cache as CoverCache;
use pmodidl::{Container, Item};
use pmosource::SourceCacheManager;
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
use serde_json::json;
use std::sync::Arc;
use std::time::SystemTime;
@@ -203,6 +204,13 @@ impl QobuzSource {
.await
.ok();
if let (Some(ref audio_pk), Some(ref cover_pk)) = (&cached_audio_pk, &cached_cover_pk) {
let _ =
self.inner
.cache_manager
.set_audio_metadata(audio_pk, "cover_pk", json!(cover_pk));
}
// 4. Store metadata
self.inner
.cache_manager
@@ -295,6 +303,14 @@ impl QobuzSource {
))
})?;
if let Some(ref cover_pk) = cached_cover_pk {
let _ = self.inner.cache_manager.set_audio_metadata(
&cached_audio_pk,
"cover_pk",
json!(cover_pk),
);
}
// 4. Store metadata
self.inner
.cache_manager

View File

@@ -21,12 +21,28 @@
use crate::{CacheStatus, MusicSourceError, Result};
use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
use pmocovers::Cache as CoverCache;
use serde_json::Value as JsonValue;
use serde_json::{json, Value as JsonValue};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::io::AsyncRead;
use tokio::sync::RwLock;
fn log_metadata_warning(key: &str, audio_pk: &str, error: &str) {
#[cfg(feature = "server")]
{
tracing::warn!(
"Failed to store metadata {} for {}: {}",
key,
audio_pk,
error
);
}
#[cfg(not(feature = "server"))]
{
let _ = (key, audio_pk, error);
}
}
/// Métadonnées d'une piste en cache
#[derive(Debug, Clone)]
pub struct TrackMetadata {
@@ -252,6 +268,7 @@ impl SourceCacheManager {
.db
.set_a_metadata_by_key(&lazy_pk, "audio_metadata", json);
}
self.seed_audio_metadata(&lazy_pk, &meta);
}
Ok(lazy_pk)
@@ -303,6 +320,62 @@ impl SourceCacheManager {
cache.remove(track_id);
}
/// Pré-remplit les métadonnées audio pour une entrée lazy
fn seed_audio_metadata(&self, audio_pk: &str, metadata: &AudioMetadata) {
let audio_pk = audio_pk.to_string();
let mut store = |key: &str, value: JsonValue| {
if let Err(e) = self.audio_cache.db.set_a_metadata(&audio_pk, key, value) {
log_metadata_warning(key, &audio_pk, &e.to_string());
}
};
if let Some(title) = metadata.title.as_ref() {
store("title", JsonValue::String(title.clone()));
}
if let Some(artist) = metadata.artist.as_ref() {
store("artist", JsonValue::String(artist.clone()));
}
if let Some(album) = metadata.album.as_ref() {
store("album", JsonValue::String(album.clone()));
}
if let Some(year) = metadata.year {
store("year", json!(year));
}
if let Some(track_number) = metadata.track_number {
store("track_number", json!(track_number));
}
if let Some(track_total) = metadata.track_total {
store("track_total", json!(track_total));
}
if let Some(disc_number) = metadata.disc_number {
store("disc_number", json!(disc_number));
}
if let Some(disc_total) = metadata.disc_total {
store("disc_total", json!(disc_total));
}
if let Some(genre) = metadata.genre.as_ref() {
store("genre", JsonValue::String(genre.clone()));
}
if let Some(duration_secs) = metadata.duration_secs {
store("duration_secs", json!(duration_secs));
store("duration_ms", json!(duration_secs * 1000));
}
if let Some(sample_rate) = metadata.sample_rate {
store("sample_rate", json!(sample_rate));
}
if let Some(channels) = metadata.channels {
store("channels", json!(channels));
}
if let Some(bitrate) = metadata.bitrate {
store("bitrate", json!(bitrate));
}
if let Some(conversion) = metadata.conversion.as_ref() {
if let Ok(value) = serde_json::to_value(conversion) {
store("conversion", value);
}
}
}
/// Stocke une métadonnée personnalisée pour un fichier audio caché
///
/// Permet de stocker des métadonnées arbitraires (clé/valeur JSON) associées