correction for lazy playlist and lazy cache
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -41,5 +41,5 @@ test_upnp*.cargo/
|
|||||||
setup-env.sh
|
setup-env.sh
|
||||||
cache
|
cache
|
||||||
gupnp-tools
|
gupnp-tools
|
||||||
pmocontrol_[0_9]*.txt
|
pmo*_[0_9]*.txt
|
||||||
webapp_[0_9]*.txt
|
webapp_[0_9]*.txt
|
||||||
@@ -48,15 +48,9 @@ pub fn is_lazy_pk(pk: &str) -> bool {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum CacheEvent {
|
pub enum CacheEvent {
|
||||||
/// Un fichier a été servi via HTTP
|
/// Un fichier a été servi via HTTP
|
||||||
Served {
|
Served { pk: String, format: String },
|
||||||
pk: String,
|
|
||||||
format: String,
|
|
||||||
},
|
|
||||||
/// Un fichier lazy a été téléchargé et est maintenant disponible
|
/// Un fichier lazy a été téléchargé et est maintenant disponible
|
||||||
LazyDownloaded {
|
LazyDownloaded { lazy_pk: String, real_pk: String },
|
||||||
lazy_pk: String,
|
|
||||||
real_pk: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Informations transmises lors de la diffusion d'un élément du cache via HTTP.
|
/// Informations transmises lors de la diffusion d'un élément du cache via HTTP.
|
||||||
@@ -1358,7 +1352,7 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
let lazy_pk = generate_lazy_pk(url);
|
let lazy_pk = generate_lazy_pk(url);
|
||||||
|
|
||||||
// Vérifier si ce lazy_pk existe déjà (collision improbable mais...)
|
// Vérifier si ce lazy_pk existe déjà (collision improbable mais...)
|
||||||
if let Ok(Some(_)) = self.db.get_pk_by_lazy_pk(&lazy_pk) {
|
if let Ok(true) = self.db.has_lazy_entry(&lazy_pk) {
|
||||||
bail!("Lazy PK collision for URL: {}", url);
|
bail!("Lazy PK collision for URL: {}", url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::{
|
|||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{CacheConfig, DB};
|
use crate::{cache::is_lazy_pk, CacheConfig, DB};
|
||||||
|
|
||||||
/// Trait générique pour les caches de fichiers
|
/// Trait générique pour les caches de fichiers
|
||||||
///
|
///
|
||||||
@@ -145,6 +145,29 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
|
|||||||
/// Ceci permet le progressive caching: les fichiers en cours de download sont acceptés
|
/// Ceci permet le progressive caching: les fichiers en cours de download sont acceptés
|
||||||
/// dès que le prebuffer est atteint, sans attendre le marker de completion.
|
/// dès que le prebuffer est atteint, sans attendre le marker de completion.
|
||||||
async fn is_valid_pk(&self, pk: &str) -> bool {
|
async fn is_valid_pk(&self, pk: &str) -> bool {
|
||||||
|
if is_lazy_pk(pk) {
|
||||||
|
match self.get_database().has_lazy_entry(pk) {
|
||||||
|
Ok(true) => {
|
||||||
|
tracing::debug!(
|
||||||
|
"is_valid_pk({}): Lazy entry present in DB, download deferred",
|
||||||
|
pk
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"is_valid_pk({}): Lazy pk not registered in DB, rejecting",
|
||||||
|
pk
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("is_valid_pk({}): Error while checking lazy pk: {}", pk, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if self.get_database().get(pk, false).is_err() {
|
if self.get_database().get(pk, false).is_err() {
|
||||||
tracing::debug!("is_valid_pk({}): DB entry not found", pk);
|
tracing::debug!("is_valid_pk({}): DB entry not found", pk);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -765,11 +765,11 @@ impl DB {
|
|||||||
// LAZY PK SUPPORT
|
// LAZY PK SUPPORT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Ajoute une entrée en mode lazy (pk = NULL, lazy_pk rempli)
|
/// Ajoute une entrée en mode lazy (pk = lazy_pk tant que non téléchargé)
|
||||||
///
|
///
|
||||||
/// Utilisé pour créer des entries sans télécharger le fichier.
|
/// Utilisé pour créer des entries sans télécharger le fichier.
|
||||||
/// Le lazy_pk est calculé à partir de l'URL, le real pk sera calculé
|
/// Le lazy_pk est calculé à partir de l'URL et sert temporairement
|
||||||
/// lors du téléchargement effectif.
|
/// également de pk pour satisfaire les contraintes de clé étrangère.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
@@ -784,12 +784,9 @@ impl DB {
|
|||||||
) -> rusqlite::Result<()> {
|
) -> rusqlite::Result<()> {
|
||||||
let conn = self.lock_conn("add_lazy");
|
let conn = self.lock_conn("add_lazy");
|
||||||
|
|
||||||
// Créer une entry avec pk = NULL et lazy_pk rempli
|
|
||||||
// On utilise une astuce: insérer avec lazy_pk comme clé temporaire
|
|
||||||
// puis mettre pk à NULL
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO asset (pk, lazy_pk, id, collection, hits, last_used)
|
"INSERT INTO asset (pk, lazy_pk, id, collection, hits, last_used)
|
||||||
VALUES (NULL, ?1, ?2, ?3, 0, ?4)",
|
VALUES (?1, ?1, ?2, ?3, 0, ?4)",
|
||||||
params![lazy_pk, id, collection, Utc::now().to_rfc3339()],
|
params![lazy_pk, id, collection, Utc::now().to_rfc3339()],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -805,16 +802,46 @@ impl DB {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// * `Ok(Some(pk))` - Le real pk si le fichier a été téléchargé
|
/// * `Ok(Some(pk))` - Le real pk si le fichier a été téléchargé
|
||||||
/// * `Ok(None)` - Pas encore téléchargé (pk = NULL) ou lazy_pk inconnu
|
/// * `Ok(None)` - Pas encore téléchargé ou lazy_pk inconnu
|
||||||
pub fn get_pk_by_lazy_pk(&self, lazy_pk: &str) -> rusqlite::Result<Option<String>> {
|
pub fn get_pk_by_lazy_pk(&self, lazy_pk: &str) -> rusqlite::Result<Option<String>> {
|
||||||
let conn = self.lock_conn("get_pk_by_lazy_pk");
|
let conn = self.lock_conn("get_pk_by_lazy_pk");
|
||||||
|
|
||||||
conn.query_row(
|
let result: Option<(String, Option<String>)> = conn
|
||||||
"SELECT pk FROM asset WHERE lazy_pk = ?1",
|
.query_row(
|
||||||
[lazy_pk],
|
"SELECT pk, lazy_pk FROM asset WHERE lazy_pk = ?1",
|
||||||
|row| row.get(0),
|
[lazy_pk],
|
||||||
)
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
.optional()
|
)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
Ok(result.and_then(|(pk, lazy)| {
|
||||||
|
if let Some(lazy_pk_value) = lazy {
|
||||||
|
if pk == lazy_pk_value {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(pk)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(pk)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie l'existence d'une entrée lazy sans télécharger le fichier
|
||||||
|
///
|
||||||
|
/// Retourne `true` si une ligne avec `lazy_pk` existe, même si `pk` est `NULL`.
|
||||||
|
pub fn has_lazy_entry(&self, lazy_pk: &str) -> rusqlite::Result<bool> {
|
||||||
|
let conn = self.lock_conn("has_lazy_entry");
|
||||||
|
|
||||||
|
let exists: Option<i64> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM asset WHERE lazy_pk = ?1 LIMIT 1",
|
||||||
|
[lazy_pk],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
Ok(exists.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transition d'une entry lazy vers downloaded (ajoute le real pk)
|
/// Transition d'une entry lazy vers downloaded (ajoute le real pk)
|
||||||
@@ -827,43 +854,51 @@ impl DB {
|
|||||||
///
|
///
|
||||||
/// * `lazy_pk` - Le lazy PK de l'entry originale
|
/// * `lazy_pk` - Le lazy PK de l'entry originale
|
||||||
/// * `real_pk` - Le real PK calculé après téléchargement
|
/// * `real_pk` - Le real PK calculé après téléchargement
|
||||||
pub fn update_lazy_to_downloaded(
|
pub fn update_lazy_to_downloaded(&self, lazy_pk: &str, real_pk: &str) -> rusqlite::Result<()> {
|
||||||
&self,
|
|
||||||
lazy_pk: &str,
|
|
||||||
real_pk: &str,
|
|
||||||
) -> rusqlite::Result<()> {
|
|
||||||
let mut conn = self.lock_conn("update_lazy_to_downloaded");
|
let mut conn = self.lock_conn("update_lazy_to_downloaded");
|
||||||
|
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
|
|
||||||
// 1. Récupérer les infos de l'entry lazy
|
// 1. Récupérer l'entry lazy (pk = lazy_pk tant que pas téléchargé)
|
||||||
let (collection, id): (Option<String>, Option<String>) = tx
|
let (old_pk, collection, id, hits): (String, Option<String>, Option<String>, i32) = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT collection, id FROM asset WHERE lazy_pk = ?1 AND pk IS NULL",
|
"SELECT pk, collection, id, hits FROM asset WHERE lazy_pk = ?1",
|
||||||
[lazy_pk],
|
[lazy_pk],
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||||
)
|
)
|
||||||
.optional()?
|
.optional()?
|
||||||
.ok_or_else(|| Error::QueryReturnedNoRows)?;
|
.ok_or_else(|| Error::QueryReturnedNoRows)?;
|
||||||
|
|
||||||
// 2. Supprimer l'entry lazy (pk = NULL)
|
if old_pk == real_pk {
|
||||||
tx.execute(
|
// Rien à faire si déjà commuté
|
||||||
"DELETE FROM asset WHERE lazy_pk = ?1 AND pk IS NULL",
|
return Ok(());
|
||||||
[lazy_pk],
|
}
|
||||||
)?;
|
|
||||||
|
|
||||||
// 3. Créer nouvelle entry avec pk rempli ET lazy_pk
|
let now = Utc::now().to_rfc3339();
|
||||||
// Si le real_pk existe déjà (téléchargé via eager mode), on met juste à jour
|
let hits_to_add = if hits > 0 { hits } else { 1 };
|
||||||
|
|
||||||
|
// 2. Créer/mettre à jour l'entry avec le real pk
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO asset (pk, lazy_pk, collection, id, hits, last_used)
|
"INSERT INTO asset (pk, lazy_pk, collection, id, hits, last_used)
|
||||||
VALUES (?1, ?2, ?3, ?4, 1, ?5)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
ON CONFLICT(pk) DO UPDATE SET
|
ON CONFLICT(pk) DO UPDATE SET
|
||||||
lazy_pk = excluded.lazy_pk,
|
lazy_pk = excluded.lazy_pk,
|
||||||
last_used = excluded.last_used,
|
collection = COALESCE(excluded.collection, collection),
|
||||||
hits = hits + 1",
|
id = COALESCE(excluded.id, id),
|
||||||
params![real_pk, lazy_pk, collection, id, Utc::now().to_rfc3339()],
|
hits = hits + excluded.hits,
|
||||||
|
last_used = excluded.last_used",
|
||||||
|
params![real_pk, lazy_pk, collection, id, hits_to_add, now],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// 3. Re-pointer les métadonnées vers le real pk
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE metadata SET pk = ?1 WHERE pk = ?2",
|
||||||
|
params![real_pk, old_pk],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 4. Supprimer l'ancienne entry lazy
|
||||||
|
tx.execute("DELETE FROM asset WHERE pk = ?1", [old_pk])?;
|
||||||
|
|
||||||
tx.commit()
|
tx.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,11 +925,11 @@ impl DB {
|
|||||||
|
|
||||||
// Chercher via origin_url dans metadata
|
// Chercher via origin_url dans metadata
|
||||||
// On joint avec asset pour récupérer pk et lazy_pk
|
// On joint avec asset pour récupérer pk et lazy_pk
|
||||||
let result: Option<(Option<String>, Option<String>)> = conn
|
let raw: Option<(String, Option<String>)> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT a.pk, a.lazy_pk
|
"SELECT a.pk, a.lazy_pk
|
||||||
FROM asset a
|
FROM asset a
|
||||||
JOIN metadata m ON (a.pk = m.pk OR a.lazy_pk = m.pk)
|
JOIN metadata m ON a.pk = m.pk
|
||||||
WHERE m.key = 'origin_url' AND m.value = ?1
|
WHERE m.key = 'origin_url' AND m.value = ?1
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
[url],
|
[url],
|
||||||
@@ -902,6 +937,18 @@ impl DB {
|
|||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
|
||||||
|
let result = raw.map(|(pk, lazy_pk)| {
|
||||||
|
if let Some(ref lazy) = lazy_pk {
|
||||||
|
if pk == *lazy {
|
||||||
|
(None, Some(lazy.clone()))
|
||||||
|
} else {
|
||||||
|
(Some(pk), lazy_pk)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(Some(pk), lazy_pk)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,13 +979,7 @@ impl DB {
|
|||||||
///
|
///
|
||||||
/// * `lazy_pk` - Le lazy PK de l'entry
|
/// * `lazy_pk` - Le lazy PK de l'entry
|
||||||
/// * `origin_url` - L'URL d'origine à stocker
|
/// * `origin_url` - L'URL d'origine à stocker
|
||||||
pub fn set_origin_url_for_lazy(
|
pub fn set_origin_url_for_lazy(&self, lazy_pk: &str, origin_url: &str) -> rusqlite::Result<()> {
|
||||||
&self,
|
|
||||||
lazy_pk: &str,
|
|
||||||
origin_url: &str,
|
|
||||||
) -> rusqlite::Result<()> {
|
|
||||||
// Pour les entries lazy, on stocke l'origin_url avec lazy_pk comme clé
|
|
||||||
// dans la table metadata (au lieu de pk qui est NULL)
|
|
||||||
self.set_a_metadata_by_key(lazy_pk, "origin_url", Value::String(origin_url.to_owned()))
|
self.set_a_metadata_by_key(lazy_pk, "origin_url", Value::String(origin_url.to_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,12 @@ async fn serve_lazy_audio_file<C: CacheConfig>(
|
|||||||
format!("/cache/{}/{}/{}", C::cache_type(), real_pk, param)
|
format!("/cache/{}/{}/{}", C::cache_type(), real_pk, param)
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::debug!("Lazy PK {} downloaded as {}, redirecting to {}", lazy_pk, real_pk, redirect_url);
|
tracing::debug!(
|
||||||
|
"Lazy PK {} downloaded as {}, redirecting to {}",
|
||||||
|
lazy_pk,
|
||||||
|
real_pk,
|
||||||
|
redirect_url
|
||||||
|
);
|
||||||
|
|
||||||
Redirect::temporary(&redirect_url).into_response()
|
Redirect::temporary(&redirect_url).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,5 @@ fn print_usage() {
|
|||||||
println!(" cargo run --example encrypt_password -- test");
|
println!(" cargo run --example encrypt_password -- test");
|
||||||
println!("\nExamples:");
|
println!("\nExamples:");
|
||||||
println!(" cargo run --example encrypt_password -- encrypt \"MySecretPassword\"");
|
println!(" cargo run --example encrypt_password -- encrypt \"MySecretPassword\"");
|
||||||
println!(
|
println!(" cargo run --example encrypt_password -- decrypt \"encrypted:SGVsbG8gV29ybGQh...\"");
|
||||||
" cargo run --example encrypt_password -- decrypt \"encrypted:SGVsbG8gV29ybGQh...\""
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ fn derive_key() -> Result<[u8; 32]> {
|
|||||||
/// ```
|
/// ```
|
||||||
pub fn encrypt_password(password: &str) -> Result<String> {
|
pub fn encrypt_password(password: &str) -> Result<String> {
|
||||||
let key = derive_key()?;
|
let key = derive_key()?;
|
||||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
let cipher =
|
||||||
.map_err(|e| anyhow!("Failed to create cipher: {}", e))?;
|
Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("Failed to create cipher: {}", e))?;
|
||||||
|
|
||||||
// Nonce de 96 bits (12 bytes) - dérivé du mot de passe pour avoir
|
// Nonce de 96 bits (12 bytes) - dérivé du mot de passe pour avoir
|
||||||
// un chiffrement déterministe (même password = même ciphertext)
|
// un chiffrement déterministe (même password = même ciphertext)
|
||||||
@@ -173,8 +173,8 @@ pub fn decrypt_password(encrypted: &str) -> Result<String> {
|
|||||||
.ok_or_else(|| anyhow!("Invalid encrypted password format (missing prefix)"))?;
|
.ok_or_else(|| anyhow!("Invalid encrypted password format (missing prefix)"))?;
|
||||||
|
|
||||||
let key = derive_key()?;
|
let key = derive_key()?;
|
||||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
let cipher =
|
||||||
.map_err(|e| anyhow!("Failed to create cipher: {}", e))?;
|
Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("Failed to create cipher: {}", e))?;
|
||||||
|
|
||||||
let ciphertext = base64::engine::general_purpose::STANDARD
|
let ciphertext = base64::engine::general_purpose::STANDARD
|
||||||
.decode(base64_data)
|
.decode(base64_data)
|
||||||
|
|||||||
@@ -433,9 +433,7 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
for (idx, item) in self.queue_snapshot.iter().enumerate() {
|
for (idx, item) in self.queue_snapshot.iter().enumerate() {
|
||||||
let meta = item.metadata.as_ref();
|
let meta = item.metadata.as_ref();
|
||||||
let title = meta
|
let title = meta.and_then(|m| m.title.as_deref()).unwrap_or("<titre>");
|
||||||
.and_then(|m| m.title.as_deref())
|
|
||||||
.unwrap_or("<titre>");
|
|
||||||
let artist = meta.and_then(|m| m.artist.as_deref()).unwrap_or("");
|
let artist = meta.and_then(|m| m.artist.as_deref()).unwrap_or("");
|
||||||
let prefix = match self.queue_current_index {
|
let prefix = match self.queue_current_index {
|
||||||
Some(current) if current == idx => "▶",
|
Some(current) if current == idx => "▶",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
use std::io::{self, BufRead, BufReader, Read, Write};
|
use std::io::{self, BufRead, BufReader, Read, Write};
|
||||||
use std::net::{IpAddr, TcpListener, TcpStream, UdpSocket};
|
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
|
use std::net::{IpAddr, TcpListener, TcpStream, UdpSocket};
|
||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
use std::sync::{Arc, Mutex, MutexGuard, RwLock};
|
use std::sync::{Arc, Mutex, MutexGuard, RwLock};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
@@ -10,9 +10,9 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use anyhow::{Context, anyhow};
|
use anyhow::{Context, anyhow};
|
||||||
use crossbeam_channel::{Receiver, Sender, unbounded};
|
use crossbeam_channel::{Receiver, Sender, unbounded};
|
||||||
use quick_xml::se::to_string as to_didl_string;
|
|
||||||
use pmodidl::{DIDLLite, Item as DidlItem, Resource as DidlResource};
|
use pmodidl::{DIDLLite, Item as DidlItem, Resource as DidlResource};
|
||||||
use pmoupnp::ssdp::SsdpClient;
|
use pmoupnp::ssdp::SsdpClient;
|
||||||
|
use quick_xml::se::to_string as to_didl_string;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use ureq::{Agent, http};
|
use ureq::{Agent, http};
|
||||||
@@ -21,23 +21,22 @@ use xmltree::{Element, XMLNode};
|
|||||||
pub mod music_queue;
|
pub mod music_queue;
|
||||||
pub mod openhome_queue;
|
pub mod openhome_queue;
|
||||||
|
|
||||||
use crate::control_point::music_queue::MusicQueue;
|
|
||||||
use crate::control_point::openhome_queue::OpenHomeQueue;
|
|
||||||
use crate::music_renderer::{
|
|
||||||
OpenHomeQueueProvider, RendererRuntimeState, set_openhome_queue_provider,
|
|
||||||
};
|
|
||||||
use crate::queue_interne::InternalQueue;
|
|
||||||
use crate::MusicRenderer;
|
use crate::MusicRenderer;
|
||||||
use crate::capabilities::{
|
use crate::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||||
VolumeControl,
|
VolumeControl,
|
||||||
};
|
};
|
||||||
|
use crate::control_point::music_queue::MusicQueue;
|
||||||
|
use crate::control_point::openhome_queue::OpenHomeQueue;
|
||||||
use crate::discovery::DiscoveryManager;
|
use crate::discovery::DiscoveryManager;
|
||||||
use crate::events::{MediaServerEventBus, RendererEventBus};
|
use crate::events::{MediaServerEventBus, RendererEventBus};
|
||||||
use crate::media_server::{MediaBrowser, MediaEntry, MediaServerInfo, MusicServer, ServerId};
|
use crate::media_server::{MediaBrowser, MediaEntry, MediaServerInfo, MusicServer, ServerId};
|
||||||
use crate::media_server_events::spawn_media_server_event_runtime;
|
use crate::media_server_events::spawn_media_server_event_runtime;
|
||||||
use crate::model::TrackMetadata;
|
use crate::model::TrackMetadata;
|
||||||
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo};
|
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo};
|
||||||
|
use crate::music_renderer::{
|
||||||
|
OpenHomeQueueProvider, RendererRuntimeState, set_openhome_queue_provider,
|
||||||
|
};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::openapi::{
|
use crate::openapi::{
|
||||||
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
|
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
|
||||||
@@ -46,8 +45,9 @@ use crate::openapi::{
|
|||||||
use crate::openhome_client::{OhInfoClient, OhPlaylistClient, parse_track_metadata_from_didl};
|
use crate::openhome_client::{OhInfoClient, OhPlaylistClient, parse_track_metadata_from_didl};
|
||||||
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
use crate::openhome_renderer::{format_seconds, map_openhome_state};
|
use crate::openhome_renderer::{format_seconds, map_openhome_state};
|
||||||
use crate::queue_backend::{EnqueueMode, PlaybackItem, QueueBackend};
|
|
||||||
use crate::provider::HttpXmlDescriptionProvider;
|
use crate::provider::HttpXmlDescriptionProvider;
|
||||||
|
use crate::queue_backend::{EnqueueMode, PlaybackItem, QueueBackend};
|
||||||
|
use crate::queue_interne::InternalQueue;
|
||||||
use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
||||||
use crate::upnp_renderer::UpnpRenderer;
|
use crate::upnp_renderer::UpnpRenderer;
|
||||||
|
|
||||||
@@ -212,12 +212,10 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlaylistBackend::PMOQueue => {
|
PlaylistBackend::PMOQueue => {
|
||||||
runtime_cp
|
runtime_cp.runtime.set_music_queue(
|
||||||
.runtime
|
&info.id,
|
||||||
.set_music_queue(
|
MusicQueue::Internal(InternalQueue::new()),
|
||||||
&info.id,
|
);
|
||||||
MusicQueue::Internal(InternalQueue::new()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if matches!(backend, PlaylistBackend::OpenHome) {
|
if matches!(backend, PlaylistBackend::OpenHome) {
|
||||||
@@ -842,10 +840,7 @@ impl ControlPoint {
|
|||||||
title: item.metadata.as_ref().and_then(|m| m.title.clone()),
|
title: item.metadata.as_ref().and_then(|m| m.title.clone()),
|
||||||
artist: item.metadata.as_ref().and_then(|m| m.artist.clone()),
|
artist: item.metadata.as_ref().and_then(|m| m.artist.clone()),
|
||||||
album: item.metadata.as_ref().and_then(|m| m.album.clone()),
|
album: item.metadata.as_ref().and_then(|m| m.album.clone()),
|
||||||
album_art_uri: item
|
album_art_uri: item.metadata.as_ref().and_then(|m| m.album_art_uri.clone()),
|
||||||
.metadata
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|m| m.album_art_uri.clone()),
|
|
||||||
server_id: Some(item.media_server_id.0.clone()),
|
server_id: Some(item.media_server_id.0.clone()),
|
||||||
object_id: Some(item.didl_id.clone()),
|
object_id: Some(item.didl_id.clone()),
|
||||||
})
|
})
|
||||||
@@ -1741,10 +1736,7 @@ impl RuntimeState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn renderer_state_mut(
|
fn renderer_state_mut(&self, id: &RendererId) -> anyhow::Result<RendererRuntimeStateMut<'_>> {
|
||||||
&self,
|
|
||||||
id: &RendererId,
|
|
||||||
) -> anyhow::Result<RendererRuntimeStateMut<'_>> {
|
|
||||||
let mut entries = self.entries.lock().unwrap();
|
let mut entries = self.entries.lock().unwrap();
|
||||||
let queue_ptr = {
|
let queue_ptr = {
|
||||||
let entry = entries
|
let entry = entries
|
||||||
@@ -1997,8 +1989,7 @@ fn refresh_attached_queue_for(
|
|||||||
container = container_id.as_str(),
|
container = container_id.as_str(),
|
||||||
"Refreshed playlist is empty, clearing queue"
|
"Refreshed playlist is empty, clearing queue"
|
||||||
);
|
);
|
||||||
runtime
|
runtime.with_music_queue_mut(renderer_id, |queue| queue.clear_queue())?;
|
||||||
.with_music_queue_mut(renderer_id, |queue| queue.clear_queue())?;
|
|
||||||
|
|
||||||
// Emit QueueUpdated event
|
// Emit QueueUpdated event
|
||||||
event_bus.broadcast(RendererEvent::QueueUpdated {
|
event_bus.broadcast(RendererEvent::QueueUpdated {
|
||||||
@@ -2023,54 +2014,57 @@ fn refresh_attached_queue_for(
|
|||||||
new_items
|
new_items
|
||||||
.iter()
|
.iter()
|
||||||
.position(|new_item| new_item.unique_id() == current_uid)
|
.position(|new_item| new_item.unique_id() == current_uid)
|
||||||
.or_else(|| new_items.iter().position(|new_item| new_item.uri == current.uri))
|
.or_else(|| {
|
||||||
|
new_items
|
||||||
|
.iter()
|
||||||
|
.position(|new_item| new_item.uri == current.uri)
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
let final_queue_len = runtime
|
let final_queue_len = runtime.with_music_queue_mut(renderer_id, |queue| {
|
||||||
.with_music_queue_mut(renderer_id, |queue| {
|
if let Some(idx) = item_found_at {
|
||||||
if let Some(idx) = item_found_at {
|
queue.replace_queue(new_items.clone(), Some(idx))?;
|
||||||
queue.replace_queue(new_items.clone(), Some(idx))?;
|
info!(
|
||||||
info!(
|
renderer = renderer_id.0.as_str(),
|
||||||
renderer = renderer_id.0.as_str(),
|
server = server_id.0.as_str(),
|
||||||
server = server_id.0.as_str(),
|
container = container_id.as_str(),
|
||||||
container = container_id.as_str(),
|
total_items = new_items.len(),
|
||||||
total_items = new_items.len(),
|
current_index = idx,
|
||||||
current_index = idx,
|
upcoming = new_items.len().saturating_sub(idx + 1),
|
||||||
upcoming = new_items.len().saturating_sub(idx + 1),
|
current_preserved = true,
|
||||||
current_preserved = true,
|
"Refreshed queue from playlist container"
|
||||||
"Refreshed queue from playlist container"
|
);
|
||||||
);
|
Ok(new_items.len())
|
||||||
Ok(new_items.len())
|
} else if let Some(ref current) = current_item {
|
||||||
} else if let Some(ref current) = current_item {
|
let mut combined = Vec::with_capacity(new_items.len() + 1);
|
||||||
let mut combined = Vec::with_capacity(new_items.len() + 1);
|
combined.push(current.clone());
|
||||||
combined.push(current.clone());
|
combined.extend(new_items.clone());
|
||||||
combined.extend(new_items.clone());
|
queue.replace_queue(combined, Some(0))?;
|
||||||
queue.replace_queue(combined, Some(0))?;
|
info!(
|
||||||
info!(
|
renderer = renderer_id.0.as_str(),
|
||||||
renderer = renderer_id.0.as_str(),
|
server = server_id.0.as_str(),
|
||||||
server = server_id.0.as_str(),
|
container = container_id.as_str(),
|
||||||
container = container_id.as_str(),
|
total_items = new_items.len() + 1,
|
||||||
total_items = new_items.len() + 1,
|
current_index = 0,
|
||||||
current_index = 0,
|
upcoming = new_items.len(),
|
||||||
upcoming = new_items.len(),
|
current_preserved = true,
|
||||||
current_preserved = true,
|
current_reinserted = true,
|
||||||
current_reinserted = true,
|
"Refreshed queue from playlist container (current item reinserted at start)"
|
||||||
"Refreshed queue from playlist container (current item reinserted at start)"
|
);
|
||||||
);
|
Ok(new_items.len() + 1)
|
||||||
Ok(new_items.len() + 1)
|
} else {
|
||||||
} else {
|
queue.replace_queue(new_items.clone(), None)?;
|
||||||
queue.replace_queue(new_items.clone(), None)?;
|
info!(
|
||||||
info!(
|
renderer = renderer_id.0.as_str(),
|
||||||
renderer = renderer_id.0.as_str(),
|
server = server_id.0.as_str(),
|
||||||
server = server_id.0.as_str(),
|
container = container_id.as_str(),
|
||||||
container = container_id.as_str(),
|
total_items = new_items.len(),
|
||||||
total_items = new_items.len(),
|
current_preserved = false,
|
||||||
current_preserved = false,
|
"Refreshed queue from playlist container (no current item)"
|
||||||
"Refreshed queue from playlist container (no current item)"
|
);
|
||||||
);
|
Ok(new_items.len())
|
||||||
Ok(new_items.len())
|
}
|
||||||
}
|
})?;
|
||||||
})?;
|
|
||||||
|
|
||||||
// Emit QueueUpdated event
|
// Emit QueueUpdated event
|
||||||
event_bus.broadcast(RendererEvent::QueueUpdated {
|
event_bus.broadcast(RendererEvent::QueueUpdated {
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ impl OpenHomeQueue {
|
|||||||
.info_client
|
.info_client
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|client| client.id().ok());
|
.and_then(|client| client.id().ok());
|
||||||
let current_index = current_id
|
let current_index =
|
||||||
.and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id));
|
current_id.and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id));
|
||||||
|
|
||||||
self.items = items;
|
self.items = items;
|
||||||
self.track_ids = track_ids;
|
self.track_ids = track_ids;
|
||||||
@@ -76,10 +76,7 @@ impl OpenHomeQueue {
|
|||||||
title: item.metadata.as_ref().and_then(|m| m.title.clone()),
|
title: item.metadata.as_ref().and_then(|m| m.title.clone()),
|
||||||
artist: item.metadata.as_ref().and_then(|m| m.artist.clone()),
|
artist: item.metadata.as_ref().and_then(|m| m.artist.clone()),
|
||||||
album: item.metadata.as_ref().and_then(|m| m.album.clone()),
|
album: item.metadata.as_ref().and_then(|m| m.album.clone()),
|
||||||
album_art_uri: item
|
album_art_uri: item.metadata.as_ref().and_then(|m| m.album_art_uri.clone()),
|
||||||
.metadata
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|m| m.album_art_uri.clone()),
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -151,7 +148,10 @@ impl OpenHomeQueue {
|
|||||||
if id == 0 {
|
if id == 0 {
|
||||||
Some(0)
|
Some(0)
|
||||||
} else {
|
} else {
|
||||||
self.track_ids.iter().position(|tid| *tid == id).map(|pos| pos + 1)
|
self.track_ids
|
||||||
|
.iter()
|
||||||
|
.position(|tid| *tid == id)
|
||||||
|
.map(|pos| pos + 1)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| self.track_ids.len());
|
.unwrap_or_else(|| self.track_ids.len());
|
||||||
@@ -166,13 +166,8 @@ impl OpenHomeQueue {
|
|||||||
self.current_index = if play {
|
self.current_index = if play {
|
||||||
Some(insert_index)
|
Some(insert_index)
|
||||||
} else {
|
} else {
|
||||||
self.current_index.map(|idx| {
|
self.current_index
|
||||||
if insert_index <= idx {
|
.map(|idx| if insert_index <= idx { idx + 1 } else { idx })
|
||||||
idx + 1
|
|
||||||
} else {
|
|
||||||
idx
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(new_id)
|
Ok(new_id)
|
||||||
@@ -213,11 +208,7 @@ pub fn didl_id_from_metadata(xml: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let parsed = pmodidl::parse_metadata::<DIDLLite>(xml).ok()?;
|
let parsed = pmodidl::parse_metadata::<DIDLLite>(xml).ok()?;
|
||||||
parsed
|
parsed.data.items.first().map(|item| item.id.clone())
|
||||||
.data
|
|
||||||
.items
|
|
||||||
.first()
|
|
||||||
.map(|item| item.id.clone())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_metadata_xml(item: &PlaybackItem) -> String {
|
fn build_metadata_xml(item: &PlaybackItem) -> String {
|
||||||
@@ -255,10 +246,7 @@ fn build_metadata_xml(item: &PlaybackItem) -> String {
|
|||||||
}
|
}
|
||||||
if let Some(uri) = meta.album_art_uri.as_deref() {
|
if let Some(uri) = meta.album_art_uri.as_deref() {
|
||||||
let escaped = escape(uri);
|
let escaped = escape(uri);
|
||||||
xml.push_str(&format!(
|
xml.push_str(&format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", escaped));
|
||||||
"<upnp:albumArtURI>{}</upnp:albumArtURI>",
|
|
||||||
escaped
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if let Some(date) = meta.date.as_deref() {
|
if let Some(date) = meta.date.as_deref() {
|
||||||
let escaped = escape(date);
|
let escaped = escape(date);
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ pub mod music_renderer;
|
|||||||
pub mod openhome_client;
|
pub mod openhome_client;
|
||||||
pub mod openhome_playlist;
|
pub mod openhome_playlist;
|
||||||
pub mod openhome_renderer;
|
pub mod openhome_renderer;
|
||||||
|
pub mod provider;
|
||||||
pub mod queue_backend;
|
pub mod queue_backend;
|
||||||
pub mod queue_interne;
|
pub mod queue_interne;
|
||||||
pub mod provider;
|
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod rendering_control_client;
|
pub mod rendering_control_client;
|
||||||
pub mod soap_client;
|
pub mod soap_client;
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
use std::sync::{Arc, OnceLock, RwLock};
|
use std::sync::{Arc, OnceLock, RwLock};
|
||||||
|
|
||||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
||||||
|
use crate::control_point::RendererRuntimeStateMut;
|
||||||
use crate::control_point::music_queue::MusicQueue;
|
use crate::control_point::music_queue::MusicQueue;
|
||||||
use crate::control_point::openhome_queue::didl_id_from_metadata;
|
use crate::control_point::openhome_queue::didl_id_from_metadata;
|
||||||
use crate::control_point::RendererRuntimeStateMut;
|
|
||||||
use crate::media_server::ServerId;
|
use crate::media_server::ServerId;
|
||||||
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
||||||
use crate::openhome_client::parse_track_metadata_from_didl;
|
use crate::openhome_client::parse_track_metadata_from_didl;
|
||||||
@@ -67,9 +67,7 @@ pub trait OpenHomeQueueProvider: Send + Sync + 'static {
|
|||||||
|
|
||||||
static OPENHOME_QUEUE_PROVIDER: OnceLock<Arc<dyn OpenHomeQueueProvider>> = OnceLock::new();
|
static OPENHOME_QUEUE_PROVIDER: OnceLock<Arc<dyn OpenHomeQueueProvider>> = OnceLock::new();
|
||||||
|
|
||||||
pub fn set_openhome_queue_provider(
|
pub fn set_openhome_queue_provider(provider: Arc<dyn OpenHomeQueueProvider>) {
|
||||||
provider: Arc<dyn OpenHomeQueueProvider>,
|
|
||||||
) {
|
|
||||||
let _ = OPENHOME_QUEUE_PROVIDER.set(provider);
|
let _ = OPENHOME_QUEUE_PROVIDER.set(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,11 +238,7 @@ impl MusicRenderer {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let snapshot = self.fetch_openhome_playlist_snapshot()?;
|
let snapshot = self.fetch_openhome_playlist_snapshot()?;
|
||||||
Ok(snapshot
|
Ok(snapshot.tracks.into_iter().map(|track| track.id).collect())
|
||||||
.tracks
|
|
||||||
.into_iter()
|
|
||||||
.map(|track| track.id)
|
|
||||||
.collect())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -317,9 +317,7 @@ pub trait QueueBackend {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
for (offset, it) in items.into_iter().enumerate() {
|
for (offset, it) in items.into_iter().enumerate() {
|
||||||
snapshot
|
snapshot.items.insert(insert_pos + offset, it);
|
||||||
.items
|
|
||||||
.insert(insert_pos + offset, it);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EnqueueMode::ReplaceAll => {
|
EnqueueMode::ReplaceAll => {
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ impl InternalQueue {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
Self { items, current_index }
|
Self {
|
||||||
|
items,
|
||||||
|
current_index,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exposes a read-only view of the underlying items.
|
/// Exposes a read-only view of the underlying items.
|
||||||
|
|||||||
972
pmodidl_026.txt
972
pmodidl_026.txt
@@ -1,972 +0,0 @@
|
|||||||
=============== pmodidl/Cargo.toml ============
|
|
||||||
[package]
|
|
||||||
name = "pmodidl"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde = "1.0.228"
|
|
||||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
|
||||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
|
||||||
quick-xml = { version = "0.38.3", features = ["serialize"] }
|
|
||||||
bevy_reflect = "0.17.1"
|
|
||||||
bevy_reflect_derive = "0.17.1"
|
|
||||||
pmoutils = { path = "../pmoutils" }
|
|
||||||
xmltree = "0.10"
|
|
||||||
========= End of pmodidl/Cargo.toml ===========
|
|
||||||
|
|
||||||
=============== pmodidl/examples/test_serialization.rs ============
|
|
||||||
use pmodidl::{DIDLLite, Item, Resource};
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let item1 = Item {
|
|
||||||
id: "test1".to_string(),
|
|
||||||
parent_id: "root".to_string(),
|
|
||||||
restricted: Some("1".to_string()),
|
|
||||||
title: "Test Song".to_string(),
|
|
||||||
creator: Some("Test Artist".to_string()),
|
|
||||||
class: "object.item.audioItem.musicTrack".to_string(),
|
|
||||||
artist: Some("Test Artist".to_string()),
|
|
||||||
album: None, // Pas d'album
|
|
||||||
genre: None,
|
|
||||||
album_art: None, // Pas d'albumArtURI
|
|
||||||
album_art_pk: None,
|
|
||||||
date: None,
|
|
||||||
original_track_number: None,
|
|
||||||
resources: vec![Resource {
|
|
||||||
protocol_info: "http-get:*:audio/flac:*".to_string(),
|
|
||||||
bits_per_sample: Some("16".to_string()),
|
|
||||||
sample_frequency: Some("44100".to_string()),
|
|
||||||
nr_audio_channels: Some("2".to_string()),
|
|
||||||
duration: Some("0:03:00".to_string()),
|
|
||||||
url: "http://example.com/test.flac".to_string(),
|
|
||||||
}],
|
|
||||||
descriptions: vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
let didl = DIDLLite {
|
|
||||||
xmlns: "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/".to_string(),
|
|
||||||
xmlns_upnp: Some("urn:schemas-upnp-org:metadata-1-0/upnp/".to_string()),
|
|
||||||
xmlns_dc: Some("http://purl.org/dc/elements/1.1/".to_string()),
|
|
||||||
xmlns_dlna: Some("urn:schemas-dlna-org:metadata-1-0/".to_string()),
|
|
||||||
xmlns_pv: None,
|
|
||||||
xmlns_sec: None,
|
|
||||||
containers: vec![],
|
|
||||||
items: vec![item1],
|
|
||||||
};
|
|
||||||
|
|
||||||
let xml = quick_xml::se::to_string(&didl).expect("Serialization failed");
|
|
||||||
|
|
||||||
println!("=== Output from quick_xml::se::to_string() ===");
|
|
||||||
println!("{}", xml);
|
|
||||||
println!("\n=== Length: {} bytes ===", xml.len());
|
|
||||||
println!(
|
|
||||||
"\n=== Starts with '<?xml' ? {} ===",
|
|
||||||
xml.starts_with("<?xml")
|
|
||||||
);
|
|
||||||
println!("\n=== With manual XML declaration ===");
|
|
||||||
let with_decl = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>{}", xml);
|
|
||||||
println!("{}", with_decl);
|
|
||||||
}
|
|
||||||
========= End of pmodidl/examples/test_serialization.rs ===========
|
|
||||||
|
|
||||||
=============== pmodidl/src/lib.rs ============
|
|
||||||
//! # pmodidl - DIDL-Lite Parser
|
|
||||||
//!
|
|
||||||
//! Parser et utilitaires pour le format DIDL-Lite utilisé dans UPnP/DLNA.
|
|
||||||
|
|
||||||
use bevy_reflect::Reflect;
|
|
||||||
use pmoutils::ToXmlElement;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::borrow::Cow;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::fmt::Write;
|
|
||||||
use std::io::Cursor;
|
|
||||||
use xmltree::{Element, XMLNode};
|
|
||||||
|
|
||||||
// ============= Couche d'abstraction générique =============
|
|
||||||
|
|
||||||
/// Trait pour tout parser de métadonnées média
|
|
||||||
pub trait MediaMetadataParser: Sized {
|
|
||||||
type Error: std::error::Error + Send + Sync + 'static;
|
|
||||||
|
|
||||||
/// Parse une chaîne de métadonnées
|
|
||||||
fn parse(input: &str) -> Result<Self, Self::Error>;
|
|
||||||
|
|
||||||
/// Retourne le format du parser
|
|
||||||
fn format_name() -> &'static str;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enveloppe générique pour tout type de métadonnées parsées
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Reflect)]
|
|
||||||
pub struct ParsedMetadata<T> {
|
|
||||||
/// Format du document (ex: "DIDL-Lite", "RSS", etc.)
|
|
||||||
pub format: String,
|
|
||||||
|
|
||||||
/// Données parsées
|
|
||||||
pub data: T,
|
|
||||||
|
|
||||||
/// Timestamp du parsing (exclu de la réflexion car SystemTime n'implémente pas Reflect)
|
|
||||||
#[reflect(ignore)]
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub parsed_at: Option<std::time::SystemTime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> ParsedMetadata<T> {
|
|
||||||
pub fn new(format: impl Into<String>, data: T) -> Self {
|
|
||||||
Self {
|
|
||||||
format: format.into(),
|
|
||||||
data,
|
|
||||||
parsed_at: Some(std::time::SystemTime::now()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transforme les données avec une fonction
|
|
||||||
pub fn map<U, F>(self, f: F) -> ParsedMetadata<U>
|
|
||||||
where
|
|
||||||
F: FnOnce(T) -> U,
|
|
||||||
{
|
|
||||||
ParsedMetadata {
|
|
||||||
format: self.format,
|
|
||||||
data: f(self.data),
|
|
||||||
parsed_at: self.parsed_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fonction helper pour parser et envelopper automatiquement
|
|
||||||
pub fn parse_metadata<P: MediaMetadataParser>(input: &str) -> Result<ParsedMetadata<P>, P::Error> {
|
|
||||||
let data = P::parse(input)?;
|
|
||||||
Ok(ParsedMetadata::new(P::format_name(), data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============= Implémentation pour DIDLLite =============
|
|
||||||
|
|
||||||
impl MediaMetadataParser for DIDLLite {
|
|
||||||
type Error = quick_xml::de::DeError;
|
|
||||||
|
|
||||||
fn parse(input: &str) -> Result<Self, Self::Error> {
|
|
||||||
let sanitized = sanitize_singleton_elements(input);
|
|
||||||
quick_xml::de::from_str(sanitized.as_ref())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_name() -> &'static str {
|
|
||||||
"DIDL-Lite"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Type alias pour faciliter l'utilisation
|
|
||||||
pub type DidlMetadata = ParsedMetadata<DIDLLite>;
|
|
||||||
|
|
||||||
// ============= Structures DIDL-Lite =============
|
|
||||||
|
|
||||||
/// Racine d'un document DIDL-Lite
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
|
||||||
#[serde(rename = "DIDL-Lite")]
|
|
||||||
pub struct DIDLLite {
|
|
||||||
#[serde(rename = "@xmlns")]
|
|
||||||
pub xmlns: String,
|
|
||||||
|
|
||||||
#[serde(rename = "@xmlns:upnp", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub xmlns_upnp: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "@xmlns:dc", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub xmlns_dc: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "@xmlns:dlna", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub xmlns_dlna: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "@xmlns:sec", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub xmlns_sec: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "@xmlns:pv", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub xmlns_pv: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "container", default)]
|
|
||||||
pub containers: Vec<Container>,
|
|
||||||
|
|
||||||
#[serde(rename = "item", default)]
|
|
||||||
pub items: Vec<Item>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Container pouvant contenir d'autres containers ou items
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
|
||||||
pub struct Container {
|
|
||||||
#[serde(rename = "@id")]
|
|
||||||
pub id: String,
|
|
||||||
|
|
||||||
#[serde(rename = "@parentID", default)]
|
|
||||||
pub parent_id: String,
|
|
||||||
|
|
||||||
#[serde(rename = "@restricted", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub restricted: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "@childCount", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub child_count: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "@searchable", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub searchable: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "dc:title", alias = "title")]
|
|
||||||
pub title: String,
|
|
||||||
|
|
||||||
#[serde(rename = "upnp:class", alias = "class", default)]
|
|
||||||
pub class: String,
|
|
||||||
|
|
||||||
#[serde(rename = "container", default)]
|
|
||||||
pub containers: Vec<Container>,
|
|
||||||
|
|
||||||
#[serde(rename = "item", default)]
|
|
||||||
pub items: Vec<Item>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Item représentant un objet audio
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
|
||||||
pub struct Item {
|
|
||||||
#[serde(rename = "@id")]
|
|
||||||
pub id: String,
|
|
||||||
|
|
||||||
#[serde(rename = "@parentID", default)]
|
|
||||||
pub parent_id: String,
|
|
||||||
|
|
||||||
#[serde(rename = "@restricted", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub restricted: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "dc:title", alias = "title")]
|
|
||||||
pub title: String,
|
|
||||||
|
|
||||||
#[serde(
|
|
||||||
rename = "dc:creator",
|
|
||||||
alias = "creator",
|
|
||||||
skip_serializing_if = "Option::is_none"
|
|
||||||
)]
|
|
||||||
pub creator: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "upnp:class", alias = "class", default)]
|
|
||||||
pub class: String,
|
|
||||||
|
|
||||||
#[serde(
|
|
||||||
rename = "upnp:artist",
|
|
||||||
alias = "artist",
|
|
||||||
skip_serializing_if = "Option::is_none"
|
|
||||||
)]
|
|
||||||
pub artist: Option<String>,
|
|
||||||
|
|
||||||
#[serde(
|
|
||||||
rename = "upnp:album",
|
|
||||||
alias = "album",
|
|
||||||
skip_serializing_if = "Option::is_none"
|
|
||||||
)]
|
|
||||||
pub album: Option<String>,
|
|
||||||
|
|
||||||
#[serde(
|
|
||||||
rename = "upnp:genre",
|
|
||||||
alias = "genre",
|
|
||||||
skip_serializing_if = "Option::is_none"
|
|
||||||
)]
|
|
||||||
pub genre: Option<String>,
|
|
||||||
|
|
||||||
#[serde(
|
|
||||||
rename = "upnp:albumArtURI",
|
|
||||||
alias = "albumArtURI",
|
|
||||||
skip_serializing_if = "Option::is_none"
|
|
||||||
)]
|
|
||||||
pub album_art: Option<String>,
|
|
||||||
|
|
||||||
#[serde(skip)]
|
|
||||||
pub album_art_pk: Option<String>,
|
|
||||||
|
|
||||||
#[serde(
|
|
||||||
rename = "dc:date",
|
|
||||||
alias = "date",
|
|
||||||
skip_serializing_if = "Option::is_none"
|
|
||||||
)]
|
|
||||||
pub date: Option<String>,
|
|
||||||
|
|
||||||
#[serde(
|
|
||||||
rename = "upnp:originalTrackNumber",
|
|
||||||
alias = "originalTrackNumber",
|
|
||||||
skip_serializing_if = "Option::is_none"
|
|
||||||
)]
|
|
||||||
pub original_track_number: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "res", default)]
|
|
||||||
pub resources: Vec<Resource>,
|
|
||||||
|
|
||||||
#[serde(rename = "desc", default)]
|
|
||||||
pub descriptions: Vec<Description>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ressource média (fichier audio)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
|
||||||
pub struct Resource {
|
|
||||||
#[serde(rename = "@protocolInfo", default)]
|
|
||||||
pub protocol_info: String,
|
|
||||||
|
|
||||||
#[serde(rename = "@bitsPerSample", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub bits_per_sample: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "@sampleFrequency", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub sample_frequency: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "@nrAudioChannels", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub nr_audio_channels: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "@duration", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub duration: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "$text", default)]
|
|
||||||
pub url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Description avec métadonnées additionnelles (replaygain, etc.)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
|
||||||
pub struct Description {
|
|
||||||
#[serde(rename = "@id", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub id: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "@nameSpace", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub namespace: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "track_gain", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub track_gain: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "track_peak", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub track_peak: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============= Implémentation des méthodes =============
|
|
||||||
|
|
||||||
impl Default for DIDLLite {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
xmlns: "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/".to_string(),
|
|
||||||
xmlns_upnp: Some("urn:schemas-upnp-org:metadata-1-0/upnp/".to_string()),
|
|
||||||
xmlns_dc: Some("http://purl.org/dc/elements/1.1/".to_string()),
|
|
||||||
xmlns_dlna: None,
|
|
||||||
xmlns_sec: None,
|
|
||||||
xmlns_pv: None,
|
|
||||||
containers: Vec::new(),
|
|
||||||
items: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DIDLLite {
|
|
||||||
/// Applique les namespaces sur un élément xmltree.
|
|
||||||
fn set_namespaces(&self, elem: &mut Element) {
|
|
||||||
elem.attributes.insert("xmlns".into(), self.xmlns.clone());
|
|
||||||
if let Some(ref upnp) = self.xmlns_upnp {
|
|
||||||
elem.attributes.insert("xmlns:upnp".into(), upnp.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref dc) = self.xmlns_dc {
|
|
||||||
elem.attributes.insert("xmlns:dc".into(), dc.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref dlna) = self.xmlns_dlna {
|
|
||||||
elem.attributes.insert("xmlns:dlna".into(), dlna.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref sec) = self.xmlns_sec {
|
|
||||||
elem.attributes.insert("xmlns:sec".into(), sec.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref pv) = self.xmlns_pv {
|
|
||||||
elem.attributes.insert("xmlns:pv".into(), pv.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Itère sur tous les containers de manière récursive
|
|
||||||
pub fn all_containers(&self) -> impl Iterator<Item = &Container> {
|
|
||||||
AllContainersIter::new(&self.containers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Itère sur tous les items de manière récursive
|
|
||||||
pub fn all_items(&self) -> impl Iterator<Item = &Item> {
|
|
||||||
AllItemsIter::new(&self.containers, &self.items)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trouve un container par ID
|
|
||||||
pub fn get_container_by_id(&self, id: &str) -> Option<&Container> {
|
|
||||||
self.all_containers().find(|c| c.id == id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trouve un item par ID
|
|
||||||
pub fn get_item_by_id(&self, id: &str) -> Option<&Item> {
|
|
||||||
self.all_items().find(|i| i.id == id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Filtre les containers
|
|
||||||
pub fn filter_containers<F>(&self, predicate: F) -> impl Iterator<Item = &Container>
|
|
||||||
where
|
|
||||||
F: Fn(&Container) -> bool,
|
|
||||||
{
|
|
||||||
self.all_containers().filter(move |c| predicate(c))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Filtre les items
|
|
||||||
pub fn filter_items<F>(&self, predicate: F) -> impl Iterator<Item = &Item>
|
|
||||||
where
|
|
||||||
F: Fn(&Item) -> bool,
|
|
||||||
{
|
|
||||||
self.all_items().filter(move |i| predicate(i))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Génère une représentation Markdown
|
|
||||||
pub fn to_markdown(&self) -> String {
|
|
||||||
let mut buf = String::new();
|
|
||||||
buf.push_str("### DIDL-Lite Document\n\n");
|
|
||||||
|
|
||||||
if !self.containers.is_empty() {
|
|
||||||
buf.push_str("#### Containers\n\n");
|
|
||||||
for container in &self.containers {
|
|
||||||
container.write_markdown(&mut buf, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.items.is_empty() {
|
|
||||||
buf.push_str("#### Items\n\n");
|
|
||||||
for item in &self.items {
|
|
||||||
item.write_markdown(&mut buf, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Container {
|
|
||||||
/// Itère sur tous les containers enfants récursivement
|
|
||||||
pub fn all_containers(&self) -> impl Iterator<Item = &Container> {
|
|
||||||
AllContainersIter::new(&self.containers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Itère sur tous les items de ce container et ses enfants
|
|
||||||
pub fn all_items(&self) -> impl Iterator<Item = &Item> {
|
|
||||||
AllItemsIter::new(&self.containers, &self.items)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_markdown(&self, buf: &mut String, depth: usize) {
|
|
||||||
let indent = " ".repeat(depth);
|
|
||||||
|
|
||||||
writeln!(buf, "{}- **Container**: {}", indent, self.title).unwrap();
|
|
||||||
writeln!(buf, "{} - ID: `{}`", indent, self.id).unwrap();
|
|
||||||
writeln!(buf, "{} - ParentID: `{}`", indent, self.parent_id).unwrap();
|
|
||||||
writeln!(buf, "{} - Class: `{}`", indent, self.class).unwrap();
|
|
||||||
|
|
||||||
if let Some(ref restricted) = self.restricted {
|
|
||||||
writeln!(buf, "{} - Restricted: `{}`", indent, restricted).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref count) = self.child_count {
|
|
||||||
writeln!(buf, "{} - ChildCount: `{}`", indent, count).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.containers.is_empty() {
|
|
||||||
writeln!(buf, "{} - Subcontainers:", indent).unwrap();
|
|
||||||
for sub in &self.containers {
|
|
||||||
sub.write_markdown(buf, depth + 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.items.is_empty() {
|
|
||||||
writeln!(buf, "{} - Items:", indent).unwrap();
|
|
||||||
for item in &self.items {
|
|
||||||
item.write_markdown(buf, depth + 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Item {
|
|
||||||
/// Formate la date pour satisfaire les clients stricts (YYYY-MM-DD). Si seule
|
|
||||||
/// l'année est fournie, on complète avec "-01-01".
|
|
||||||
fn normalized_date(&self) -> Option<String> {
|
|
||||||
self.date.as_ref().map(|d| {
|
|
||||||
let trimmed = d.trim();
|
|
||||||
if trimmed.len() == 4 && trimmed.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
format!("{}-01-01", trimmed)
|
|
||||||
} else {
|
|
||||||
trimmed.to_string()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Itère sur les ressources audio uniquement
|
|
||||||
pub fn audio_resources(&self) -> impl Iterator<Item = &Resource> {
|
|
||||||
self.resources
|
|
||||||
.iter()
|
|
||||||
.filter(|r| r.protocol_info.contains("audio/"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retourne la ressource principale (première disponible)
|
|
||||||
pub fn primary_resource(&self) -> Option<&Resource> {
|
|
||||||
self.resources.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Itère sur les métadonnées sous forme de paires clé-valeur
|
|
||||||
pub fn metadata(&self) -> impl Iterator<Item = (&str, &str)> {
|
|
||||||
let mut pairs = Vec::new();
|
|
||||||
|
|
||||||
pairs.push(("title", self.title.as_str()));
|
|
||||||
|
|
||||||
if let Some(ref artist) = self.artist {
|
|
||||||
pairs.push(("artist", artist.as_str()));
|
|
||||||
}
|
|
||||||
if let Some(ref album) = self.album {
|
|
||||||
pairs.push(("album", album.as_str()));
|
|
||||||
}
|
|
||||||
if let Some(ref genre) = self.genre {
|
|
||||||
pairs.push(("genre", genre.as_str()));
|
|
||||||
}
|
|
||||||
if let Some(ref date) = self.date {
|
|
||||||
pairs.push(("date", date.as_str()));
|
|
||||||
}
|
|
||||||
if let Some(ref track) = self.original_track_number {
|
|
||||||
pairs.push(("trackNumber", track.as_str()));
|
|
||||||
}
|
|
||||||
|
|
||||||
for desc in &self.descriptions {
|
|
||||||
if let Some(ref gain) = desc.track_gain {
|
|
||||||
pairs.push(("replayGain", gain.as_str()));
|
|
||||||
}
|
|
||||||
if let Some(ref peak) = desc.track_peak {
|
|
||||||
pairs.push(("replayPeak", peak.as_str()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pairs.into_iter()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_markdown(&self, buf: &mut String, depth: usize) {
|
|
||||||
let indent = " ".repeat(depth);
|
|
||||||
|
|
||||||
writeln!(buf, "{}- **Item**: {}", indent, self.title).unwrap();
|
|
||||||
writeln!(buf, "{} - ID: `{}`", indent, self.id).unwrap();
|
|
||||||
writeln!(buf, "{} - ParentID: `{}`", indent, self.parent_id).unwrap();
|
|
||||||
writeln!(buf, "{} - Class: `{}`", indent, self.class).unwrap();
|
|
||||||
|
|
||||||
if let Some(ref creator) = self.creator {
|
|
||||||
writeln!(buf, "{} - Creator: {}", indent, creator).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref artist) = self.artist {
|
|
||||||
writeln!(buf, "{} - Artist: {}", indent, artist).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref album) = self.album {
|
|
||||||
writeln!(buf, "{} - Album: {}", indent, album).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref genre) = self.genre {
|
|
||||||
writeln!(buf, "{} - Genre: {}", indent, genre).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref art) = self.album_art {
|
|
||||||
writeln!(buf, "{} - Album Art: ", indent, art).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref date) = self.date {
|
|
||||||
writeln!(buf, "{} - Date: {}", indent, date).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref track) = self.original_track_number {
|
|
||||||
writeln!(buf, "{} - Track: {}", indent, track).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.resources.is_empty() {
|
|
||||||
writeln!(buf, "{} - Resources:", indent).unwrap();
|
|
||||||
for res in &self.resources {
|
|
||||||
writeln!(buf, "{} - URL: {}", indent, res.url).unwrap();
|
|
||||||
writeln!(buf, "{} - Protocol: `{}`", indent, res.protocol_info).unwrap();
|
|
||||||
if let Some(ref dur) = res.duration {
|
|
||||||
writeln!(buf, "{} - Duration: `{}`", indent, dur).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref bits) = res.bits_per_sample {
|
|
||||||
writeln!(buf, "{} - BitsPerSample: `{}`", indent, bits).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref freq) = res.sample_frequency {
|
|
||||||
writeln!(buf, "{} - SampleFrequency: `{}`", indent, freq).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref channels) = res.nr_audio_channels {
|
|
||||||
writeln!(buf, "{} - Channels: `{}`", indent, channels).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.descriptions.is_empty() {
|
|
||||||
writeln!(buf, "{} - Descriptions:", indent).unwrap();
|
|
||||||
for desc in &self.descriptions {
|
|
||||||
if let Some(ref ns) = desc.namespace {
|
|
||||||
writeln!(buf, "{} - Namespace: `{}`", indent, ns).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref gain) = desc.track_gain {
|
|
||||||
writeln!(buf, "{} - Track Gain: `{}`", indent, gain).unwrap();
|
|
||||||
}
|
|
||||||
if let Some(ref peak) = desc.track_peak {
|
|
||||||
writeln!(buf, "{} - Track Peak: `{}`", indent, peak).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============= Implémentation ToXmlElement =============
|
|
||||||
|
|
||||||
fn text_element(name: &str, value: &str) -> Element {
|
|
||||||
let mut e = Element::new(name);
|
|
||||||
e.children.push(XMLNode::Text(value.to_string()));
|
|
||||||
e
|
|
||||||
}
|
|
||||||
|
|
||||||
const SINGLETON_ELEMENTS: &[&str] = &[
|
|
||||||
"dc:title",
|
|
||||||
"title",
|
|
||||||
"dc:creator",
|
|
||||||
"creator",
|
|
||||||
"upnp:class",
|
|
||||||
"class",
|
|
||||||
"upnp:artist",
|
|
||||||
"artist",
|
|
||||||
"upnp:album",
|
|
||||||
"album",
|
|
||||||
"upnp:genre",
|
|
||||||
"genre",
|
|
||||||
"upnp:albumArtURI",
|
|
||||||
"albumArtURI",
|
|
||||||
"dc:date",
|
|
||||||
"date",
|
|
||||||
"upnp:originalTrackNumber",
|
|
||||||
"originalTrackNumber",
|
|
||||||
];
|
|
||||||
|
|
||||||
fn sanitize_singleton_elements(input: &str) -> Cow<'_, str> {
|
|
||||||
if !SINGLETON_ELEMENTS.iter().any(|tag| input.contains(tag)) {
|
|
||||||
return Cow::Borrowed(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cursor = Cursor::new(input.as_bytes());
|
|
||||||
let mut root = match Element::parse(&mut cursor) {
|
|
||||||
Ok(elem) => elem,
|
|
||||||
Err(_) => return Cow::Borrowed(input),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !dedup_singleton_children(&mut root) {
|
|
||||||
return Cow::Borrowed(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
if root.write(&mut buf).is_err() {
|
|
||||||
return Cow::Borrowed(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
String::from_utf8(buf)
|
|
||||||
.map(Cow::Owned)
|
|
||||||
.unwrap_or_else(|_| Cow::Borrowed(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dedup_singleton_children(element: &mut Element) -> bool {
|
|
||||||
let mut changed = false;
|
|
||||||
let mut seen: HashSet<String> = HashSet::new();
|
|
||||||
let mut idx = 0;
|
|
||||||
|
|
||||||
while idx < element.children.len() {
|
|
||||||
let mut remove_current = false;
|
|
||||||
if let XMLNode::Element(child_elem) = &mut element.children[idx] {
|
|
||||||
if SINGLETON_ELEMENTS.contains(&child_elem.name.as_str())
|
|
||||||
&& !seen.insert(child_elem.name.clone())
|
|
||||||
{
|
|
||||||
remove_current = true;
|
|
||||||
changed = true;
|
|
||||||
} else if dedup_singleton_children(child_elem) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if remove_current {
|
|
||||||
element.children.remove(idx);
|
|
||||||
} else {
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
changed
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToXmlElement for DIDLLite {
|
|
||||||
fn to_xml_element(&self) -> Element {
|
|
||||||
let mut root = Element::new("DIDL-Lite");
|
|
||||||
self.set_namespaces(&mut root);
|
|
||||||
for c in &self.containers {
|
|
||||||
root.children.push(XMLNode::Element(c.to_xml_element()));
|
|
||||||
}
|
|
||||||
for i in &self.items {
|
|
||||||
root.children.push(XMLNode::Element(i.to_xml_element()));
|
|
||||||
}
|
|
||||||
root
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToXmlElement for Container {
|
|
||||||
fn to_xml_element(&self) -> Element {
|
|
||||||
let mut elem = Element::new("container");
|
|
||||||
elem.attributes.insert("id".into(), self.id.clone());
|
|
||||||
elem.attributes
|
|
||||||
.insert("parentID".into(), self.parent_id.clone());
|
|
||||||
if let Some(ref r) = self.restricted {
|
|
||||||
elem.attributes.insert("restricted".into(), r.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref cc) = self.child_count {
|
|
||||||
elem.attributes.insert("childCount".into(), cc.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref searchable) = self.searchable {
|
|
||||||
elem.attributes
|
|
||||||
.insert("searchable".into(), searchable.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("dc:title", &self.title)));
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("upnp:class", &self.class)));
|
|
||||||
|
|
||||||
for c in &self.containers {
|
|
||||||
elem.children.push(XMLNode::Element(c.to_xml_element()));
|
|
||||||
}
|
|
||||||
for i in &self.items {
|
|
||||||
elem.children.push(XMLNode::Element(i.to_xml_element()));
|
|
||||||
}
|
|
||||||
|
|
||||||
elem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToXmlElement for Item {
|
|
||||||
fn to_xml_element(&self) -> Element {
|
|
||||||
let mut elem = Element::new("item");
|
|
||||||
elem.attributes.insert("id".into(), self.id.clone());
|
|
||||||
elem.attributes
|
|
||||||
.insert("parentID".into(), self.parent_id.clone());
|
|
||||||
if let Some(ref r) = self.restricted {
|
|
||||||
elem.attributes.insert("restricted".into(), r.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("dc:title", &self.title)));
|
|
||||||
|
|
||||||
if let Some(ref c) = self.creator {
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("dc:creator", c)));
|
|
||||||
}
|
|
||||||
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("upnp:class", &self.class)));
|
|
||||||
|
|
||||||
if let Some(ref artist) = self.artist {
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("upnp:artist", artist)));
|
|
||||||
}
|
|
||||||
if let Some(ref album) = self.album {
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("upnp:album", album)));
|
|
||||||
}
|
|
||||||
if let Some(ref genre) = self.genre {
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("upnp:genre", genre)));
|
|
||||||
}
|
|
||||||
if let Some(ref art) = self.album_art {
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("upnp:albumArtURI", art)));
|
|
||||||
}
|
|
||||||
if let Some(date) = self.normalized_date() {
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("dc:date", &date)));
|
|
||||||
}
|
|
||||||
if let Some(ref track) = self.original_track_number {
|
|
||||||
elem.children.push(XMLNode::Element(text_element(
|
|
||||||
"upnp:originalTrackNumber",
|
|
||||||
track,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
for res in &self.resources {
|
|
||||||
elem.children.push(XMLNode::Element(res.to_xml_element()));
|
|
||||||
}
|
|
||||||
for desc in &self.descriptions {
|
|
||||||
elem.children.push(XMLNode::Element(desc.to_xml_element()));
|
|
||||||
}
|
|
||||||
|
|
||||||
elem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToXmlElement for Resource {
|
|
||||||
fn to_xml_element(&self) -> Element {
|
|
||||||
let mut elem = Element::new("res");
|
|
||||||
elem.attributes
|
|
||||||
.insert("protocolInfo".into(), self.protocol_info.clone());
|
|
||||||
if let Some(ref bps) = self.bits_per_sample {
|
|
||||||
elem.attributes.insert("bitsPerSample".into(), bps.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref freq) = self.sample_frequency {
|
|
||||||
elem.attributes
|
|
||||||
.insert("sampleFrequency".into(), freq.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref ch) = self.nr_audio_channels {
|
|
||||||
elem.attributes.insert("nrAudioChannels".into(), ch.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref dur) = self.duration {
|
|
||||||
elem.attributes.insert("duration".into(), dur.clone());
|
|
||||||
}
|
|
||||||
elem.children.push(XMLNode::Text(self.url.clone()));
|
|
||||||
elem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToXmlElement for Description {
|
|
||||||
fn to_xml_element(&self) -> Element {
|
|
||||||
let mut elem = Element::new("desc");
|
|
||||||
if let Some(ref id) = self.id {
|
|
||||||
elem.attributes.insert("id".into(), id.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref ns) = self.namespace {
|
|
||||||
elem.attributes.insert("nameSpace".into(), ns.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref gain) = self.track_gain {
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("track_gain", gain)));
|
|
||||||
}
|
|
||||||
if let Some(ref peak) = self.track_peak {
|
|
||||||
elem.children
|
|
||||||
.push(XMLNode::Element(text_element("track_peak", peak)));
|
|
||||||
}
|
|
||||||
elem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============= Itérateurs personnalisés =============
|
|
||||||
|
|
||||||
struct AllContainersIter<'a> {
|
|
||||||
stack: Vec<&'a Container>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> AllContainersIter<'a> {
|
|
||||||
fn new(containers: &'a [Container]) -> Self {
|
|
||||||
Self {
|
|
||||||
stack: containers.iter().collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Iterator for AllContainersIter<'a> {
|
|
||||||
type Item = &'a Container;
|
|
||||||
|
|
||||||
fn next(&mut self) -> Option<Self::Item> {
|
|
||||||
self.stack.pop().map(|container| {
|
|
||||||
// Ajouter les enfants à la pile
|
|
||||||
self.stack.extend(container.containers.iter());
|
|
||||||
container
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AllItemsIter<'a> {
|
|
||||||
containers: Vec<&'a Container>,
|
|
||||||
current_items: std::slice::Iter<'a, Item>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> AllItemsIter<'a> {
|
|
||||||
fn new(containers: &'a [Container], items: &'a [Item]) -> Self {
|
|
||||||
Self {
|
|
||||||
containers: containers.iter().collect(),
|
|
||||||
current_items: items.iter(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Iterator for AllItemsIter<'a> {
|
|
||||||
type Item = &'a Item;
|
|
||||||
|
|
||||||
fn next(&mut self) -> Option<Self::Item> {
|
|
||||||
loop {
|
|
||||||
if let Some(item) = self.current_items.next() {
|
|
||||||
return Some(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
let container = self.containers.pop()?;
|
|
||||||
self.containers.extend(container.containers.iter());
|
|
||||||
self.current_items = container.items.iter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_simple_didl() {
|
|
||||||
let xml = r#"
|
|
||||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
|
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
||||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
|
||||||
<item id="1" parentID="0">
|
|
||||||
<dc:title>Test Song</dc:title>
|
|
||||||
<upnp:class>object.item.audioItem.musicTrack</upnp:class>
|
|
||||||
<res protocolInfo="http-get:*:audio/mpeg:*">http://example.com/song.mp3</res>
|
|
||||||
</item>
|
|
||||||
</DIDL-Lite>
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let didl = DIDLLite::parse(xml).unwrap();
|
|
||||||
assert_eq!(didl.items.len(), 1);
|
|
||||||
assert_eq!(didl.items[0].title, "Test Song");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_without_namespaces() {
|
|
||||||
// Teste un XML sans namespaces explicites (devices UPnP laxistes)
|
|
||||||
let xml = r#"
|
|
||||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
|
|
||||||
<item id="1" parentID="0">
|
|
||||||
<title>Test Song</title>
|
|
||||||
<class>object.item.audioItem.musicTrack</class>
|
|
||||||
<res protocolInfo="http-get:*:audio/mpeg:*">http://example.com/song.mp3</res>
|
|
||||||
</item>
|
|
||||||
</DIDL-Lite>
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let didl = DIDLLite::parse(xml).unwrap();
|
|
||||||
assert_eq!(didl.items.len(), 1);
|
|
||||||
assert_eq!(didl.items[0].title, "Test Song");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generic_parser() {
|
|
||||||
let xml = r#"
|
|
||||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
|
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
||||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
|
||||||
</DIDL-Lite>
|
|
||||||
"#;
|
|
||||||
|
|
||||||
// Utiliser le parser générique
|
|
||||||
let metadata: DidlMetadata = parse_metadata(xml).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(metadata.format, "DIDL-Lite");
|
|
||||||
assert!(metadata.parsed_at.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_metadata_map() {
|
|
||||||
let xml = r#"
|
|
||||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
|
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
||||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
|
||||||
</DIDL-Lite>
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let metadata: DidlMetadata = parse_metadata(xml).unwrap();
|
|
||||||
|
|
||||||
// Transformer les données
|
|
||||||
let item_count = metadata.map(|didl| didl.items.len());
|
|
||||||
|
|
||||||
assert_eq!(item_count.format, "DIDL-Lite");
|
|
||||||
assert_eq!(item_count.data, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
========= End of pmodidl/src/lib.rs ===========
|
|
||||||
|
|
||||||
7970
pmoparadise_011.txt
7970
pmoparadise_011.txt
File diff suppressed because it is too large
Load Diff
7970
pmoparadise_012.txt
7970
pmoparadise_012.txt
File diff suppressed because it is too large
Load Diff
@@ -357,7 +357,9 @@ impl WriteHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let manager = crate::manager::PlaylistManager();
|
let manager = crate::manager::PlaylistManager();
|
||||||
manager.rebuild_track_index(&self.playlist.id, &snapshot).await;
|
manager
|
||||||
|
.rebuild_track_index(&self.playlist.id, &snapshot)
|
||||||
|
.await;
|
||||||
manager.notify_playlist_changed(&self.playlist.id);
|
manager.notify_playlist_changed(&self.playlist.id);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -390,7 +392,9 @@ impl WriteHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let manager = crate::manager::PlaylistManager();
|
let manager = crate::manager::PlaylistManager();
|
||||||
manager.rebuild_track_index(&self.playlist.id, &snapshot).await;
|
manager
|
||||||
|
.rebuild_track_index(&self.playlist.id, &snapshot)
|
||||||
|
.await;
|
||||||
manager.notify_playlist_changed(&self.playlist.id);
|
manager.notify_playlist_changed(&self.playlist.id);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -452,7 +456,9 @@ impl WriteHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let manager = crate::manager::PlaylistManager();
|
let manager = crate::manager::PlaylistManager();
|
||||||
manager.rebuild_track_index(&self.playlist.id, &snapshot).await;
|
manager
|
||||||
|
.rebuild_track_index(&self.playlist.id, &snapshot)
|
||||||
|
.await;
|
||||||
manager.notify_playlist_changed(&self.playlist.id);
|
manager.notify_playlist_changed(&self.playlist.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -568,7 +568,11 @@ impl PlaylistManager {
|
|||||||
let manager = self.clone();
|
let manager = self.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tracing::info!("Lazy mode enabled for playlist {} (lookahead: {})", playlist_id, lookahead);
|
tracing::info!(
|
||||||
|
"Lazy mode enabled for playlist {} (lookahead: {})",
|
||||||
|
playlist_id,
|
||||||
|
lookahead
|
||||||
|
);
|
||||||
|
|
||||||
while let Ok(event) = rx.recv().await {
|
while let Ok(event) = rx.recv().await {
|
||||||
match event {
|
match event {
|
||||||
@@ -579,7 +583,9 @@ impl PlaylistManager {
|
|||||||
if let Ok(writer) = manager.get_write_handle(playlist_id.clone()).await {
|
if let Ok(writer) = manager.get_write_handle(playlist_id.clone()).await {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Switching PK in playlist {}: {} -> {}",
|
"Switching PK in playlist {}: {} -> {}",
|
||||||
playlist_id, lazy_pk, real_pk
|
playlist_id,
|
||||||
|
lazy_pk,
|
||||||
|
real_pk
|
||||||
);
|
);
|
||||||
if let Err(e) = writer.update_cache_pk(&lazy_pk, &real_pk).await {
|
if let Err(e) = writer.update_cache_pk(&lazy_pk, &real_pk).await {
|
||||||
tracing::error!("Failed to update PK in playlist: {}", e);
|
tracing::error!("Failed to update PK in playlist: {}", e);
|
||||||
@@ -587,7 +593,9 @@ impl PlaylistManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Prefetch les tracks suivants
|
// 2. Prefetch les tracks suivants
|
||||||
manager.prefetch_next_tracks(&playlist_id, &real_pk, lookahead).await;
|
manager
|
||||||
|
.prefetch_next_tracks(&playlist_id, &real_pk, lookahead)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
//! Record : entrée dans la playlist pointant vers le cache audio
|
//! Record : entrée dans la playlist pointant vers le cache audio
|
||||||
|
|
||||||
use std::time::{Duration, SystemTime};
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
static LAST_ADDED_AT: AtomicI64 = AtomicI64::new(0);
|
||||||
|
|
||||||
/// Un enregistrement dans la playlist
|
/// Un enregistrement dans la playlist
|
||||||
///
|
///
|
||||||
@@ -23,7 +26,7 @@ impl Record {
|
|||||||
pub fn new(cache_pk: String) -> Self {
|
pub fn new(cache_pk: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
cache_pk,
|
cache_pk,
|
||||||
added_at: SystemTime::now(),
|
added_at: next_timestamp(),
|
||||||
ttl: None,
|
ttl: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +35,7 @@ impl Record {
|
|||||||
pub fn with_ttl(cache_pk: String, ttl: Duration) -> Self {
|
pub fn with_ttl(cache_pk: String, ttl: Duration) -> Self {
|
||||||
Self {
|
Self {
|
||||||
cache_pk,
|
cache_pk,
|
||||||
added_at: SystemTime::now(),
|
added_at: next_timestamp(),
|
||||||
ttl: Some(ttl),
|
ttl: Some(ttl),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,3 +62,34 @@ impl Record {
|
|||||||
.as_nanos() as i64
|
.as_nanos() as i64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn next_timestamp() -> SystemTime {
|
||||||
|
let now_nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos() as i64;
|
||||||
|
|
||||||
|
let mut last = LAST_ADDED_AT.load(Ordering::Relaxed);
|
||||||
|
loop {
|
||||||
|
let candidate = if now_nanos > last {
|
||||||
|
now_nanos
|
||||||
|
} else {
|
||||||
|
last.saturating_add(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
match LAST_ADDED_AT.compare_exchange(
|
||||||
|
last,
|
||||||
|
candidate,
|
||||||
|
Ordering::SeqCst,
|
||||||
|
Ordering::SeqCst,
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
let nanos = candidate as u64;
|
||||||
|
return UNIX_EPOCH + Duration::from_nanos(nanos);
|
||||||
|
}
|
||||||
|
Err(updated) => {
|
||||||
|
last = updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ async fn main() -> Result<()> {
|
|||||||
match api.get_file_url(track_id).await {
|
match api.get_file_url(track_id).await {
|
||||||
Ok(stream_info) => {
|
Ok(stream_info) => {
|
||||||
println!(" ✓ Success!");
|
println!(" ✓ Success!");
|
||||||
println!(" URL: {}...", &stream_info.url[..80.min(stream_info.url.len())]);
|
println!(
|
||||||
|
" URL: {}...",
|
||||||
|
&stream_info.url[..80.min(stream_info.url.len())]
|
||||||
|
);
|
||||||
println!(" MIME type: {}", stream_info.mime_type);
|
println!(" MIME type: {}", stream_info.mime_type);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ async fn main() -> Result<()> {
|
|||||||
println!(" format_id: {}", format_id);
|
println!(" format_id: {}", format_id);
|
||||||
println!(" intent: {}", intent);
|
println!(" intent: {}", intent);
|
||||||
println!(" timestamp: {}", timestamp);
|
println!(" timestamp: {}", timestamp);
|
||||||
println!(" secret: {}... (first 10 chars)", &app_secret[..10.min(app_secret.len())]);
|
println!(
|
||||||
|
" secret: {}... (first 10 chars)",
|
||||||
|
&app_secret[..10.min(app_secret.len())]
|
||||||
|
);
|
||||||
|
|
||||||
// Calculer la signature
|
// Calculer la signature
|
||||||
let signature = signing::sign_track_get_file_url(
|
let signature = signing::sign_track_get_file_url(
|
||||||
|
|||||||
@@ -252,8 +252,14 @@ impl QobuzApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Headers additionnels pour compatibilité avec qobuz-player-client
|
// Headers additionnels pour compatibilité avec qobuz-player-client
|
||||||
request = request.header("Accept-Language", "en,en-US;q=0.8,ko;q=0.6,zh;q=0.4,zh-CN;q=0.2");
|
request = request.header(
|
||||||
request = request.header("Access-Control-Request-Headers", "x-user-auth-token,x-app-id");
|
"Accept-Language",
|
||||||
|
"en,en-US;q=0.8,ko;q=0.6,zh;q=0.4,zh-CN;q=0.2",
|
||||||
|
);
|
||||||
|
request = request.header(
|
||||||
|
"Access-Control-Request-Headers",
|
||||||
|
"x-user-auth-token,x-app-id",
|
||||||
|
);
|
||||||
|
|
||||||
// Ajouter les paramètres
|
// Ajouter les paramètres
|
||||||
if method == "GET" {
|
if method == "GET" {
|
||||||
@@ -268,7 +274,11 @@ impl QobuzApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Traite la réponse HTTP
|
/// Traite la réponse HTTP
|
||||||
async fn handle_response<T: DeserializeOwned>(&self, response: Response, endpoint: &str) -> Result<T> {
|
async fn handle_response<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
response: Response,
|
||||||
|
endpoint: &str,
|
||||||
|
) -> Result<T> {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let status_code = status.as_u16();
|
let status_code = status.as_u16();
|
||||||
|
|
||||||
@@ -276,7 +286,10 @@ impl QobuzApi {
|
|||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let error_text = response.text().await.unwrap_or_default();
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
debug!("API error ({}) on {}: {}", status_code, endpoint, error_text);
|
debug!(
|
||||||
|
"API error ({}) on {}: {}",
|
||||||
|
status_code, endpoint, error_text
|
||||||
|
);
|
||||||
return Err(QobuzError::from_status_code(status_code, error_text));
|
return Err(QobuzError::from_status_code(status_code, error_text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,10 +134,7 @@ impl QobuzClient {
|
|||||||
let mut api = match (config_appid.clone(), config_spoofer_secret, config_secret) {
|
let mut api = match (config_appid.clone(), config_spoofer_secret, config_secret) {
|
||||||
// Priority 1: Try memorized Spoofer secret (raw, no XOR)
|
// Priority 1: Try memorized Spoofer secret (raw, no XOR)
|
||||||
(Some(app_id), Some(spoofer_secret), _) => {
|
(Some(app_id), Some(spoofer_secret), _) => {
|
||||||
info!(
|
info!("Trying memorized Spoofer secret with App ID: {}", app_id);
|
||||||
"Trying memorized Spoofer secret with App ID: {}",
|
|
||||||
app_id
|
|
||||||
);
|
|
||||||
match QobuzApi::with_raw_secret(&app_id, &spoofer_secret) {
|
match QobuzApi::with_raw_secret(&app_id, &spoofer_secret) {
|
||||||
Ok(api) => {
|
Ok(api) => {
|
||||||
used_config_credentials = true;
|
used_config_credentials = true;
|
||||||
@@ -174,9 +171,7 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
// Priority 3: Fallback to Spoofer
|
// Priority 3: Fallback to Spoofer
|
||||||
_ => {
|
_ => {
|
||||||
info!(
|
info!("AppID or secret not configured, using Spoofer...");
|
||||||
"AppID or secret not configured, using Spoofer..."
|
|
||||||
);
|
|
||||||
Self::try_spoofer_fallback(config).await?
|
Self::try_spoofer_fallback(config).await?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -266,13 +261,19 @@ impl QobuzClient {
|
|||||||
// Optimization: Login once with first secret to get auth token
|
// Optimization: Login once with first secret to get auth token
|
||||||
// Then test all secrets using the same token
|
// Then test all secrets using the same token
|
||||||
if let Some((first_timezone, first_secret)) = secrets.first() {
|
if let Some((first_timezone, first_secret)) = secrets.first() {
|
||||||
if let Ok(temp_api) = QobuzApi::with_raw_secret(&app_id, first_secret) {
|
if let Ok(temp_api) =
|
||||||
if let Ok(_auth_info) = temp_api.login(&username, &password).await {
|
QobuzApi::with_raw_secret(&app_id, first_secret)
|
||||||
|
{
|
||||||
|
if let Ok(_auth_info) =
|
||||||
|
temp_api.login(&username, &password).await
|
||||||
|
{
|
||||||
// Now test each secret with the authenticated token
|
// Now test each secret with the authenticated token
|
||||||
for (timezone, secret) in secrets.iter() {
|
for (timezone, secret) in secrets.iter() {
|
||||||
debug!("Testing timezone secret: {}", timezone);
|
debug!("Testing timezone secret: {}", timezone);
|
||||||
|
|
||||||
if let Ok(test_api) = QobuzApi::with_raw_secret(&app_id, secret) {
|
if let Ok(test_api) =
|
||||||
|
QobuzApi::with_raw_secret(&app_id, secret)
|
||||||
|
{
|
||||||
// Set the auth token from our initial login
|
// Set the auth token from our initial login
|
||||||
test_api.set_auth_token(
|
test_api.set_auth_token(
|
||||||
temp_api.auth_token().unwrap(),
|
temp_api.auth_token().unwrap(),
|
||||||
@@ -282,17 +283,29 @@ impl QobuzClient {
|
|||||||
// Test the secret using track/getFileUrl (like qobuz-player-client)
|
// Test the secret using track/getFileUrl (like qobuz-player-client)
|
||||||
// Use the same hardcoded track_id (64868955) as qobuz-player-client
|
// Use the same hardcoded track_id (64868955) as qobuz-player-client
|
||||||
if test_api.get_file_url("64868955").await.is_ok() {
|
if test_api.get_file_url("64868955").await.is_ok() {
|
||||||
info!("✓ Secret from timezone '{}' works!", timezone);
|
info!(
|
||||||
|
"✓ Secret from timezone '{}' works!",
|
||||||
|
timezone
|
||||||
|
);
|
||||||
|
|
||||||
// Save both appid and the working secret
|
// Save both appid and the working secret
|
||||||
if let Err(e) = config.set_qobuz_appid(&app_id) {
|
if let Err(e) = config.set_qobuz_appid(&app_id)
|
||||||
|
{
|
||||||
debug!("Could not save appid: {}", e);
|
debug!("Could not save appid: {}", e);
|
||||||
}
|
}
|
||||||
if let Err(e) = config.set_qobuz_spoofer_secret(secret) {
|
if let Err(e) =
|
||||||
debug!("Could not save spoofer secret: {}", e);
|
config.set_qobuz_spoofer_secret(secret)
|
||||||
|
{
|
||||||
|
debug!(
|
||||||
|
"Could not save spoofer secret: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(Some((app_id.clone(), secret.clone())));
|
return Ok(Some((
|
||||||
|
app_id.clone(),
|
||||||
|
secret.clone(),
|
||||||
|
)));
|
||||||
} else {
|
} else {
|
||||||
debug!("✗ Secret from timezone '{}' failed track/getFileUrl test", timezone);
|
debug!("✗ Secret from timezone '{}' failed track/getFileUrl test", timezone);
|
||||||
}
|
}
|
||||||
@@ -310,7 +323,7 @@ impl QobuzClient {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
info!(
|
info!(
|
||||||
"Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID",
|
"Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID",
|
||||||
|
|||||||
@@ -230,8 +230,9 @@ impl QobuzSource {
|
|||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// The track ID (e.g., "qobuz://track/12345")
|
/// `(track_id, lazy_pk)` where `track_id` is the logical Qobuz URI and
|
||||||
pub async fn add_track_lazy(&self, track: &Track) -> Result<String> {
|
/// `lazy_pk` the cache identifier stored in pmocache.
|
||||||
|
pub async fn add_track_lazy(&self, track: &Track) -> Result<(String, String)> {
|
||||||
let track_id = format!("qobuz://track/{}", track.id);
|
let track_id = format!("qobuz://track/{}", track.id);
|
||||||
|
|
||||||
// Get streaming URL
|
// Get streaming URL
|
||||||
@@ -287,7 +288,12 @@ impl QobuzSource {
|
|||||||
.cache_manager
|
.cache_manager
|
||||||
.cache_audio_lazy(&stream_url, Some(metadata))
|
.cache_audio_lazy(&stream_url, Some(metadata))
|
||||||
.await
|
.await
|
||||||
.ok();
|
.map_err(|e| {
|
||||||
|
MusicSourceError::CacheError(format!(
|
||||||
|
"Failed to cache lazy track {}: {}",
|
||||||
|
track.title, e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
// 4. Store metadata
|
// 4. Store metadata
|
||||||
self.inner
|
self.inner
|
||||||
@@ -296,13 +302,13 @@ impl QobuzSource {
|
|||||||
track_id.clone(),
|
track_id.clone(),
|
||||||
pmosource::TrackMetadata {
|
pmosource::TrackMetadata {
|
||||||
original_uri: stream_url,
|
original_uri: stream_url,
|
||||||
cached_audio_pk,
|
cached_audio_pk: Some(cached_audio_pk.clone()),
|
||||||
cached_cover_pk,
|
cached_cover_pk,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(track_id)
|
Ok((track_id, cached_audio_pk))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load full album into pmoplaylist with lazy audio
|
/// Load full album into pmoplaylist with lazy audio
|
||||||
@@ -345,15 +351,15 @@ impl QobuzSource {
|
|||||||
|
|
||||||
for (i, track) in tracks.iter().enumerate() {
|
for (i, track) in tracks.iter().enumerate() {
|
||||||
match self.add_track_lazy(track).await {
|
match self.add_track_lazy(track).await {
|
||||||
Ok(track_id) => {
|
Ok((_track_id, lazy_pk)) => {
|
||||||
debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title);
|
debug!(
|
||||||
|
"Track {}/{}: {} (lazy pk {})",
|
||||||
// Extract lazy PK from cache manager
|
i + 1,
|
||||||
if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await {
|
tracks.len(),
|
||||||
if let Some(audio_pk) = metadata.cached_audio_pk {
|
track.title,
|
||||||
lazy_pks.push(audio_pk);
|
&lazy_pk
|
||||||
}
|
);
|
||||||
}
|
lazy_pks.push(lazy_pk);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
|
warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
|
||||||
@@ -431,15 +437,15 @@ impl QobuzSource {
|
|||||||
|
|
||||||
for (i, track) in tracks.iter().enumerate() {
|
for (i, track) in tracks.iter().enumerate() {
|
||||||
match self.add_track_lazy(track).await {
|
match self.add_track_lazy(track).await {
|
||||||
Ok(track_id) => {
|
Ok((_track_id, lazy_pk)) => {
|
||||||
debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title);
|
debug!(
|
||||||
|
"Track {}/{}: {} (lazy pk {})",
|
||||||
// Extract lazy PK from cache manager
|
i + 1,
|
||||||
if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await {
|
tracks.len(),
|
||||||
if let Some(audio_pk) = metadata.cached_audio_pk {
|
track.title,
|
||||||
lazy_pks.push(audio_pk);
|
&lazy_pk
|
||||||
}
|
);
|
||||||
}
|
lazy_pks.push(lazy_pk);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
|
warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
|
||||||
|
|||||||
4889
pmoqobuz_026.txt
4889
pmoqobuz_026.txt
File diff suppressed because it is too large
Load Diff
8488
pmoqobuz_027.txt
8488
pmoqobuz_027.txt
File diff suppressed because it is too large
Load Diff
8610
pmoqobuz_028.txt
8610
pmoqobuz_028.txt
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user