Merge pull request 'push-xyrsxtokwrlu' (#81) from push-xyrsxtokwrlu into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m55s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m55s
Reviewed-on: #81
This commit was merged in pull request #81.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,6 +31,7 @@ xxx
|
|||||||
C/src/soxr-0.1.3/Release/tests
|
C/src/soxr-0.1.3/Release/tests
|
||||||
**/Release/
|
**/Release/
|
||||||
**/Debug/
|
**/Debug/
|
||||||
|
qobuz_debug
|
||||||
OLD-GO-CODE/
|
OLD-GO-CODE/
|
||||||
xxx
|
xxx
|
||||||
xx
|
xx
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.25"
|
version = "0.3.27"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
"console-subscriber",
|
"console-subscriber",
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ RUN apt-get update && apt-get install -y \
|
|||||||
cmake \
|
cmake \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy Cargo workspace files
|
# Copy Cargo workspace files and registry configuration
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY .cargo/ ./.cargo/
|
||||||
|
|
||||||
# Copy all crates
|
# Copy all crates
|
||||||
COPY PMOMusic/ ./PMOMusic/
|
COPY PMOMusic/ ./PMOMusic/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.26"
|
version = "0.3.27"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -449,8 +449,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
|||||||
|
|
||||||
@media (max-width: 768px) and (orientation: portrait) {
|
@media (max-width: 768px) and (orientation: portrait) {
|
||||||
.drawer-backdrop {
|
.drawer-backdrop {
|
||||||
right: 0; /* Mobile portrait: backdrop prend tout l'écran */
|
display: none; /* Mobile portrait: le drawer couvre 100vw, pas besoin de backdrop */
|
||||||
background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, reactive } from "vue";
|
import { ref, computed, watch, reactive, onMounted, onBeforeUnmount } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
@@ -20,7 +20,6 @@ import type {
|
|||||||
MediaServerSummary,
|
MediaServerSummary,
|
||||||
ContainerEntry,
|
ContainerEntry,
|
||||||
} from "@/services/pmocontrol/types";
|
} from "@/services/pmocontrol/types";
|
||||||
import type { BrowseState } from "@/composables/useMediaServers";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean; // v-model pour contrôler l'ouverture
|
modelValue: boolean; // v-model pour contrôler l'ouverture
|
||||||
@@ -35,6 +34,10 @@ const {
|
|||||||
allServers,
|
allServers,
|
||||||
fetchServers,
|
fetchServers,
|
||||||
browseContainer,
|
browseContainer,
|
||||||
|
getBrowseCached,
|
||||||
|
loadMoreBrowse,
|
||||||
|
hasMore,
|
||||||
|
loadingMore,
|
||||||
currentPath,
|
currentPath,
|
||||||
setPath,
|
setPath,
|
||||||
clearPath,
|
clearPath,
|
||||||
@@ -47,9 +50,51 @@ const router = useRouter();
|
|||||||
|
|
||||||
// État de navigation
|
// État de navigation
|
||||||
const currentServer = ref<MediaServerSummary | null>(null);
|
const currentServer = ref<MediaServerSummary | null>(null);
|
||||||
const browseData = ref<BrowseState | null>(null);
|
const currentContainerId = ref<string | null>(null);
|
||||||
|
const browseData = computed(() =>
|
||||||
|
currentServer.value && currentContainerId.value
|
||||||
|
? (getBrowseCached(currentServer.value.id, currentContainerId.value) ?? null)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
// Infinite scroll
|
||||||
|
const sentinelRef = ref<HTMLElement | null>(null);
|
||||||
|
const drawerContentRef = ref<HTMLElement | null>(null);
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
const canLoadMore = computed(() =>
|
||||||
|
currentServer.value && currentContainerId.value
|
||||||
|
? hasMore(currentServer.value.id, currentContainerId.value)
|
||||||
|
: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
function setupObserver() {
|
||||||
|
if (observer) observer.disconnect();
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
|
||||||
|
if (currentServer.value && currentContainerId.value) {
|
||||||
|
loadMoreBrowse(currentServer.value.id, currentContainerId.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: drawerContentRef.value, threshold: 0.1 },
|
||||||
|
);
|
||||||
|
if (sentinelRef.value) observer.observe(sentinelRef.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => setupObserver());
|
||||||
|
onBeforeUnmount(() => observer?.disconnect());
|
||||||
|
|
||||||
|
watch(sentinelRef, (el) => {
|
||||||
|
if (el) setupObserver();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(drawerContentRef, (el) => {
|
||||||
|
if (el) setupObserver();
|
||||||
|
});
|
||||||
|
|
||||||
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
|
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
|
||||||
const openMenuId = ref<string | null>(null);
|
const openMenuId = ref<string | null>(null);
|
||||||
|
|
||||||
@@ -85,7 +130,7 @@ watch(
|
|||||||
} else {
|
} else {
|
||||||
// Reset navigation quand on ferme
|
// Reset navigation quand on ferme
|
||||||
currentServer.value = null;
|
currentServer.value = null;
|
||||||
browseData.value = null;
|
currentContainerId.value = null;
|
||||||
clearPath();
|
clearPath();
|
||||||
closeMenu();
|
closeMenu();
|
||||||
imageStates.clear();
|
imageStates.clear();
|
||||||
@@ -143,11 +188,12 @@ async function handleServerClick(server: MediaServerSummary) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Browse racine (containerId = "0")
|
// Browse racine (containerId = "0")
|
||||||
browseData.value = await browseContainer(server.id, "0");
|
await browseContainer(server.id, "0");
|
||||||
|
currentContainerId.value = "0";
|
||||||
setPath([{ id: "0", title: server.friendly_name }]);
|
setPath([{ id: "0", title: server.friendly_name }]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ServerDrawer] Erreur browse racine:", error);
|
console.error("[ServerDrawer] Erreur browse racine:", error);
|
||||||
browseData.value = null;
|
currentContainerId.value = null;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -155,7 +201,7 @@ async function handleServerClick(server: MediaServerSummary) {
|
|||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
currentServer.value = null;
|
currentServer.value = null;
|
||||||
browseData.value = null;
|
currentContainerId.value = null;
|
||||||
clearPath();
|
clearPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,10 +210,8 @@ async function handleContainerClick(item: ContainerEntry) {
|
|||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
browseData.value = await browseContainer(
|
await browseContainer(currentServer.value.id, item.id);
|
||||||
currentServer.value.id,
|
currentContainerId.value = item.id;
|
||||||
item.id,
|
|
||||||
);
|
|
||||||
setPath([...currentPath.value, { id: item.id, title: item.title }]);
|
setPath([...currentPath.value, { id: item.id, title: item.title }]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ServerDrawer] Erreur browse container:", error);
|
console.error("[ServerDrawer] Erreur browse container:", error);
|
||||||
@@ -185,10 +229,8 @@ async function handleBreadcrumbClick(index: number) {
|
|||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
browseData.value = await browseContainer(
|
await browseContainer(currentServer.value.id, targetCrumb.id);
|
||||||
currentServer.value.id,
|
currentContainerId.value = targetCrumb.id;
|
||||||
targetCrumb.id,
|
|
||||||
);
|
|
||||||
setPath(currentPath.value.slice(0, index + 1));
|
setPath(currentPath.value.slice(0, index + 1));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ServerDrawer] Erreur breadcrumb navigation:", error);
|
console.error("[ServerDrawer] Erreur breadcrumb navigation:", error);
|
||||||
@@ -386,7 +428,7 @@ function handleSettingsClick() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Contenu -->
|
<!-- Contenu -->
|
||||||
<div class="drawer-content">
|
<div ref="drawerContentRef" class="drawer-content">
|
||||||
<!-- Liste des serveurs -->
|
<!-- Liste des serveurs -->
|
||||||
<div v-if="!isNavigating">
|
<div v-if="!isNavigating">
|
||||||
<!-- Servers online -->
|
<!-- Servers online -->
|
||||||
@@ -627,8 +669,16 @@ function handleSettingsClick() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- Sentinel infinite scroll -->
|
||||||
|
<div v-if="canLoadMore" ref="sentinelRef" class="scroll-sentinel" />
|
||||||
|
|
||||||
|
<!-- Spinner load more -->
|
||||||
|
<div v-if="loadingMore" class="load-more-spinner">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Vide -->
|
<!-- Vide -->
|
||||||
<div v-else class="empty-servers">
|
<div v-else-if="!browseData" class="empty-servers">
|
||||||
<Folder :size="48" />
|
<Folder :size="48" />
|
||||||
<p>Dossier vide</p>
|
<p>Dossier vide</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -666,8 +716,7 @@ function handleSettingsClick() {
|
|||||||
|
|
||||||
@media (max-width: 768px) and (orientation: portrait) {
|
@media (max-width: 768px) and (orientation: portrait) {
|
||||||
.drawer-backdrop {
|
.drawer-backdrop {
|
||||||
left: 0; /* Mobile portrait: backdrop commence à gauche car drawer prend 100vw */
|
display: none; /* Mobile portrait: le drawer couvre 100vw, pas besoin de backdrop */
|
||||||
background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1257,6 +1306,17 @@ function handleSettingsClick() {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scroll-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-spinner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
.backdrop-enter-active {
|
.backdrop-enter-active {
|
||||||
transition: opacity 0.3s ease-out;
|
transition: opacity 0.3s ease-out;
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ fn map_db_err(err: rusqlite::Error) -> MetadataError {
|
|||||||
pub struct AudioCacheTrackMetadata {
|
pub struct AudioCacheTrackMetadata {
|
||||||
cache: Arc<crate::Cache>,
|
cache: Arc<crate::Cache>,
|
||||||
pk: String,
|
pk: String,
|
||||||
|
/// Real pk si le lazy pk a été téléchargé, None sinon.
|
||||||
|
/// Résolu une seule fois à la construction pour éviter N appels DB par champ.
|
||||||
|
real_pk: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioCacheTrackMetadata {
|
impl AudioCacheTrackMetadata {
|
||||||
@@ -28,13 +31,26 @@ impl AudioCacheTrackMetadata {
|
|||||||
/// Le type implémente ensuite toutes les méthodes du trait `pmometadata::TrackMetadata`
|
/// Le type implémente ensuite toutes les méthodes du trait `pmometadata::TrackMetadata`
|
||||||
/// en stockant les données dans la base SQLite de `pmocache`.
|
/// en stockant les données dans la base SQLite de `pmocache`.
|
||||||
pub fn new(cache: Arc<crate::Cache>, pk: impl Into<String>) -> Self {
|
pub fn new(cache: Arc<crate::Cache>, pk: impl Into<String>) -> Self {
|
||||||
Self {
|
let pk_str: String = pk.into();
|
||||||
cache,
|
// Résolution lazy→real une seule fois à la construction (une seule query DB).
|
||||||
pk: pk.into(),
|
let real_pk = if pmocache::is_lazy_pk(&pk_str) {
|
||||||
}
|
cache.db.get_pk_by_lazy_pk(&pk_str).ok().flatten()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Self { cache, pk: pk_str, real_pk }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_raw(&self, key: &str) -> Result<Option<Value>, MetadataError> {
|
fn read_raw(&self, key: &str) -> Result<Option<Value>, MetadataError> {
|
||||||
|
// Si le lazy pk a été téléchargé, lire d'abord sous le real_pk
|
||||||
|
// (métadonnées écrites par FlacCacheSink), puis fallback sous le lazy_pk
|
||||||
|
// pour les clés semées avant téléchargement (cover_pk, etc.).
|
||||||
|
if let Some(real_pk) = &self.real_pk {
|
||||||
|
if let Ok(Some(v)) = self.cache.db.get_a_metadata(real_pk, key) {
|
||||||
|
return Ok(Some(v));
|
||||||
|
}
|
||||||
|
return self.cache.db.get_a_metadata(&self.pk, key).map_err(map_db_err);
|
||||||
|
}
|
||||||
self.cache
|
self.cache
|
||||||
.db
|
.db
|
||||||
.get_a_metadata(&self.pk, key)
|
.get_a_metadata(&self.pk, key)
|
||||||
|
|||||||
@@ -404,7 +404,20 @@ impl<C: CacheConfig + 'static> Cache<C> {
|
|||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let directory = PathBuf::from(dir);
|
let directory = PathBuf::from(dir);
|
||||||
std::fs::create_dir_all(&directory)?;
|
std::fs::create_dir_all(&directory)?;
|
||||||
let db = DB::init(&directory.join("cache.db"))?;
|
let (db, was_reset) = DB::init(&directory.join("cache.db"))?;
|
||||||
|
|
||||||
|
// Si le schéma a changé, effacer tous les fichiers du cache (cohérence DB/fichiers)
|
||||||
|
if was_reset {
|
||||||
|
tracing::warn!("Cache schema changed, clearing all cache files in {:?}", directory);
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&directory) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() && path.file_name().map_or(false, |n| n != "cache.db") {
|
||||||
|
std::fs::remove_file(&path).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Créer un channel pour les events (capacité de 100 events en buffer)
|
// Créer un channel pour les events (capacité de 100 events en buffer)
|
||||||
let (served_tx, _) = broadcast::channel(100);
|
let (served_tx, _) = broadcast::channel(100);
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ use tracing::{trace, warn};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::{Mutex, MutexGuard};
|
use std::sync::{Mutex, MutexGuard};
|
||||||
|
|
||||||
|
/// Version du schéma de la base de données du cache.
|
||||||
|
///
|
||||||
|
/// Incrémenter cette constante à chaque modification incompatible du schéma.
|
||||||
|
/// Cela provoquera la suppression de la DB **et de tous les fichiers du cache**
|
||||||
|
/// au prochain démarrage.
|
||||||
|
pub const SCHEMA_VERSION: u32 = 1;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
#[cfg(feature = "openapi")]
|
#[cfg(feature = "openapi")]
|
||||||
@@ -118,7 +125,33 @@ impl DB {
|
|||||||
///
|
///
|
||||||
/// let db = DB::init(Path::new("cache.db")).unwrap();
|
/// let db = DB::init(Path::new("cache.db")).unwrap();
|
||||||
/// ```
|
/// ```
|
||||||
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
|
/// Initialise la DB. Retourne `(db, was_reset)` où `was_reset` indique si la DB
|
||||||
|
/// a été supprimée et recréée suite à un changement de version de schéma.
|
||||||
|
/// Dans ce cas, l'appelant doit aussi effacer les fichiers du cache.
|
||||||
|
pub fn init(path: &Path) -> Result<(Self, bool), rusqlite::Error> {
|
||||||
|
let was_reset = if path.exists() {
|
||||||
|
if let Ok(conn) = Connection::open(path) {
|
||||||
|
let version: u32 = conn
|
||||||
|
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
if version != SCHEMA_VERSION {
|
||||||
|
drop(conn);
|
||||||
|
warn!(
|
||||||
|
"Cache DB schema version mismatch (found {}, expected {}), recreating",
|
||||||
|
version, SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
std::fs::remove_file(path).ok();
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
conn.execute("PRAGMA foreign_keys = ON", [])?;
|
conn.execute("PRAGMA foreign_keys = ON", [])?;
|
||||||
|
|
||||||
@@ -184,9 +217,10 @@ impl DB {
|
|||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(Self {
|
// Inscrire la version du schéma
|
||||||
conn: Mutex::new(conn),
|
conn.execute_batch(&format!("PRAGMA user_version = {}", SCHEMA_VERSION))?;
|
||||||
})
|
|
||||||
|
Ok((Self { conn: Mutex::new(conn) }, was_reset))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ajoute ou met à jour une entrée dans la base de données
|
/// Ajoute ou met à jour une entrée dans la base de données
|
||||||
@@ -1103,7 +1137,23 @@ impl DB {
|
|||||||
return Err(Error::QueryReturnedNoRows);
|
return Err(Error::QueryReturnedNoRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit()
|
// Compter les métadonnées encore sous l'ancien lazy_pk avant le commit
|
||||||
|
let meta_under_lazy: i64 = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM metadata WHERE pk = ?1",
|
||||||
|
[lazy_pk],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
tx.commit()?;
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"update_lazy_to_downloaded: {} → {} ({} metadata rows still under lazy_pk)",
|
||||||
|
lazy_pk, real_pk, meta_under_lazy
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recherche une entry par son origin_url
|
/// Recherche une entry par son origin_url
|
||||||
|
|||||||
@@ -1538,13 +1538,26 @@ fn refresh_attached_queue_for(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Browse container
|
// Step 3: Notify UI that the renderer is loading (Transitioning state)
|
||||||
|
event_bus.broadcast(RendererEvent::StateChanged {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
state: PlaybackState::Transitioning,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Browse container (renamed from Step 3 for clarity)
|
||||||
|
|
||||||
const MAX_BROWSE_ATTEMPTS: usize = 3;
|
const MAX_BROWSE_ATTEMPTS: usize = 3;
|
||||||
const BROWSE_RETRY_DELAY_MS: u64 = 200;
|
const BROWSE_RETRY_DELAY_MS: u64 = 200;
|
||||||
|
const BROWSE_PAGE_SIZE: u32 = 64;
|
||||||
|
|
||||||
|
// Paginated browse — une playlist peut dépasser BROWSE_PAGE_SIZE items
|
||||||
|
let entries = {
|
||||||
|
let mut all_entries = Vec::new();
|
||||||
|
let mut offset = 0u32;
|
||||||
|
loop {
|
||||||
let mut attempt = 1;
|
let mut attempt = 1;
|
||||||
let entries = loop {
|
let page = loop {
|
||||||
match music_server.browse_children(&container_id, 0, 64) {
|
match music_server.browse_children(&container_id, offset, BROWSE_PAGE_SIZE) {
|
||||||
Ok(e) => break e,
|
Ok(e) => break e,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if attempt >= MAX_BROWSE_ATTEMPTS {
|
if attempt >= MAX_BROWSE_ATTEMPTS {
|
||||||
@@ -1558,7 +1571,6 @@ fn refresh_attached_queue_for(
|
|||||||
);
|
);
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
server = server_id.0.as_str(),
|
server = server_id.0.as_str(),
|
||||||
@@ -1574,6 +1586,15 @@ fn refresh_attached_queue_for(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let fetched = page.len() as u32;
|
||||||
|
all_entries.extend(page);
|
||||||
|
if fetched < BROWSE_PAGE_SIZE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += fetched;
|
||||||
|
}
|
||||||
|
all_entries
|
||||||
|
};
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
|
|||||||
@@ -2234,8 +2234,19 @@ fn fetch_playback_items(
|
|||||||
let object_metadata = server.browse_object(object_id)?;
|
let object_metadata = server.browse_object(object_id)?;
|
||||||
|
|
||||||
let entries = if object_metadata.is_container {
|
let entries = if object_metadata.is_container {
|
||||||
// For containers, browse children to get all items
|
// For containers, browse all children with pagination
|
||||||
server.browse_children(object_id, 0, BROWSE_DEFAULT_LIMIT)?
|
let mut all_entries = Vec::new();
|
||||||
|
let mut offset = 0u32;
|
||||||
|
loop {
|
||||||
|
let page = server.browse_children(object_id, offset, BROWSE_DEFAULT_LIMIT)?;
|
||||||
|
let fetched = page.len() as u32;
|
||||||
|
all_entries.extend(page);
|
||||||
|
if fetched < BROWSE_DEFAULT_LIMIT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += fetched;
|
||||||
|
}
|
||||||
|
all_entries
|
||||||
} else {
|
} else {
|
||||||
// For items, use the object itself
|
// For items, use the object itself
|
||||||
vec![object_metadata]
|
vec![object_metadata]
|
||||||
|
|||||||
@@ -185,11 +185,12 @@ impl OhPlaylistClient {
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenHome spec: IdList is space-delimited (not comma-delimited)
|
||||||
let id_list_csv = id_list
|
let id_list_csv = id_list
|
||||||
.iter()
|
.iter()
|
||||||
.map(|id| id.to_string())
|
.map(|id| id.to_string())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(" ");
|
||||||
let args = [("IdList", id_list_csv.as_str())];
|
let args = [("IdList", id_list_csv.as_str())];
|
||||||
|
|
||||||
let call_result =
|
let call_result =
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ impl ReadHandle {
|
|||||||
let role = self.playlist.role().await;
|
let role = self.playlist.role().await;
|
||||||
let cover_pk = self.playlist.cover_pk().await;
|
let cover_pk = self.playlist.cover_pk().await;
|
||||||
let artist = self.playlist.artist().await;
|
let artist = self.playlist.artist().await;
|
||||||
|
let source = self.playlist.source().await;
|
||||||
|
let source_version = self.playlist.source_version().await;
|
||||||
let core = self.playlist.core.read().await;
|
let core = self.playlist.core.read().await;
|
||||||
let _ = persistence
|
let _ = persistence
|
||||||
.save_playlist(
|
.save_playlist(
|
||||||
@@ -78,6 +80,8 @@ impl ReadHandle {
|
|||||||
&role,
|
&role,
|
||||||
cover_pk.as_deref(),
|
cover_pk.as_deref(),
|
||||||
artist.as_deref(),
|
artist.as_deref(),
|
||||||
|
source.as_deref(),
|
||||||
|
source_version.as_deref(),
|
||||||
&core.config,
|
&core.config,
|
||||||
&core.tracks,
|
&core.tracks,
|
||||||
)
|
)
|
||||||
@@ -170,6 +174,21 @@ impl ReadHandle {
|
|||||||
&self.playlist.id
|
&self.playlist.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nombre total de records (sans validation cache)
|
||||||
|
pub async fn len(&self) -> usize {
|
||||||
|
self.playlist.core.read().await.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la source externe.
|
||||||
|
pub async fn source(&self) -> Option<String> {
|
||||||
|
self.playlist.source().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la version de la source.
|
||||||
|
pub async fn source_version(&self) -> Option<String> {
|
||||||
|
self.playlist.source_version().await
|
||||||
|
}
|
||||||
|
|
||||||
/// Génère un Container DIDL-Lite
|
/// Génère un Container DIDL-Lite
|
||||||
pub async fn to_container(&self) -> Result<Container> {
|
pub async fn to_container(&self) -> Result<Container> {
|
||||||
if !self.playlist.is_alive() {
|
if !self.playlist.is_alive() {
|
||||||
@@ -199,6 +218,86 @@ impl ReadHandle {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Génère des Items DIDL-Lite avec pagination offset-based (sans déplacer le curseur)
|
||||||
|
///
|
||||||
|
/// Retourne `(items, total)` où `total` est le nombre total de records dans la playlist.
|
||||||
|
/// Contrairement à `to_items()`, cette méthode ignore le curseur et utilise `offset`.
|
||||||
|
pub async fn to_items_paged(&self, offset: usize, limit: usize) -> Result<(Vec<Item>, usize)> {
|
||||||
|
if !self.playlist.is_alive() {
|
||||||
|
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let core = self.playlist.core.read().await;
|
||||||
|
let total = core.len();
|
||||||
|
let cache = crate::manager::audio_cache()?;
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut idx = 0usize;
|
||||||
|
|
||||||
|
for i in offset..core.len() {
|
||||||
|
if items.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = match core.get(i) {
|
||||||
|
Some(r) => r,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !cache.is_valid_pk(&record.cache_pk).await {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
use pmoaudiocache::metadata_ext::{AudioTrackMetadataExt, TrackMetadataDidlExt};
|
||||||
|
let track_meta = cache.track_metadata(&record.cache_pk);
|
||||||
|
let meta = track_meta.read().await;
|
||||||
|
|
||||||
|
let url = cache.route_for(&record.cache_pk, None);
|
||||||
|
let resource = meta.to_didl_resource(url).await;
|
||||||
|
|
||||||
|
let title = meta
|
||||||
|
.get_title()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
let artist = meta.get_artist().await.ok().flatten();
|
||||||
|
let album = meta.get_album().await.ok().flatten();
|
||||||
|
let genre = meta.get_genre().await.ok().flatten();
|
||||||
|
let year = meta.get_year().await.ok().flatten();
|
||||||
|
let track_number = meta.get_track_number().await.ok().flatten();
|
||||||
|
let cover_pk = meta.get_cover_pk().await.ok().flatten();
|
||||||
|
let cover_url = if let Some(pk) = cover_pk.as_ref() {
|
||||||
|
Some(pmocache::covers_route_for(pk, None))
|
||||||
|
} else {
|
||||||
|
meta.get_cover_url().await.ok().flatten()
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = Item {
|
||||||
|
id: format!("{}:{}", self.playlist.id, offset + idx),
|
||||||
|
parent_id: self.playlist.id.clone(),
|
||||||
|
restricted: Some("1".to_string()),
|
||||||
|
title: title.clone(),
|
||||||
|
creator: artist.clone(),
|
||||||
|
class: "object.item.audioItem.musicTrack".to_string(),
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
genre,
|
||||||
|
album_art: cover_url,
|
||||||
|
album_art_pk: cover_pk,
|
||||||
|
date: year.map(|y| y.to_string()),
|
||||||
|
original_track_number: track_number.map(|n| n.to_string()),
|
||||||
|
resources: vec![resource],
|
||||||
|
descriptions: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
items.push(item);
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((items, total))
|
||||||
|
}
|
||||||
|
|
||||||
/// Génère des Items DIDL-Lite depuis la position actuelle
|
/// Génère des Items DIDL-Lite depuis la position actuelle
|
||||||
pub async fn to_items(&self, limit: usize) -> Result<Vec<Item>> {
|
pub async fn to_items(&self, limit: usize) -> Result<Vec<Item>> {
|
||||||
if !self.playlist.is_alive() {
|
if !self.playlist.is_alive() {
|
||||||
|
|||||||
@@ -553,6 +553,50 @@ impl WriteHandle {
|
|||||||
|
|
||||||
// Helpers internes
|
// Helpers internes
|
||||||
|
|
||||||
|
/// Met à jour la source externe de la playlist.
|
||||||
|
pub async fn set_source(&self, source: Option<String>) -> Result<()> {
|
||||||
|
if !self.playlist.is_alive() {
|
||||||
|
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.playlist.set_source(source).await;
|
||||||
|
|
||||||
|
if self.playlist.persistent {
|
||||||
|
self.save_to_db().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::manager::PlaylistManager().notify_playlist_changed(&self.playlist.id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour la version de la source (ex: updated_at).
|
||||||
|
pub async fn set_source_version(&self, version: Option<String>) -> Result<()> {
|
||||||
|
if !self.playlist.is_alive() {
|
||||||
|
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.playlist.set_source_version(version).await;
|
||||||
|
|
||||||
|
if self.playlist.persistent {
|
||||||
|
self.save_to_db().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::manager::PlaylistManager().notify_playlist_changed(&self.playlist.id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la source externe.
|
||||||
|
pub async fn source(&self) -> Option<String> {
|
||||||
|
self.playlist.source().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la version de la source.
|
||||||
|
pub async fn source_version(&self) -> Option<String> {
|
||||||
|
self.playlist.source_version().await
|
||||||
|
}
|
||||||
|
|
||||||
async fn save_to_db(&self) -> Result<()> {
|
async fn save_to_db(&self) -> Result<()> {
|
||||||
let manager = crate::manager::PlaylistManager();
|
let manager = crate::manager::PlaylistManager();
|
||||||
let persistence = manager
|
let persistence = manager
|
||||||
@@ -567,6 +611,8 @@ impl WriteHandle {
|
|||||||
|
|
||||||
let cover_pk = self.playlist.cover_pk().await;
|
let cover_pk = self.playlist.cover_pk().await;
|
||||||
let artist = self.playlist.artist().await;
|
let artist = self.playlist.artist().await;
|
||||||
|
let source = self.playlist.source().await;
|
||||||
|
let source_version = self.playlist.source_version().await;
|
||||||
|
|
||||||
persistence
|
persistence
|
||||||
.save_playlist(
|
.save_playlist(
|
||||||
@@ -575,6 +621,8 @@ impl WriteHandle {
|
|||||||
&role,
|
&role,
|
||||||
cover_pk.as_deref(),
|
cover_pk.as_deref(),
|
||||||
artist.as_deref(),
|
artist.as_deref(),
|
||||||
|
source.as_deref(),
|
||||||
|
source_version.as_deref(),
|
||||||
config,
|
config,
|
||||||
tracks,
|
tracks,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ impl PlaylistManager {
|
|||||||
&role,
|
&role,
|
||||||
cover_pk.as_deref(),
|
cover_pk.as_deref(),
|
||||||
artist.as_deref(),
|
artist.as_deref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
&core.config,
|
&core.config,
|
||||||
&core.tracks,
|
&core.tracks,
|
||||||
)
|
)
|
||||||
@@ -522,7 +524,7 @@ impl PlaylistManager {
|
|||||||
|
|
||||||
// Pas en mémoire, essayer de charger depuis la DB
|
// Pas en mémoire, essayer de charger depuis la DB
|
||||||
if let Some(persistence) = &self.inner.persistence {
|
if let Some(persistence) = &self.inner.persistence {
|
||||||
if let Some((title, role, config, cover_pk, artist, tracks)) =
|
if let Some((title, role, config, cover_pk, artist, source, source_version, tracks)) =
|
||||||
persistence.load_playlist(&id).await?
|
persistence.load_playlist(&id).await?
|
||||||
{
|
{
|
||||||
// Reconstruire la playlist
|
// Reconstruire la playlist
|
||||||
@@ -537,10 +539,16 @@ impl PlaylistManager {
|
|||||||
cover_pk,
|
cover_pk,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Restaurer l'artiste si présent
|
// Restaurer les métadonnées optionnelles
|
||||||
if let Some(artist_name) = artist {
|
if let Some(artist_name) = artist {
|
||||||
playlist.set_artist(Some(artist_name)).await;
|
playlist.set_artist(Some(artist_name)).await;
|
||||||
}
|
}
|
||||||
|
if source.is_some() {
|
||||||
|
playlist.set_source(source).await;
|
||||||
|
}
|
||||||
|
if source_version.is_some() {
|
||||||
|
playlist.set_source_version(source_version).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Restaurer les tracks
|
// Restaurer les tracks
|
||||||
{
|
{
|
||||||
@@ -583,7 +591,7 @@ impl PlaylistManager {
|
|||||||
|
|
||||||
// Pas en m<>moire, essayer de ressusciter depuis la DB
|
// Pas en m<>moire, essayer de ressusciter depuis la DB
|
||||||
if let Some(persistence) = &self.inner.persistence {
|
if let Some(persistence) = &self.inner.persistence {
|
||||||
if let Some((title, role, config, cover_pk, artist, tracks)) =
|
if let Some((title, role, config, cover_pk, artist, source, source_version, tracks)) =
|
||||||
persistence.load_playlist(id).await?
|
persistence.load_playlist(id).await?
|
||||||
{
|
{
|
||||||
// Reconstruire la playlist
|
// Reconstruire la playlist
|
||||||
@@ -598,10 +606,16 @@ impl PlaylistManager {
|
|||||||
cover_pk,
|
cover_pk,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Restaurer l'artiste si présent
|
// Restaurer les métadonnées optionnelles
|
||||||
if let Some(artist_name) = artist {
|
if let Some(artist_name) = artist {
|
||||||
playlist.set_artist(Some(artist_name)).await;
|
playlist.set_artist(Some(artist_name)).await;
|
||||||
}
|
}
|
||||||
|
if source.is_some() {
|
||||||
|
playlist.set_source(source).await;
|
||||||
|
}
|
||||||
|
if source_version.is_some() {
|
||||||
|
playlist.set_source_version(source_version).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Restaurer les tracks
|
// Restaurer les tracks
|
||||||
{
|
{
|
||||||
@@ -995,6 +1009,8 @@ impl PlaylistManager {
|
|||||||
let role = playlist.role().await;
|
let role = playlist.role().await;
|
||||||
let cover_pk = playlist.cover_pk().await;
|
let cover_pk = playlist.cover_pk().await;
|
||||||
let artist = playlist.artist().await;
|
let artist = playlist.artist().await;
|
||||||
|
let source = playlist.source().await;
|
||||||
|
let source_version = playlist.source_version().await;
|
||||||
let core = playlist.core.read().await;
|
let core = playlist.core.read().await;
|
||||||
let _ = persistence
|
let _ = persistence
|
||||||
.save_playlist(
|
.save_playlist(
|
||||||
@@ -1003,6 +1019,8 @@ impl PlaylistManager {
|
|||||||
&role,
|
&role,
|
||||||
cover_pk.as_deref(),
|
cover_pk.as_deref(),
|
||||||
artist.as_deref(),
|
artist.as_deref(),
|
||||||
|
source.as_deref(),
|
||||||
|
source_version.as_deref(),
|
||||||
&core.config,
|
&core.config,
|
||||||
&core.tracks,
|
&core.tracks,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ use std::str::FromStr;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// Version du schéma de la base de données des playlists.
|
||||||
|
///
|
||||||
|
/// Incrémenter cette constante à chaque modification incompatible du schéma
|
||||||
|
/// (ajout/suppression de colonne non nullable, changement de type, etc.).
|
||||||
|
/// Cela provoquera la suppression et la recréation automatique de la DB au démarrage.
|
||||||
|
const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
/// Gestionnaire de persistance (une base pour toutes les playlists)
|
/// Gestionnaire de persistance (une base pour toutes les playlists)
|
||||||
pub struct PersistenceManager {
|
pub struct PersistenceManager {
|
||||||
conn: Arc<Mutex<Connection>>,
|
conn: Arc<Mutex<Connection>>,
|
||||||
@@ -26,6 +33,26 @@ impl PersistenceManager {
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vérifier la version du schéma — supprimer la DB si incompatible
|
||||||
|
if db_path.exists() {
|
||||||
|
if let Ok(conn) = Connection::open(db_path) {
|
||||||
|
let version: u32 = conn
|
||||||
|
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
if version != SCHEMA_VERSION {
|
||||||
|
drop(conn);
|
||||||
|
tracing::warn!(
|
||||||
|
"Playlist DB schema version mismatch (found {}, expected {}), recreating",
|
||||||
|
version,
|
||||||
|
SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
std::fs::remove_file(db_path).map_err(|e| {
|
||||||
|
crate::Error::PersistenceError(format!("Failed to remove old DB: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let conn = Connection::open(db_path).map_err(|e| {
|
let conn = Connection::open(db_path).map_err(|e| {
|
||||||
crate::Error::PersistenceError(format!("Failed to open database: {}", e))
|
crate::Error::PersistenceError(format!("Failed to open database: {}", e))
|
||||||
})?;
|
})?;
|
||||||
@@ -38,6 +65,8 @@ impl PersistenceManager {
|
|||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
cover_pk TEXT,
|
cover_pk TEXT,
|
||||||
artist TEXT,
|
artist TEXT,
|
||||||
|
source TEXT,
|
||||||
|
source_version TEXT,
|
||||||
max_size INTEGER,
|
max_size INTEGER,
|
||||||
default_ttl_secs INTEGER,
|
default_ttl_secs INTEGER,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
@@ -75,6 +104,12 @@ impl PersistenceManager {
|
|||||||
)
|
)
|
||||||
.map_err(|e| crate::Error::PersistenceError(format!("Failed to create index: {}", e)))?;
|
.map_err(|e| crate::Error::PersistenceError(format!("Failed to create index: {}", e)))?;
|
||||||
|
|
||||||
|
// Inscrire la version du schéma
|
||||||
|
conn.execute_batch(&format!("PRAGMA user_version = {}", SCHEMA_VERSION))
|
||||||
|
.map_err(|e| {
|
||||||
|
crate::Error::PersistenceError(format!("Failed to set schema version: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
conn: Arc::new(Mutex::new(conn)),
|
conn: Arc::new(Mutex::new(conn)),
|
||||||
})
|
})
|
||||||
@@ -88,6 +123,8 @@ impl PersistenceManager {
|
|||||||
role: &PlaylistRole,
|
role: &PlaylistRole,
|
||||||
cover_pk: Option<&str>,
|
cover_pk: Option<&str>,
|
||||||
artist: Option<&str>,
|
artist: Option<&str>,
|
||||||
|
source: Option<&str>,
|
||||||
|
source_version: Option<&str>,
|
||||||
config: &PlaylistConfig,
|
config: &PlaylistConfig,
|
||||||
tracks: &VecDeque<Arc<Record>>,
|
tracks: &VecDeque<Arc<Record>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -100,16 +137,18 @@ impl PersistenceManager {
|
|||||||
|
|
||||||
// Upsert playlist metadata
|
// Upsert playlist metadata
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO playlists (id, title, role, cover_pk, artist, max_size, default_ttl_secs, created_at, last_modified)
|
"INSERT OR REPLACE INTO playlists (id, title, role, cover_pk, artist, source, source_version, max_size, default_ttl_secs, created_at, last_modified)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7,
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9,
|
||||||
COALESCE((SELECT created_at FROM playlists WHERE id = ?1), ?8),
|
COALESCE((SELECT created_at FROM playlists WHERE id = ?1), ?10),
|
||||||
?8)",
|
?10)",
|
||||||
params![
|
params![
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
role.as_str(),
|
role.as_str(),
|
||||||
cover_pk,
|
cover_pk,
|
||||||
artist,
|
artist,
|
||||||
|
source,
|
||||||
|
source_version,
|
||||||
config.max_size.map(|s| s as i64),
|
config.max_size.map(|s| s as i64),
|
||||||
config.default_ttl.map(|d| d.as_secs() as i64),
|
config.default_ttl.map(|d| d.as_secs() as i64),
|
||||||
now_nanos,
|
now_nanos,
|
||||||
@@ -154,6 +193,8 @@ impl PersistenceManager {
|
|||||||
PlaylistConfig,
|
PlaylistConfig,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
VecDeque<Arc<Record>>,
|
VecDeque<Arc<Record>>,
|
||||||
)>,
|
)>,
|
||||||
> {
|
> {
|
||||||
@@ -161,7 +202,7 @@ impl PersistenceManager {
|
|||||||
|
|
||||||
// Charger les métadonnées
|
// Charger les métadonnées
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT title, role, cover_pk, artist, max_size, default_ttl_secs FROM playlists WHERE id = ?1",
|
"SELECT title, role, cover_pk, artist, source, source_version, max_size, default_ttl_secs FROM playlists WHERE id = ?1",
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
|
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
|
||||||
@@ -172,8 +213,10 @@ impl PersistenceManager {
|
|||||||
let role_raw: String = row.get(1)?;
|
let role_raw: String = row.get(1)?;
|
||||||
let cover_pk: Option<String> = row.get(2)?;
|
let cover_pk: Option<String> = row.get(2)?;
|
||||||
let artist: Option<String> = row.get(3)?;
|
let artist: Option<String> = row.get(3)?;
|
||||||
let max_size: Option<i64> = row.get(4)?;
|
let source: Option<String> = row.get(4)?;
|
||||||
let default_ttl_secs: Option<i64> = row.get(5)?;
|
let source_version: Option<String> = row.get(5)?;
|
||||||
|
let max_size: Option<i64> = row.get(6)?;
|
||||||
|
let default_ttl_secs: Option<i64> = row.get(7)?;
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
title,
|
title,
|
||||||
@@ -185,10 +228,12 @@ impl PersistenceManager {
|
|||||||
},
|
},
|
||||||
cover_pk,
|
cover_pk,
|
||||||
artist,
|
artist,
|
||||||
|
source,
|
||||||
|
source_version,
|
||||||
))
|
))
|
||||||
});
|
});
|
||||||
|
|
||||||
let (title, role, config, cover_pk, artist) = match result {
|
let (title, role, config, cover_pk, artist, source, source_version) = match result {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -231,7 +276,7 @@ impl PersistenceManager {
|
|||||||
tracks.push_back(Arc::new(record));
|
tracks.push_back(Arc::new(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Some((title, role, config, cover_pk, artist, tracks)))
|
Ok(Some((title, role, config, cover_pk, artist, source, source_version, tracks)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supprime une playlist
|
/// Supprime une playlist
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ pub struct Playlist {
|
|||||||
role: RwLock<PlaylistRole>,
|
role: RwLock<PlaylistRole>,
|
||||||
cover_pk: RwLock<Option<String>>,
|
cover_pk: RwLock<Option<String>>,
|
||||||
artist: RwLock<Option<String>>,
|
artist: RwLock<Option<String>>,
|
||||||
|
/// Source externe (ex: "qobuz")
|
||||||
|
source: RwLock<Option<String>>,
|
||||||
|
/// Version de la source (ex: timestamp updated_at de Qobuz)
|
||||||
|
source_version: RwLock<Option<String>>,
|
||||||
state: Arc<AtomicU8>,
|
state: Arc<AtomicU8>,
|
||||||
pub core: Arc<RwLock<PlaylistCore>>,
|
pub core: Arc<RwLock<PlaylistCore>>,
|
||||||
pub persistent: bool,
|
pub persistent: bool,
|
||||||
@@ -59,6 +63,8 @@ impl Playlist {
|
|||||||
role: RwLock::new(role),
|
role: RwLock::new(role),
|
||||||
cover_pk: RwLock::new(cover_pk),
|
cover_pk: RwLock::new(cover_pk),
|
||||||
artist: RwLock::new(None),
|
artist: RwLock::new(None),
|
||||||
|
source: RwLock::new(None),
|
||||||
|
source_version: RwLock::new(None),
|
||||||
state: Arc::new(AtomicU8::new(PlaylistState::Active as u8)),
|
state: Arc::new(AtomicU8::new(PlaylistState::Active as u8)),
|
||||||
core: Arc::new(RwLock::new(PlaylistCore::new(config))),
|
core: Arc::new(RwLock::new(PlaylistCore::new(config))),
|
||||||
persistent,
|
persistent,
|
||||||
@@ -127,6 +133,28 @@ impl Playlist {
|
|||||||
self.touch().await;
|
self.touch().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retourne la source externe associée à la playlist.
|
||||||
|
pub async fn source(&self) -> Option<String> {
|
||||||
|
self.source.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modifie la source de la playlist.
|
||||||
|
pub async fn set_source(&self, value: Option<String>) {
|
||||||
|
*self.source.write().await = value;
|
||||||
|
self.touch().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la version de la source (ex: updated_at timestamp).
|
||||||
|
pub async fn source_version(&self) -> Option<String> {
|
||||||
|
self.source_version.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modifie la version de la source.
|
||||||
|
pub async fn set_source_version(&self, value: Option<String>) {
|
||||||
|
*self.source_version.write().await = value;
|
||||||
|
self.touch().await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Timestamp du dernier changement
|
/// Timestamp du dernier changement
|
||||||
pub async fn last_change(&self) -> SystemTime {
|
pub async fn last_change(&self) -> SystemTime {
|
||||||
*self.last_change.read().await
|
*self.last_change.read().await
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ pub(crate) struct AlbumResponse {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
release_date_original: Option<String>,
|
release_date_original: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
released_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
image: Option<ImageResponse>,
|
image: Option<ImageResponse>,
|
||||||
#[serde(default = "default_streamable")]
|
#[serde(default = "default_streamable")]
|
||||||
streamable: bool,
|
streamable: bool,
|
||||||
@@ -120,6 +122,8 @@ pub(crate) struct PlaylistResponse {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
owner: Option<OwnerResponse>,
|
owner: Option<OwnerResponse>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
updated_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
tracks: Option<PaginatedResponse<TrackResponse>>,
|
tracks: Option<PaginatedResponse<TrackResponse>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +321,16 @@ impl QobuzApi {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Récupère les métadonnées d'une playlist sans les tracks (appel léger)
|
||||||
|
///
|
||||||
|
/// Utile pour vérifier `updated_at` avant de décider si le cache est valide.
|
||||||
|
pub async fn get_playlist_metadata(&self, playlist_id: &str) -> Result<Playlist> {
|
||||||
|
debug!("Fetching playlist metadata {}", playlist_id);
|
||||||
|
let params = [("playlist_id", playlist_id)];
|
||||||
|
let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?;
|
||||||
|
Ok(Self::parse_playlist(response))
|
||||||
|
}
|
||||||
|
|
||||||
/// Récupère les détails d'une playlist
|
/// Récupère les détails d'une playlist
|
||||||
pub async fn get_playlist(&self, playlist_id: &str) -> Result<Playlist> {
|
pub async fn get_playlist(&self, playlist_id: &str) -> Result<Playlist> {
|
||||||
debug!("Fetching playlist {}", playlist_id);
|
debug!("Fetching playlist {}", playlist_id);
|
||||||
@@ -328,18 +342,36 @@ impl QobuzApi {
|
|||||||
/// Récupère les tracks d'une playlist
|
/// Récupère les tracks d'une playlist
|
||||||
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
|
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
|
||||||
debug!("Fetching tracks for playlist {}", playlist_id);
|
debug!("Fetching tracks for playlist {}", playlist_id);
|
||||||
let params = [("playlist_id", playlist_id), ("extra", "tracks")];
|
const PAGE_SIZE: u32 = 50;
|
||||||
|
let mut all_tracks = Vec::new();
|
||||||
|
let mut offset = 0u32;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let offset_str = offset.to_string();
|
||||||
|
let limit_str = PAGE_SIZE.to_string();
|
||||||
|
let params = [
|
||||||
|
("playlist_id", playlist_id),
|
||||||
|
("extra", "tracks"),
|
||||||
|
("offset", offset_str.as_str()),
|
||||||
|
("limit", limit_str.as_str()),
|
||||||
|
];
|
||||||
let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?;
|
let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?;
|
||||||
|
|
||||||
if let Some(tracks) = response.tracks {
|
if let Some(tracks) = response.tracks {
|
||||||
Ok(tracks
|
let total = tracks.total.unwrap_or(0);
|
||||||
.items
|
let count = tracks.items.len() as u32;
|
||||||
.into_iter()
|
all_tracks.extend(tracks.items.into_iter().map(|t| Self::parse_track(t, None)));
|
||||||
.map(|t| Self::parse_track(t, None))
|
offset += count;
|
||||||
.collect())
|
if count == 0 || offset >= total {
|
||||||
} else {
|
break;
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Fetched {} tracks total for playlist {}", all_tracks.len(), playlist_id);
|
||||||
|
Ok(all_tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère la liste des genres
|
/// Récupère la liste des genres
|
||||||
@@ -493,6 +525,7 @@ impl QobuzApi {
|
|||||||
tracks_count: response.tracks_count,
|
tracks_count: response.tracks_count,
|
||||||
duration: response.duration,
|
duration: response.duration,
|
||||||
release_date: response.release_date_original,
|
release_date: response.release_date_original,
|
||||||
|
released_at: response.released_at,
|
||||||
image: response.image.and_then(|i| i.large),
|
image: response.image.and_then(|i| i.large),
|
||||||
image_cached: None,
|
image_cached: None,
|
||||||
streamable: response.streamable,
|
streamable: response.streamable,
|
||||||
@@ -547,6 +580,7 @@ impl QobuzApi {
|
|||||||
image: response.images300.and_then(|imgs| imgs.first().cloned()),
|
image: response.images300.and_then(|imgs| imgs.first().cloned()),
|
||||||
image_cached: None,
|
image_cached: None,
|
||||||
is_public: response.is_public,
|
is_public: response.is_public,
|
||||||
|
updated_at: response.updated_at,
|
||||||
owner: response.owner.map(|o| PlaylistOwner {
|
owner: response.owner.map(|o| PlaylistOwner {
|
||||||
id: o.id.parse().unwrap_or(0),
|
id: o.id.parse().unwrap_or(0),
|
||||||
name: o.name,
|
name: o.name,
|
||||||
|
|||||||
@@ -17,6 +17,49 @@ use std::sync::RwLock;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Si la variable d'environnement `QOBUZ_DEBUG_DIR` est définie, sauvegarde
|
||||||
|
/// le JSON brut de chaque réponse API dans ce dossier.
|
||||||
|
fn debug_save_response(endpoint: &str, params: &[(&str, &str)], text: &str) {
|
||||||
|
let Ok(dir) = std::env::var("QOBUZ_DEBUG_DIR") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let dir = std::path::Path::new(&dir);
|
||||||
|
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||||
|
debug!("QOBUZ_DEBUG_DIR: cannot create dir: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire un nom de fichier lisible : endpoint + params clés
|
||||||
|
let endpoint_slug = endpoint.trim_start_matches('/').replace('/', "_");
|
||||||
|
let param_slug: String = params
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| !["app_id", "user_auth_token", "request_ts", "request_sig"].contains(k))
|
||||||
|
.map(|(k, v)| format!("{}-{}", k, v.chars().take(20).collect::<String>()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("_");
|
||||||
|
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let filename = format!("{}_{}_{}.json", endpoint_slug, param_slug, ts);
|
||||||
|
let path = dir.join(&filename);
|
||||||
|
|
||||||
|
// Pretty-print si possible
|
||||||
|
let content = serde_json::from_str::<Value>(text)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| serde_json::to_string_pretty(&v).ok())
|
||||||
|
.unwrap_or_else(|| text.to_string());
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::write(&path, content) {
|
||||||
|
debug!("QOBUZ_DEBUG_DIR: cannot write {}: {}", filename, e);
|
||||||
|
} else {
|
||||||
|
debug!("QOBUZ_DEBUG_DIR: saved {}", filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub use spoofer::Spoofer;
|
pub use spoofer::Spoofer;
|
||||||
|
|
||||||
/// URL de base de l'API Qobuz
|
/// URL de base de l'API Qobuz
|
||||||
@@ -270,7 +313,7 @@ impl QobuzApi {
|
|||||||
|
|
||||||
// Envoyer la requête
|
// Envoyer la requête
|
||||||
let response = request.send().await?;
|
let response = request.send().await?;
|
||||||
self.handle_response(response, endpoint).await
|
self.handle_response(response, endpoint, params).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Traite la réponse HTTP
|
/// Traite la réponse HTTP
|
||||||
@@ -278,6 +321,7 @@ impl QobuzApi {
|
|||||||
&self,
|
&self,
|
||||||
response: Response,
|
response: Response,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
|
params: &[(&str, &str)],
|
||||||
) -> Result<T> {
|
) -> Result<T> {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let status_code = status.as_u16();
|
let status_code = status.as_u16();
|
||||||
@@ -294,6 +338,7 @@ impl QobuzApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let text = response.text().await?;
|
let text = response.text().await?;
|
||||||
|
debug_save_response(endpoint, params, &text);
|
||||||
|
|
||||||
// Vérifier si la réponse contient une erreur Qobuz
|
// Vérifier si la réponse contient une erreur Qobuz
|
||||||
if let Ok(json) = serde_json::from_str::<Value>(&text) {
|
if let Ok(json) = serde_json::from_str::<Value>(&text) {
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ pub struct Album {
|
|||||||
/// Date de sortie (format ISO 8601)
|
/// Date de sortie (format ISO 8601)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub release_date: Option<String>,
|
pub release_date: Option<String>,
|
||||||
|
/// Timestamp Unix de sortie (released_at)
|
||||||
|
#[serde(default)]
|
||||||
|
pub released_at: Option<i64>,
|
||||||
/// URL de l'image de couverture
|
/// URL de l'image de couverture
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
@@ -141,6 +144,9 @@ pub struct Playlist {
|
|||||||
/// Indique si c'est une playlist publique
|
/// Indique si c'est une playlist publique
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub is_public: bool,
|
pub is_public: bool,
|
||||||
|
/// Timestamp de dernière modification (Unix epoch secondes)
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
/// Propriétaire de la playlist
|
/// Propriétaire de la playlist
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub owner: Option<PlaylistOwner>,
|
pub owner: Option<PlaylistOwner>,
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ use pmosource::SourceCacheManager;
|
|||||||
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
|
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::SystemTime;
|
||||||
|
|
||||||
/// TTL pour les playlists d'albums (7 jours)
|
/// TTL pour les playlists d'albums (7 jours)
|
||||||
const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600);
|
|
||||||
|
|
||||||
/// Trait pour les types dont on peut cacher la cover image.
|
/// Trait pour les types dont on peut cacher la cover image.
|
||||||
trait CoverCacheable {
|
trait CoverCacheable {
|
||||||
@@ -563,54 +562,41 @@ impl QobuzSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Vérifie si une playlist d'album existe et est valide (non expirée ET non vide)
|
/// Vérifie si une playlist d'album existe et est valide (non expirée ET non vide)
|
||||||
async fn is_album_playlist_valid(&self, playlist_id: &str) -> Result<bool> {
|
/// Retourne la source_version d'une pmoplaylist si elle existe en mémoire ou en DB.
|
||||||
|
async fn cached_source_version(&self, playlist_id: &str) -> Option<String> {
|
||||||
let playlist_manager = pmoplaylist::PlaylistManager();
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
||||||
|
|
||||||
if !playlist_manager.exists(playlist_id).await {
|
if !playlist_manager.exists(playlist_id).await {
|
||||||
return Ok(false);
|
return None;
|
||||||
|
}
|
||||||
|
playlist_manager
|
||||||
|
.get_read_handle(playlist_id)
|
||||||
|
.await
|
||||||
|
.ok()?
|
||||||
|
.source_version()
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier l'âge
|
/// Adapte des Items DIDL issus d'une pmoplaylist pour le schéma UPnP Qobuz.
|
||||||
match playlist_manager.get_playlist_age(playlist_id).await {
|
///
|
||||||
Ok(Some(age)) if age < ALBUM_PLAYLIST_TTL => {
|
/// - Résout l'ID de la track (`qobuz:track:<id>`) depuis les métadonnées cache
|
||||||
// Playlist non expirée, vérifier qu'elle contient des tracks
|
/// - Rend absolues les URLs relatives (resource + cover)
|
||||||
match playlist_manager.get_read_handle(playlist_id).await {
|
/// - Affecte le `parent_id` fourni
|
||||||
Ok(reader) => {
|
async fn adapt_items_to_qobuz(
|
||||||
let count = reader.remaining().await.unwrap_or(0);
|
|
||||||
Ok(count > 0) // Valide seulement si non vide
|
|
||||||
}
|
|
||||||
Err(_) => Ok(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Ok(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adapte les Items d'une playlist pour correspondre au schéma UPnP Qobuz
|
|
||||||
async fn adapt_playlist_items_to_qobuz(
|
|
||||||
&self,
|
&self,
|
||||||
items: Vec<Item>,
|
items: Vec<Item>,
|
||||||
album_id: &str,
|
parent_id: &str,
|
||||||
) -> Result<Vec<Item>> {
|
) -> Result<Vec<Item>> {
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
let parent_id = format!("qobuz:album:{}", album_id);
|
|
||||||
|
|
||||||
let mut adapted = Vec::with_capacity(items.len());
|
let mut adapted = Vec::with_capacity(items.len());
|
||||||
|
|
||||||
for mut item in items {
|
for mut item in items {
|
||||||
// Extraire cache_pk depuis l'URL du resource
|
let cache_pk = item
|
||||||
let cache_pk = if let Some(resource) = item.resources.first() {
|
.resources
|
||||||
resource
|
.first()
|
||||||
.url
|
.and_then(|r| r.url.strip_prefix("/audio/flac/").map(|s| s.to_string()));
|
||||||
.strip_prefix("/audio/flac/")
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(pk) = cache_pk {
|
if let Some(pk) = cache_pk {
|
||||||
// Récupérer track_id depuis metadata
|
|
||||||
if let Ok(Some(track_id_value)) = self
|
if let Ok(Some(track_id_value)) = self
|
||||||
.inner
|
.inner
|
||||||
.cache_manager
|
.cache_manager
|
||||||
@@ -625,9 +611,6 @@ impl QobuzSource {
|
|||||||
warn!("No qobuz_track_id metadata for {}", pk);
|
warn!("No qobuz_track_id metadata for {}", pk);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convertir URL relative en URL absolue
|
|
||||||
// From: /audio/flac/QOBUZ:123
|
|
||||||
// To: http://192.168.0.138:8080/audio/flac/QOBUZ:123
|
|
||||||
if let Some(resource) = item.resources.first_mut() {
|
if let Some(resource) = item.resources.first_mut() {
|
||||||
if resource.url.starts_with('/') {
|
if resource.url.starts_with('/') {
|
||||||
resource.url = format!("{}{}", self.inner.base_url, resource.url);
|
resource.url = format!("{}{}", self.inner.base_url, resource.url);
|
||||||
@@ -635,21 +618,59 @@ impl QobuzSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convertir l'URL de la cover en URL absolue si elle est relative
|
|
||||||
if let Some(art) = item.album_art.as_mut() {
|
if let Some(art) = item.album_art.as_mut() {
|
||||||
if art.starts_with('/') {
|
if art.starts_with('/') {
|
||||||
*art = format!("{}{}", self.inner.base_url, art);
|
*art = format!("{}{}", self.inner.base_url, art);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item.parent_id = parent_id.clone();
|
item.parent_id = parent_id.to_string();
|
||||||
adapted.push(item);
|
adapted.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(adapted)
|
Ok(adapted)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère ou crée une playlist lazy pour un album
|
/// Ouvre un WriteHandle sur une pmoplaylist persistante, en la créant si absente
|
||||||
|
/// ou en la vidant (flush) si elle existe déjà.
|
||||||
|
async fn get_or_flush_playlist_writer(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
role: pmoplaylist::PlaylistRole,
|
||||||
|
) -> Result<pmoplaylist::WriteHandle> {
|
||||||
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
||||||
|
|
||||||
|
if playlist_manager.exists(playlist_id).await {
|
||||||
|
let writer = playlist_manager
|
||||||
|
.get_persistent_write_handle(playlist_id.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
writer
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
Ok(writer)
|
||||||
|
} else {
|
||||||
|
playlist_manager
|
||||||
|
.create_persistent_playlist_with_role(playlist_id.to_string(), role)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met en cache la cover si l'URL est présente.
|
||||||
|
async fn cache_cover_opt(&self, image_url: Option<&str>) -> Option<String> {
|
||||||
|
self.inner
|
||||||
|
.cache_manager
|
||||||
|
.cache_cover(image_url?)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère ou crée une playlist lazy pour un album, avec invalidation par version.
|
||||||
|
///
|
||||||
|
/// `source_version` = `"{released_at}_{tracks_count}"` — change uniquement en cas de
|
||||||
|
/// réédition avec pistes supplémentaires. Cache valide à vie si la version correspond.
|
||||||
async fn get_or_create_album_playlist_items(
|
async fn get_or_create_album_playlist_items(
|
||||||
&self,
|
&self,
|
||||||
album_id: &str,
|
album_id: &str,
|
||||||
@@ -659,30 +680,11 @@ impl QobuzSource {
|
|||||||
|
|
||||||
let playlist_id = format!("qobuz-album-{}", album_id);
|
let playlist_id = format!("qobuz-album-{}", album_id);
|
||||||
let playlist_manager = pmoplaylist::PlaylistManager();
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
||||||
|
let parent_id = format!("qobuz:album:{}", album_id);
|
||||||
|
|
||||||
// Vérifier validité (existe ET non expirée)
|
let cached_version = self.cached_source_version(&playlist_id).await;
|
||||||
let is_valid = self.is_album_playlist_valid(&playlist_id).await?;
|
|
||||||
|
|
||||||
if is_valid {
|
// Charger les métadonnées album (contient déjà les tracks)
|
||||||
debug!("Album playlist {} found and valid", playlist_id);
|
|
||||||
|
|
||||||
let reader = playlist_manager
|
|
||||||
.get_read_handle(&playlist_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
||||||
|
|
||||||
let items = reader
|
|
||||||
.to_items(limit)
|
|
||||||
.await
|
|
||||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
||||||
|
|
||||||
return self.adapt_playlist_items_to_qobuz(items, album_id).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Playlist invalide/inexistante : (re)créer
|
|
||||||
info!("Album playlist {} creating/refreshing", playlist_id);
|
|
||||||
|
|
||||||
// 1. Métadonnées album
|
|
||||||
let album = self
|
let album = self
|
||||||
.inner
|
.inner
|
||||||
.client
|
.client
|
||||||
@@ -690,47 +692,49 @@ impl QobuzSource {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
// 2. Cache cover
|
let album_version = Some(format!(
|
||||||
let cover_pk = if let Some(ref image_url) = album.image {
|
"{}_{}",
|
||||||
self.inner.cache_manager.cache_cover(image_url).await.ok()
|
album.released_at.unwrap_or(0),
|
||||||
} else {
|
album.tracks_count.unwrap_or(0)
|
||||||
None
|
));
|
||||||
};
|
|
||||||
|
|
||||||
// 3. Créer ou récupérer playlist
|
if cached_version.is_some() && cached_version == album_version {
|
||||||
let writer = if playlist_manager.exists(&playlist_id).await {
|
debug!("Album playlist {} cache valid (version {:?})", playlist_id, album_version);
|
||||||
let writer = playlist_manager
|
let reader = playlist_manager
|
||||||
.get_persistent_write_handle(playlist_id.clone())
|
.get_read_handle(&playlist_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
let items = reader
|
||||||
writer
|
.to_items(limit)
|
||||||
.flush()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
return self.adapt_items_to_qobuz(items, &parent_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
writer
|
info!("Album playlist {} creating/refreshing (version {:?})", playlist_id, album_version);
|
||||||
} else {
|
|
||||||
playlist_manager
|
let cover_pk = self.cache_cover_opt(album.image.as_deref()).await;
|
||||||
.create_persistent_playlist_with_role(
|
|
||||||
playlist_id.clone(),
|
let writer = self
|
||||||
pmoplaylist::PlaylistRole::Album,
|
.get_or_flush_playlist_writer(&playlist_id, pmoplaylist::PlaylistRole::Album)
|
||||||
)
|
.await?;
|
||||||
.await
|
|
||||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. Métadonnées playlist
|
|
||||||
writer
|
writer
|
||||||
.set_title(album.title.clone())
|
.set_title(album.title.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
writer
|
writer
|
||||||
.set_artist(Some(album.artist.name.clone()))
|
.set_artist(Some(album.artist.name.clone()))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
writer
|
||||||
|
.set_source(Some("qobuz".to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
writer
|
||||||
|
.set_source_version(album_version)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
if let Some(pk) = cover_pk {
|
if let Some(pk) = cover_pk {
|
||||||
writer
|
writer
|
||||||
.set_cover_pk(Some(pk))
|
.set_cover_pk(Some(pk))
|
||||||
@@ -741,22 +745,122 @@ impl QobuzSource {
|
|||||||
// IMPORTANT: Libérer le write lock avant d'appeler add_album_to_playlist
|
// IMPORTANT: Libérer le write lock avant d'appeler add_album_to_playlist
|
||||||
drop(writer);
|
drop(writer);
|
||||||
|
|
||||||
// 5. Ajouter tracks (réutilise add_album_to_playlist existant)
|
|
||||||
self.add_album_to_playlist(&playlist_id, album_id).await?;
|
self.add_album_to_playlist(&playlist_id, album_id).await?;
|
||||||
|
|
||||||
// 6. Récupérer items
|
|
||||||
let reader = playlist_manager
|
let reader = playlist_manager
|
||||||
.get_read_handle(&playlist_id)
|
.get_read_handle(&playlist_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
let items = reader
|
let items = reader
|
||||||
.to_items(limit)
|
.to_items(limit)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
// 7. Adapter IDs
|
self.adapt_items_to_qobuz(items, &parent_id).await
|
||||||
self.adapt_playlist_items_to_qobuz(items, album_id).await
|
}
|
||||||
|
|
||||||
|
/// Récupère ou crée une pmoplaylist pour une playlist Qobuz, avec invalidation par updated_at.
|
||||||
|
///
|
||||||
|
/// - Si la playlist est en cache et que `updated_at` n'a pas changé → retourne le cache.
|
||||||
|
/// - Sinon → recharge toutes les tracks depuis Qobuz et reconstruit la pmoplaylist.
|
||||||
|
async fn get_or_create_qobuz_playlist_items(
|
||||||
|
&self,
|
||||||
|
qobuz_playlist_id: &str,
|
||||||
|
) -> Result<Vec<Item>> {
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
let pmo_playlist_id = format!("qobuz-playlist-{}", qobuz_playlist_id);
|
||||||
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
||||||
|
|
||||||
|
let cached_version = self.cached_source_version(&pmo_playlist_id).await;
|
||||||
|
|
||||||
|
// Obtenir les métadonnées depuis Qobuz pour comparer updated_at
|
||||||
|
let qobuz_meta: crate::models::Playlist = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.get_playlist(qobuz_playlist_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e: crate::error::QobuzError| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let qobuz_version = qobuz_meta.updated_at.map(|t: i64| t.to_string());
|
||||||
|
|
||||||
|
// Cache valide si version correspond
|
||||||
|
let cache_valid = cached_version.is_some() && cached_version == qobuz_version;
|
||||||
|
|
||||||
|
let parent_id = format!("qobuz:playlist:{}", qobuz_playlist_id);
|
||||||
|
|
||||||
|
if cache_valid {
|
||||||
|
debug!("Qobuz playlist {} cache valid (version {:?})", pmo_playlist_id, qobuz_version);
|
||||||
|
let reader = playlist_manager
|
||||||
|
.get_read_handle(&pmo_playlist_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
let (items, total) = reader
|
||||||
|
.to_items_paged(0, usize::MAX)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
// Si des PKs ne sont pas enregistrés dans le cache audio (e.g. playlist créée
|
||||||
|
// avec l'ancien code), on force un refresh pour les ré-enregistrer proprement.
|
||||||
|
if items.len() == total {
|
||||||
|
return self.adapt_items_to_qobuz(items, &parent_id).await;
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"Qobuz playlist {} has {}/{} valid items, forcing refresh to repair missing entries",
|
||||||
|
pmo_playlist_id, items.len(), total
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Qobuz playlist {} creating/refreshing (version {:?})", pmo_playlist_id, qobuz_version);
|
||||||
|
|
||||||
|
let tracks = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.get_playlist_tracks(qobuz_playlist_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let cover_pk = self.cache_cover_opt(qobuz_meta.image.as_deref()).await;
|
||||||
|
|
||||||
|
let writer = self
|
||||||
|
.get_or_flush_playlist_writer(&pmo_playlist_id, pmoplaylist::PlaylistRole::Source)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
writer
|
||||||
|
.set_title(qobuz_meta.name.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
writer
|
||||||
|
.set_source(Some("qobuz".to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
writer
|
||||||
|
.set_source_version(qobuz_version)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
if let Some(pk) = cover_pk {
|
||||||
|
writer
|
||||||
|
.set_cover_pk(Some(pk))
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lazy_pks = self.register_tracks_lazy(&tracks).await;
|
||||||
|
writer
|
||||||
|
.push_lazy_batch(lazy_pks)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
drop(writer);
|
||||||
|
|
||||||
|
let reader = playlist_manager
|
||||||
|
.get_read_handle(&pmo_playlist_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
let (items, _total) = reader
|
||||||
|
.to_items_paged(0, usize::MAX)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
|
self.adapt_items_to_qobuz(items, &parent_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Increment update counter (called on catalog changes)
|
/// Increment update counter (called on catalog changes)
|
||||||
@@ -937,6 +1041,104 @@ impl QobuzSource {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enregistre une liste de tracks comme lazy entries dans l'audio cache (en parallèle).
|
||||||
|
///
|
||||||
|
/// Contrairement à `add_track_lazy`, cette méthode ne fait PAS d'appel à `get_stream_url`
|
||||||
|
/// (coûteux pour de grandes playlists). L'URL audio est résolue à la demande via
|
||||||
|
/// `QobuzLazyProvider` lors de la première lecture.
|
||||||
|
///
|
||||||
|
/// Pour chaque track : cache la cover, enregistre la lazy entry, stocke les métadonnées.
|
||||||
|
/// Retourne la liste des lazy PKs enregistrés avec succès.
|
||||||
|
async fn register_tracks_lazy(&self, tracks: &[crate::models::Track]) -> Vec<String> {
|
||||||
|
// Limite la concurrence pour ne pas saturer l'API Qobuz ni la connexion réseau.
|
||||||
|
// Les covers déjà cachées sont retournées immédiatement (pas d'HTTP), donc même
|
||||||
|
// 600 tracks ne génèrent que ~N_albums_uniques téléchargements réels.
|
||||||
|
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(16));
|
||||||
|
|
||||||
|
// On attache l'index original à chaque future pour pouvoir retrier dans l'ordre
|
||||||
|
// d'origine après complétion parallèle (JoinSet retourne dans l'ordre de fin).
|
||||||
|
let futs: Vec<_> = tracks.iter().enumerate().map(|(idx, track)| {
|
||||||
|
let source = self.clone();
|
||||||
|
let sem = sem.clone();
|
||||||
|
let track_id = track.id.clone();
|
||||||
|
let track_title = track.title.clone();
|
||||||
|
let cover_image_url = track.album.as_ref().and_then(|a| a.image.clone());
|
||||||
|
let metadata = pmoaudiocache::AudioMetadata {
|
||||||
|
title: Some(track.title.clone()),
|
||||||
|
artist: track.performer.as_ref().map(|p| p.name.clone()),
|
||||||
|
album: track.album.as_ref().map(|a| a.title.clone()),
|
||||||
|
duration_secs: Some(track.duration as u64),
|
||||||
|
year: track.album.as_ref().and_then(|a| {
|
||||||
|
a.release_date.as_ref().and_then(|d| d.split('-').next()?.parse().ok())
|
||||||
|
}),
|
||||||
|
track_number: Some(track.track_number),
|
||||||
|
track_total: track.album.as_ref().and_then(|a| a.tracks_count),
|
||||||
|
disc_number: Some(track.media_number),
|
||||||
|
disc_total: None,
|
||||||
|
genre: track.album.as_ref().and_then(|a| {
|
||||||
|
if !a.genres.is_empty() { Some(a.genres.join(", ")) } else { None }
|
||||||
|
}),
|
||||||
|
sample_rate: track.sample_rate,
|
||||||
|
channels: track.channels,
|
||||||
|
bitrate: None,
|
||||||
|
conversion: None,
|
||||||
|
};
|
||||||
|
async move {
|
||||||
|
let _permit = sem.acquire().await.ok()?;
|
||||||
|
let lazy_pk = format!("QOBUZ:{}", track_id);
|
||||||
|
|
||||||
|
// 1. Cache cover eagerly
|
||||||
|
let cover_pk = if let Some(ref url) = cover_image_url {
|
||||||
|
source.inner.cache_manager.cache_cover(url).await.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Register lazy entry + set cover_pk + seed metadata.
|
||||||
|
// Si le performer est absent (réponse API incomplète), on passe None pour
|
||||||
|
// que le provider appelle get_track et récupère les métadonnées complètes.
|
||||||
|
if metadata.artist.is_none() {
|
||||||
|
tracing::warn!(
|
||||||
|
"register_tracks_lazy: no performer for track {} (id={}), will call provider",
|
||||||
|
track_title, track_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if cover_pk.is_none() && cover_image_url.is_some() {
|
||||||
|
tracing::warn!(
|
||||||
|
"register_tracks_lazy: cover download failed for track {} (id={}), will call provider for cover",
|
||||||
|
track_title, track_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let meta_hint = if metadata.artist.is_some() { Some(metadata) } else { None };
|
||||||
|
match source.inner.cache_manager
|
||||||
|
.cache_audio_lazy_with_provider(&lazy_pk, meta_hint, cover_pk)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(pk) => {
|
||||||
|
let _ = source.inner.cache_manager.set_audio_metadata(
|
||||||
|
&pk, "qobuz_track_id", json!(track_id),
|
||||||
|
);
|
||||||
|
Some((idx, pk))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to register lazy track {}: {}", track_title, e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Retrier par index original pour conserver l'ordre de la playlist Qobuz
|
||||||
|
let mut results: Vec<(usize, String)> = tokio::task::JoinSet::from_iter(futs)
|
||||||
|
.join_all()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
results.sort_unstable_by_key(|(i, _)| *i);
|
||||||
|
results.into_iter().map(|(_, pk)| pk).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Cache les covers d'une liste d'artistes en parallèle.
|
/// Cache les covers d'une liste d'artistes en parallèle.
|
||||||
async fn cache_artist_covers(&self, artists: Vec<crate::models::Artist>) -> Vec<crate::models::Artist> {
|
async fn cache_artist_covers(&self, artists: Vec<crate::models::Artist>) -> Vec<crate::models::Artist> {
|
||||||
let futs: Vec<_> = artists.into_iter().map(|mut artist| {
|
let futs: Vec<_> = artists.into_iter().map(|mut artist| {
|
||||||
@@ -1623,22 +1825,9 @@ impl MusicSource for QobuzSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ObjectIdType::Playlist(playlist_id) => {
|
ObjectIdType::Playlist(playlist_id) => {
|
||||||
let tracks = self
|
let items = self
|
||||||
.inner
|
.get_or_create_qobuz_playlist_items(&playlist_id)
|
||||||
.client
|
.await?;
|
||||||
.get_playlist_tracks(&playlist_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
||||||
|
|
||||||
let tracks = self.cache_covers(tracks).await;
|
|
||||||
let items: Vec<Item> = tracks
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|track| {
|
|
||||||
track
|
|
||||||
.to_didl_item(&format!("qobuz:playlist:{}", playlist_id))
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(BrowseResult::Items(items))
|
Ok(BrowseResult::Items(items))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ use tokio::sync::RwLock;
|
|||||||
#[cfg(feature = "cache")]
|
#[cfg(feature = "cache")]
|
||||||
use pmocovers::Cache as CoverCache;
|
use pmocovers::Cache as CoverCache;
|
||||||
|
|
||||||
#[cfg(feature = "cache")]
|
|
||||||
use pmocache::cache_trait::FileCache;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CachedMetadata
|
// CachedMetadata
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -583,11 +580,7 @@ impl MetadataCache {
|
|||||||
}
|
}
|
||||||
Err(discover_err) => {
|
Err(discover_err) => {
|
||||||
#[cfg(feature = "logging")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::error!(
|
tracing::error!("Rediscovery failed for '{}': {}", slug, discover_err);
|
||||||
"Rediscovery failed for '{}': {}",
|
|
||||||
slug,
|
|
||||||
discover_err
|
|
||||||
);
|
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -697,12 +690,7 @@ impl MetadataCache {
|
|||||||
match self.client.rediscover_station(slug).await {
|
match self.client.rediscover_station(slug).await {
|
||||||
Ok((id, url)) => {
|
Ok((id, url)) => {
|
||||||
#[cfg(feature = "logging")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::info!(
|
tracing::info!("Re-discovered station '{}': id={}, url={}", slug, id, url);
|
||||||
"Re-discovered station '{}': id={}, url={}",
|
|
||||||
slug,
|
|
||||||
id,
|
|
||||||
url
|
|
||||||
);
|
|
||||||
self.persist_station_mapping();
|
self.persist_station_mapping();
|
||||||
// Invalider le cache mémoire pour forcer un refresh des métadonnées
|
// Invalider le cache mémoire pour forcer un refresh des métadonnées
|
||||||
let mut cache = self.cache.write().await;
|
let mut cache = self.cache.write().await;
|
||||||
@@ -801,11 +789,18 @@ mod tests {
|
|||||||
assert!(base.is_expired());
|
assert!(base.is_expired());
|
||||||
|
|
||||||
// end_time dans le futur → non expiré
|
// end_time dans le futur → non expiré
|
||||||
let not_expired = CachedMetadata { end_time: Some(now + 3600), ..base.clone() };
|
let not_expired = CachedMetadata {
|
||||||
|
end_time: Some(now + 3600),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
assert!(!not_expired.is_expired());
|
assert!(!not_expired.is_expired());
|
||||||
|
|
||||||
// Pas de end_time, fetched_at récent → non expiré (fallback TTL)
|
// Pas de end_time, fetched_at récent → non expiré (fallback TTL)
|
||||||
let no_end_fresh = CachedMetadata { end_time: None, fetched_at: now, ..base.clone() };
|
let no_end_fresh = CachedMetadata {
|
||||||
|
end_time: None,
|
||||||
|
fetched_at: now,
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
assert!(!no_end_fresh.is_expired());
|
assert!(!no_end_fresh.is_expired());
|
||||||
|
|
||||||
// Pas de end_time, fetched_at ancien → expiré
|
// Pas de end_time, fetched_at ancien → expiré
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ impl SourceCacheManager {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let metadata_is_some = metadata.is_some();
|
||||||
let mut final_metadata = metadata;
|
let mut final_metadata = metadata;
|
||||||
if final_metadata.is_none() {
|
if final_metadata.is_none() {
|
||||||
if let Some(data) = provider_data.as_ref() {
|
if let Some(data) = provider_data.as_ref() {
|
||||||
@@ -352,10 +353,25 @@ impl SourceCacheManager {
|
|||||||
|
|
||||||
if let Some(meta) = final_metadata.as_ref() {
|
if let Some(meta) = final_metadata.as_ref() {
|
||||||
self.seed_audio_metadata(lazy_pk, meta);
|
self.seed_audio_metadata(lazy_pk, meta);
|
||||||
|
} else {
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
tracing::warn!(
|
||||||
|
"cache_audio_lazy_with_provider: no metadata to seed for {} \
|
||||||
|
(meta_hint={}, provider_data={})",
|
||||||
|
lazy_pk,
|
||||||
|
metadata_is_some,
|
||||||
|
provider_data.as_ref().map(|d| d.metadata.is_some()).unwrap_or(false)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(cover_pk) = final_cover_pk {
|
if let Some(cover_pk) = final_cover_pk {
|
||||||
let _ = self.set_audio_metadata(lazy_pk, "cover_pk", json!(cover_pk));
|
let _ = self.set_audio_metadata(lazy_pk, "cover_pk", json!(cover_pk));
|
||||||
|
} else {
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
tracing::debug!(
|
||||||
|
"cache_audio_lazy_with_provider: no cover_pk seeded for {}",
|
||||||
|
lazy_pk
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(lazy_pk.to_string())
|
Ok(lazy_pk.to_string())
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.3.26
|
0.3.27
|
||||||
|
|||||||
Reference in New Issue
Block a user