Première version testable avec la construction d'une image docker #22
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user