Update la web app pour tirer partie du nouveau systeme de cache

This commit is contained in:
2025-10-28 00:10:51 +01:00
parent b4a8925281
commit a6ed30e0c7
14 changed files with 459 additions and 43 deletions

View File

@@ -102,6 +102,9 @@
<span v-if="track.metadata?.bitrate"> <span v-if="track.metadata?.bitrate">
{{ formatBitrate(track.metadata.bitrate) }} {{ formatBitrate(track.metadata.bitrate) }}
</span> </span>
<span v-if="conversionLabel(track)">
{{ conversionLabel(track) }}
</span>
</div> </div>
<div class="collection" v-if="track.collection"> <div class="collection" v-if="track.collection">
{{ track.collection }} {{ track.collection }}
@@ -153,6 +156,7 @@
<p v-if="selectedTrack.metadata.sample_rate"><strong>Sample Rate:</strong> {{ formatSampleRate(selectedTrack.metadata.sample_rate) }}</p> <p v-if="selectedTrack.metadata.sample_rate"><strong>Sample Rate:</strong> {{ formatSampleRate(selectedTrack.metadata.sample_rate) }}</p>
<p v-if="selectedTrack.metadata.bitrate"><strong>Bitrate:</strong> {{ formatBitrate(selectedTrack.metadata.bitrate) }}</p> <p v-if="selectedTrack.metadata.bitrate"><strong>Bitrate:</strong> {{ formatBitrate(selectedTrack.metadata.bitrate) }}</p>
<p v-if="selectedTrack.metadata.channels"><strong>Channels:</strong> {{ selectedTrack.metadata.channels }}</p> <p v-if="selectedTrack.metadata.channels"><strong>Channels:</strong> {{ selectedTrack.metadata.channels }}</p>
<p v-if="conversionLabel(selectedTrack)"><strong>Conversion:</strong> {{ conversionLabel(selectedTrack) }}</p>
</div> </div>
<div class="cache-section"> <div class="cache-section">
<h4>Cache Info</h4> <h4>Cache Info</h4>
@@ -414,6 +418,37 @@ function formatDate(dateString: string) {
return d.toLocaleDateString(); return d.toLocaleDateString();
} }
function formatConversion(
conversion?: { mode?: string; input_codec?: string; details?: string } | null
): string | undefined {
if (!conversion || !conversion.mode) return undefined;
const modeLower = conversion.mode.toLowerCase();
const modeLabel =
modeLower === "passthrough"
? "Passthrough"
: modeLower === "transcode"
? "Transcoded"
: conversion.mode.charAt(0).toUpperCase() + conversion.mode.slice(1);
if (conversion.input_codec) {
const codec = conversion.input_codec.toUpperCase();
if (modeLower === "passthrough") {
return `${modeLabel} (${codec})`;
}
return `${modeLabel} (${codec} → FLAC)`;
}
if (conversion.details) {
return `${modeLabel} ${conversion.details}`;
}
return modeLabel;
}
function conversionLabel(track: AudioCacheEntry | null): string | undefined {
return formatConversion(track?.metadata?.conversion ?? undefined);
}
onMounted(() => { onMounted(() => {
refreshTracks(); refreshTracks();
}); });

View File

@@ -18,6 +18,7 @@ export interface AudioCacheMetadata {
sample_rate?: number; sample_rate?: number;
bitrate?: number; bitrate?: number;
channels?: number; channels?: number;
conversion?: ConversionInfo;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -30,6 +31,12 @@ export interface AudioCacheEntry {
metadata?: AudioCacheMetadata | null; metadata?: AudioCacheMetadata | null;
} }
export interface ConversionInfo {
mode: string;
input_codec?: string;
details?: string;
}
export interface AddTrackRequest { export interface AddTrackRequest {
url: string; url: string;
collection?: string; collection?: string;
@@ -43,9 +50,13 @@ export interface AddTrackResponse {
export interface DownloadStatus { export interface DownloadStatus {
pk: string; pk: string;
status: "pending" | "downloading" | "completed" | "failed"; in_progress: boolean;
progress?: number; finished: boolean;
current_size?: number;
transformed_size?: number;
expected_size?: number;
error?: string; error?: string;
conversion?: ConversionInfo;
} }
export interface ApiError { export interface ApiError {

View File

@@ -103,7 +103,16 @@ pub async fn add_with_metadata_extraction(
let flac_bytes = tokio::fs::read(&file_path).await?; let flac_bytes = tokio::fs::read(&file_path).await?;
// Extraire les métadonnées // Extraire les métadonnées
let metadata = crate::metadata::AudioMetadata::from_bytes(&flac_bytes)?; let mut metadata = crate::metadata::AudioMetadata::from_bytes(&flac_bytes)?;
if let Some(transform) = cache.transform_metadata(&pk).await {
if let Some(mode) = transform.mode {
metadata.conversion = Some(crate::metadata::AudioConversionInfo {
mode,
source_codec: transform.input_codec,
});
}
}
let metadata_json: Value = serde_json::to_value(&metadata) let metadata_json: Value = serde_json::to_value(&metadata)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
// Stocker dans la DB // Stocker dans la DB

View File

@@ -68,6 +68,23 @@ pub struct AudioMetadata {
/// Bitrate moyen (kbps) /// Bitrate moyen (kbps)
#[cfg_attr(feature = "pmoserver", schema(example = 1411))] #[cfg_attr(feature = "pmoserver", schema(example = 1411))]
pub bitrate: Option<u32>, pub bitrate: Option<u32>,
/// Informations sur la conversion appliquée lors de l'ingestion
#[cfg_attr(feature = "pmoserver", schema(example = json!({"mode":"transcode","source_codec":"mp3"})))]
pub conversion: Option<AudioConversionInfo>,
}
/// Informations sur le processus de conversion
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "pmoserver", derive(ToSchema))]
pub struct AudioConversionInfo {
/// Mode de conversion (ex: "passthrough", "transcode")
#[cfg_attr(feature = "pmoserver", schema(example = "transcode"))]
pub mode: String,
/// Codec source détecté
#[cfg_attr(feature = "pmoserver", schema(example = "mp3"))]
pub source_codec: Option<String>,
} }
impl AudioMetadata { impl AudioMetadata {
@@ -94,6 +111,7 @@ impl AudioMetadata {
sample_rate: properties.sample_rate(), sample_rate: properties.sample_rate(),
channels: properties.channels(), channels: properties.channels(),
bitrate: properties.audio_bitrate(), bitrate: properties.audio_bitrate(),
conversion: None,
}; };
if let Some(tag) = tag { if let Some(tag) = tag {
@@ -232,6 +250,7 @@ impl Default for AudioMetadata {
sample_rate: None, sample_rate: None,
channels: None, channels: None,
bitrate: None, bitrate: None,
conversion: None,
} }
} }
} }
@@ -256,6 +275,7 @@ mod tests {
sample_rate: Some(44100), sample_rate: Some(44100),
channels: Some(2), channels: Some(2),
bitrate: Some(1411), bitrate: Some(1411),
conversion: None,
}; };
assert_eq!( assert_eq!(
@@ -280,6 +300,7 @@ mod tests {
sample_rate: None, sample_rate: None,
channels: None, channels: None,
bitrate: None, bitrate: None,
conversion: None,
}; };
assert_eq!(metadata.collection_key(), None); assert_eq!(metadata.collection_key(), None);

View File

@@ -6,13 +6,14 @@
//! while native FLAC input is forwarded byte-for-byte without re-encoding. //! while native FLAC input is forwarded byte-for-byte without re-encoding.
use bytes::Bytes; use bytes::Bytes;
use pmocache::download::TransformMetadata;
use pmocache::StreamTransformer; use pmocache::StreamTransformer;
use pmoflac::{transcode_to_flac_stream, AudioCodec, TranscodeOptions}; use pmoflac::{transcode_to_flac_stream, AudioCodec, TranscodeOptions};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// Creates the transformer consumed by the audio cache. /// Creates the transformer consumed by the audio cache.
pub fn create_streaming_flac_transformer() -> StreamTransformer { pub fn create_streaming_flac_transformer() -> StreamTransformer {
Box::new(|input, mut file, progress| { Box::new(|input, mut file, context| {
Box::pin(async move { Box::pin(async move {
let byte_stream = input.into_byte_stream(); let byte_stream = input.into_byte_stream();
let reader = StreamToAsyncRead::new(byte_stream); let reader = StreamToAsyncRead::new(byte_stream);
@@ -21,7 +22,23 @@ pub fn create_streaming_flac_transformer() -> StreamTransformer {
.await .await
.map_err(|e| format!("Audio transcode error: {}", e))?; .map_err(|e| format!("Audio transcode error: {}", e))?;
log_stream_info(transcode.input_codec(), transcode.input_stream_info()); let codec = transcode.input_codec();
let info = transcode.input_stream_info().clone();
log_stream_info(codec, &info);
let mode = if transcode.is_passthrough() {
"passthrough"
} else {
"transcode"
};
context
.set_metadata(TransformMetadata {
mode: Some(mode.to_string()),
input_codec: Some(codec_to_string(codec)),
details: None,
})
.await;
let mut flac_stream = transcode.into_stream(); let mut flac_stream = transcode.into_stream();
let mut buffer = vec![0u8; 64 * 1024]; let mut buffer = vec![0u8; 64 * 1024];
@@ -42,7 +59,7 @@ pub fn create_streaming_flac_transformer() -> StreamTransformer {
.map_err(|e| format!("Failed to write FLAC file: {}", e))?; .map_err(|e| format!("Failed to write FLAC file: {}", e))?;
total_written += read as u64; total_written += read as u64;
progress(total_written); context.report_progress(total_written);
} }
file.flush() file.flush()
@@ -70,6 +87,18 @@ fn log_stream_info(codec: AudioCodec, info: &pmoflac::StreamInfo) {
); );
} }
fn codec_to_string(codec: AudioCodec) -> String {
match codec {
AudioCodec::Flac => "flac",
AudioCodec::Mp3 => "mp3",
AudioCodec::OggVorbis => "ogg_vorbis",
AudioCodec::OggOpus => "ogg_opus",
AudioCodec::Wav => "wav",
AudioCodec::Aiff => "aiff",
}
.to_string()
}
/// Adapter exposing a byte stream as `AsyncRead`. /// Adapter exposing a byte stream as `AsyncRead`.
struct StreamToAsyncRead { struct StreamToAsyncRead {
stream: futures_util::stream::BoxStream<'static, Result<Bytes, String>>, stream: futures_util::stream::BoxStream<'static, Result<Bytes, String>>,

View File

@@ -11,9 +11,7 @@ fn main() {
println!(" dl.wait_until_finished().await?;\n"); println!(" dl.wait_until_finished().await?;\n");
println!("2. Téléchargement avec transformation:"); println!("2. Téléchargement avec transformation:");
println!( println!(" let transformer: StreamTransformer = Box::new(|response, mut file, ctx| {{");
" let transformer: StreamTransformer = Box::new(|response, mut file, update_progress| {{"
);
println!(" Box::pin(async move {{"); println!(" Box::pin(async move {{");
println!(" let mut stream = response.bytes_stream();"); println!(" let mut stream = response.bytes_stream();");
println!(" let mut total = 0u64;"); println!(" let mut total = 0u64;");
@@ -26,7 +24,7 @@ fn main() {
println!(); println!();
println!(" file.write_all(&transformed).await.map_err(|e| e.to_string())?;"); println!(" file.write_all(&transformed).await.map_err(|e| e.to_string())?;");
println!(" total += transformed.len() as u64;"); println!(" total += transformed.len() as u64;");
println!(" update_progress(total);"); println!(" ctx.report_progress(total);");
println!(" }}"); println!(" }}");
println!(); println!();
println!(" file.flush().await.map_err(|e| e.to_string())?;"); println!(" file.flush().await.map_err(|e| e.to_string())?;");

View File

@@ -19,7 +19,7 @@ fn create_gzip_transformer() -> StreamTransformer {
unimplemented!("Cette fonction nécessite la dépendance async-compression") unimplemented!("Cette fonction nécessite la dépendance async-compression")
/* /*
Box::new(|response, mut file, update_progress| { Box::new(|response, mut file, context| {
Box::pin(async move { Box::pin(async move {
use async_compression::tokio::write::GzipEncoder; use async_compression::tokio::write::GzipEncoder;
@@ -36,7 +36,7 @@ fn create_gzip_transformer() -> StreamTransformer {
.map_err(|e| format!("Failed to write compressed data: {}", e))?; .map_err(|e| format!("Failed to write compressed data: {}", e))?;
total_written += chunk.len() as u64; total_written += chunk.len() as u64;
update_progress(total_written); context.report_progress(total_written);
} }
encoder encoder
@@ -52,7 +52,7 @@ fn create_gzip_transformer() -> StreamTransformer {
/// Exemple de transformer qui convertit les données en majuscules (exemple simple) /// Exemple de transformer qui convertit les données en majuscules (exemple simple)
fn create_uppercase_transformer() -> StreamTransformer { fn create_uppercase_transformer() -> StreamTransformer {
Box::new(|input, mut file, update_progress| { Box::new(|input, mut file, context| {
Box::pin(async move { Box::pin(async move {
let mut stream = input.into_byte_stream(); let mut stream = input.into_byte_stream();
let mut total_written = 0u64; let mut total_written = 0u64;
@@ -77,7 +77,7 @@ fn create_uppercase_transformer() -> StreamTransformer {
.map_err(|e| format!("Failed to write: {}", e))?; .map_err(|e| format!("Failed to write: {}", e))?;
total_written += transformed.len() as u64; total_written += transformed.len() as u64;
update_progress(total_written); context.report_progress(total_written);
} }
file.flush() file.flush()
@@ -91,7 +91,7 @@ fn create_uppercase_transformer() -> StreamTransformer {
/// Exemple de transformer qui saute les N premiers bytes (utile pour enlever des headers) /// Exemple de transformer qui saute les N premiers bytes (utile pour enlever des headers)
fn create_skip_header_transformer(skip_bytes: usize) -> StreamTransformer { fn create_skip_header_transformer(skip_bytes: usize) -> StreamTransformer {
Box::new(move |input, mut file, update_progress| { Box::new(move |input, mut file, context| {
Box::pin(async move { Box::pin(async move {
let mut stream = input.into_byte_stream(); let mut stream = input.into_byte_stream();
let mut skipped = 0usize; let mut skipped = 0usize;
@@ -118,7 +118,7 @@ fn create_skip_header_transformer(skip_bytes: usize) -> StreamTransformer {
.map_err(|e| format!("Failed to write: {}", e))?; .map_err(|e| format!("Failed to write: {}", e))?;
total_written += to_write.len() as u64; total_written += to_write.len() as u64;
update_progress(total_written); context.report_progress(total_written);
} }
file.flush() file.flush()
@@ -132,7 +132,7 @@ fn create_skip_header_transformer(skip_bytes: usize) -> StreamTransformer {
/// Exemple de transformer qui compte les lignes et ajoute des numéros /// Exemple de transformer qui compte les lignes et ajoute des numéros
fn create_line_number_transformer() -> StreamTransformer { fn create_line_number_transformer() -> StreamTransformer {
Box::new(|input, mut file, update_progress| { Box::new(|input, mut file, context| {
Box::pin(async move { Box::pin(async move {
let mut stream = input.into_byte_stream(); let mut stream = input.into_byte_stream();
let mut line_number = 1u32; let mut line_number = 1u32;
@@ -162,7 +162,7 @@ fn create_line_number_transformer() -> StreamTransformer {
.map_err(|e| format!("Failed to write: {}", e))?; .map_err(|e| format!("Failed to write: {}", e))?;
total_written += numbered_line.len() as u64 + line.len() as u64 + 1; total_written += numbered_line.len() as u64 + line.len() as u64 + 1;
update_progress(total_written); context.report_progress(total_written);
line_number += 1; line_number += 1;
buffer.drain(..=newline_pos); buffer.drain(..=newline_pos);
@@ -181,7 +181,7 @@ fn create_line_number_transformer() -> StreamTransformer {
.map_err(|e| format!("Failed to write: {}", e))?; .map_err(|e| format!("Failed to write: {}", e))?;
total_written += numbered_line.len() as u64 + buffer.len() as u64; total_written += numbered_line.len() as u64 + buffer.len() as u64;
update_progress(total_written); context.report_progress(total_written);
} }
file.flush() file.flush()

View File

@@ -15,6 +15,7 @@ use axum::{
Json, Json,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
#[cfg(feature = "openapi")] #[cfg(feature = "openapi")]
@@ -39,6 +40,21 @@ pub struct DownloadStatus {
pub finished: bool, pub finished: bool,
/// Erreur éventuelle /// Erreur éventuelle
pub error: Option<String>, pub error: Option<String>,
/// Informations sur la conversion
pub conversion: Option<ConversionStatus>,
}
/// Informations sur la conversion en cours ou réalisée
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ConversionStatus {
/// Mode de conversion (ex: "passthrough", "transcode")
#[cfg_attr(feature = "openapi", schema(example = "passthrough"))]
pub mode: String,
/// Codec source détecté (si disponible)
pub input_codec: Option<String>,
/// Informations complémentaires lisibles (optionnel)
pub details: Option<String>,
} }
/// Requête pour ajouter un item au cache /// Requête pour ajouter un item au cache
@@ -134,8 +150,9 @@ pub async fn get_download_status<C: CacheConfig>(
State(cache): State<Arc<Cache<C>>>, State(cache): State<Arc<Cache<C>>>,
Path(pk): Path<String>, Path(pk): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Vérifier que l'item existe dans la DB let entry = match cache.db.get(&pk, false) {
if cache.db.get(&pk, false).is_err() { Ok(entry) => entry,
Err(_) => {
return ( return (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Json(ErrorResponse { Json(ErrorResponse {
@@ -145,19 +162,62 @@ pub async fn get_download_status<C: CacheConfig>(
) )
.into_response(); .into_response();
} }
};
let in_progress = cache.get_download(&pk).await.is_some(); let download = cache.get_download(&pk).await;
let current_size = cache.current_size(&pk).await; let file_path = cache.get_file_path(&pk);
let transformed_size = cache.transformed_size(&pk).await; let file_size = if file_path.exists() {
let expected_size = cache.expected_size(&pk).await; std::fs::metadata(&file_path).ok().map(|m| m.len())
let finished = cache.is_finished(&pk).await; } else {
None
};
let error = if let Some(download) = cache.get_download(&pk).await { let in_progress = download.is_some();
let current_size = if let Some(download) = download.as_ref() {
Some(download.current_size().await)
} else {
file_size
};
let transformed_size = if let Some(download) = download.as_ref() {
Some(download.transformed_size().await)
} else {
file_size
};
let expected_size = if let Some(download) = download.as_ref() {
download.expected_size().await
} else {
file_size
};
let finished = if let Some(download) = download.as_ref() {
download.finished().await
} else {
file_path.exists()
};
let error = if let Some(download) = download.as_ref() {
download.error().await download.error().await
} else { } else {
None None
}; };
let mut conversion = if let Some(download) = download.as_ref() {
download
.transform_metadata()
.await
.map(ConversionStatus::from)
} else {
None
};
if conversion.is_none() {
if let Some(meta) = entry.metadata.as_ref() {
conversion = conversion_from_json(meta);
}
}
let status = DownloadStatus { let status = DownloadStatus {
pk, pk,
in_progress, in_progress,
@@ -166,11 +226,28 @@ pub async fn get_download_status<C: CacheConfig>(
expected_size, expected_size,
finished, finished,
error, error,
conversion,
}; };
(StatusCode::OK, Json(status)).into_response() (StatusCode::OK, Json(status)).into_response()
} }
impl From<crate::download::TransformMetadata> for ConversionStatus {
fn from(value: crate::download::TransformMetadata) -> Self {
Self {
mode: value.mode.unwrap_or_else(|| "unknown".to_string()),
input_codec: value.input_codec,
details: value.details,
}
}
}
fn conversion_from_json(value: &Value) -> Option<ConversionStatus> {
value
.get("conversion")
.and_then(|conv| serde_json::from_value(conv.clone()).ok())
}
/// Ajoute un item au cache depuis une URL /// Ajoute un item au cache depuis une URL
/// ///
/// Télécharge l'item depuis l'URL fournie et l'ajoute au cache. /// Télécharge l'item depuis l'URL fournie et l'ajoute au cache.

View File

@@ -94,9 +94,10 @@ impl<C: CacheConfig> Cache<C> {
/// ///
/// let transformer_factory = Arc::new(|| { /// let transformer_factory = Arc::new(|| {
/// // Créer un transformer qui convertit les données /// // Créer un transformer qui convertit les données
/// Box::new(|input, file, progress| { /// Box::new(|input, file, ctx| {
/// Box::pin(async move { /// Box::pin(async move {
/// // Transformation personnalisée /// // Transformation personnalisée
/// ctx.report_progress(0);
/// Ok(()) /// Ok(())
/// }) /// })
/// }) as StreamTransformer /// }) as StreamTransformer
@@ -610,6 +611,15 @@ impl<C: CacheConfig> Cache<C> {
} }
} }
/// Retourne les métadonnées de transformation (si disponibles)
pub async fn transform_metadata(&self, pk: &str) -> Option<crate::download::TransformMetadata> {
if let Some(download) = self.get_download(pk).await {
download.transform_metadata().await
} else {
None
}
}
/// Indique si le téléchargement est terminé /// Indique si le téléchargement est terminé
/// ///
/// # Arguments /// # Arguments

View File

@@ -15,18 +15,20 @@ use tokio_util::io::ReaderStream;
/// La fonction reçoit : /// La fonction reçoit :
/// - Un `CacheInput` abstrait (HTTP ou lecteur en streaming) /// - Un `CacheInput` abstrait (HTTP ou lecteur en streaming)
/// - Un writer pour écrire les données transformées /// - Un writer pour écrire les données transformées
/// - Un callback pour mettre à jour la progression /// - Un contexte fournissant des utilitaires (progression, métadonnées)
/// ///
/// Elle retourne un `Future` qui se résout en `Result`. /// Elle retourne un `Future` qui se résout en `Result`.
pub type StreamTransformer = Box< pub type StreamTransformer = Box<
dyn FnOnce( dyn FnOnce(
CacheInput, CacheInput,
tokio::fs::File, tokio::fs::File,
Arc<dyn Fn(u64) + Send + Sync>, TransformContextHandle,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> ) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>>
+ Send, + Send,
>; >;
pub type TransformContextHandle = Arc<TransformContext>;
type ByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>>; type ByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>>;
/// Source générique (HTTP ou lecteur) exposée aux transformers. /// Source générique (HTTP ou lecteur) exposée aux transformers.
@@ -180,6 +182,7 @@ struct DownloadState {
finished: bool, finished: bool,
read_position: u64, read_position: u64,
error: Option<String>, error: Option<String>,
transform_metadata: Option<TransformMetadata>,
} }
/// Objet représentant un téléchargement en cours /// Objet représentant un téléchargement en cours
@@ -200,6 +203,7 @@ impl Download {
finished: false, finished: false,
read_position: 0, read_position: 0,
error: None, error: None,
transform_metadata: None,
})), })),
}) })
} }
@@ -274,6 +278,45 @@ impl Download {
let state = self.state.read().await; let state = self.state.read().await;
state.error.clone() state.error.clone()
} }
pub async fn transform_metadata(&self) -> Option<TransformMetadata> {
let state = self.state.read().await;
state.transform_metadata.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct TransformMetadata {
pub mode: Option<String>,
pub input_codec: Option<String>,
pub details: Option<String>,
}
pub struct TransformContext {
state: Arc<RwLock<DownloadState>>,
progress_cb: Arc<dyn Fn(u64) + Send + Sync>,
}
impl TransformContext {
fn new(state: Arc<RwLock<DownloadState>>, progress_cb: Arc<dyn Fn(u64) + Send + Sync>) -> Self {
Self { state, progress_cb }
}
/// Reports progress (in bytes) to the download state.
pub fn report_progress(&self, bytes: u64) {
(self.progress_cb)(bytes);
}
/// Returns the underlying progress callback (useful for piping into other APIs).
pub fn progress_callback(&self) -> Arc<dyn Fn(u64) + Send + Sync> {
Arc::clone(&self.progress_cb)
}
/// Stores metadata describing the transformation that occurred.
pub async fn set_metadata(&self, metadata: TransformMetadata) {
let mut state = self.state.write().await;
state.transform_metadata = Some(metadata);
}
} }
/// Lance le téléchargement d'une URL dans un fichier. /// Lance le téléchargement d'une URL dans un fichier.
@@ -402,7 +445,9 @@ async fn process_input(
}); });
}); });
match transformer(input, file, Arc::clone(&progress_callback)).await { let context = Arc::new(TransformContext::new(Arc::clone(&state), progress_callback));
match transformer(input, file, Arc::clone(&context)).await {
Ok(_) => { Ok(_) => {
let mut s = state.write().await; let mut s = state.write().await;
if s.current_size == 0 { if s.current_size == 0 {

View File

@@ -134,7 +134,7 @@ pub use cache_trait::{pk_from_content_header, FileCache};
pub use db::{CacheEntry, DB}; pub use db::{CacheEntry, DB};
pub use download::{ pub use download::{
download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header, download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header,
Download, StreamTransformer, Download, StreamTransformer, TransformContextHandle, TransformMetadata,
}; };
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]

View File

@@ -31,7 +31,7 @@ pub type Cache = pmocache::Cache<CoversConfig>;
/// ///
/// Convertit automatiquement toute image téléchargée en format WebP /// Convertit automatiquement toute image téléchargée en format WebP
fn create_webp_transformer() -> StreamTransformer { fn create_webp_transformer() -> StreamTransformer {
Box::new(|mut input, mut file, progress| { Box::new(|mut input, mut file, context| {
Box::pin(async move { Box::pin(async move {
// Télécharger tout en mémoire // Télécharger tout en mémoire
let bytes = input.bytes().await?; let bytes = input.bytes().await?;
@@ -48,7 +48,7 @@ fn create_webp_transformer() -> StreamTransformer {
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
file.flush().await.map_err(|e| e.to_string())?; file.flush().await.map_err(|e| e.to_string())?;
progress(webp_data.len() as u64); context.report_progress(webp_data.len() as u64);
Ok(()) Ok(())
}) })

View File

@@ -187,6 +187,7 @@ impl QobuzSource {
sample_rate: track.sample_rate, sample_rate: track.sample_rate,
channels: track.channels, channels: track.channels,
bitrate: None, bitrate: None,
conversion: None,
}; };
// 3. Cache audio via manager // 3. Cache audio via manager

180
tools/sniff_upnp_devices Executable file
View File

@@ -0,0 +1,180 @@
#!/bin/bash
# UPnP Device Sniffer avec déduplication
# Découverte et analyse des devices UPnP sur le réseau
set -e
# Couleurs pour l'affichage
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Configuration
INTERFACE="${1:-en0}"
TIMEOUT="${2:-5}"
TEMP_FILE=$(mktemp)
SEEN_FILE=$(mktemp)
echo -e "${CYAN}"
echo "╔══════════════════════════════════════╗"
echo "║ UPnP Device Sniffer PRO ║"
echo "║ Avec déduplication des devices ║"
echo "╚══════════════════════════════════════╝"
echo -e "${NC}"
# Nettoyage à la sortie
cleanup() {
rm -f "$TEMP_FILE" "$SEEN_FILE"
echo -e "${YELLOW}🧹 Fichiers temporaires nettoyés.${NC}"
}
trap cleanup EXIT
# Fonction pour extraire les codecs audio
extract_audio_codecs() {
grep -i "protocolInfo" | grep -i "audio" | \
sed 's/.*\(audio.*\)/\1/' | sort -u | head -8
}
# Fonction pour formater l'URL
format_url() {
echo "$1" | sed 's/.*http/http/' | sed 's/\/$//'
}
# Fonction pour normaliser le nom du device
normalize_name() {
echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]//g'
}
# Fonction pour vérifier si un device a déjà été vu
is_device_seen() {
local key="$1"
grep -q "^$key$" "$SEEN_FILE" 2>/dev/null
}
# Fonction pour marquer un device comme vu
mark_device_seen() {
local key="$1"
echo "$key" >> "$SEEN_FILE"
}
echo -e "${YELLOW}🔍 Recherche des devices UPnP sur $INTERFACE (${TIMEOUT}s)...${NC}"
echo -e "${YELLOW}📡 Filtrage des doublons activé...${NC}"
echo
# Phase 1: Collecte des devices avec déduplication
echo -e "${BLUE}📦 Phase 1: Collecte des devices...${NC}"
DEVICE_COUNT=0
gssdp-discover --timeout $TIMEOUT -i $INTERFACE 2>/dev/null | \
while IFS= read -r line; do
if [[ $line == *"Location:"* ]]; then
url=$(format_url "$(echo "$line" | awk '{print $2}')")
# Récupération des infos de base pour la déduplication
if curl_output=$(curl -s --connect-timeout 2 "$url" 2>/dev/null); then
friendly_name=$(echo "$curl_output" | grep -i "friendlyName" | head -1 | sed 's/.*<friendlyName>\(.*\)<\/friendlyName>.*/\1/')
model_name=$(echo "$curl_output" | grep -i "modelName" | head -1 | sed 's/.*<modelName>\(.*\)<\/modelName>.*/\1/')
# Création d'une clé unique pour la déduplication
device_key=$(normalize_name "${friendly_name}-${model_name}")
# Vérifier si on a déjà vu ce device
if ! is_device_seen "$device_key"; then
mark_device_seen "$device_key"
((DEVICE_COUNT++))
# Stocker les données pour l'affichage
echo "===DEVICE_START===" >> "$TEMP_FILE"
echo "URL:$url" >> "$TEMP_FILE"
echo "FRIENDLY_NAME:$friendly_name" >> "$TEMP_FILE"
echo "MODEL:$model_name" >> "$TEMP_FILE"
echo "MANUFACTURER:$(echo "$curl_output" | grep -i "manufacturer" | head -1 | sed 's/.*<manufacturer>\(.*\)<\/manufacturer>.*/\1/')" >> "$TEMP_FILE"
echo "CURL_OUTPUT:$curl_output" >> "$TEMP_FILE"
echo "===DEVICE_END===" >> "$TEMP_FILE"
else
echo -e "${YELLOW} ⚡ Doublon ignoré: $friendly_name${NC}" >&2
fi
fi
fi
done
# Phase 2: Affichage organisé des devices uniques
echo -e "${BLUE}📊 Phase 2: Analyse des devices uniques...${NC}"
echo
FINAL_COUNT=0
# Lire le fichier temporaire et afficher les résultats
while IFS= read -r line; do
case $line in
"===DEVICE_START===")
unset url friendly_name model_name manufacturer curl_output
;;
"===DEVICE_END===")
if [[ -n "$url" ]]; then
((FINAL_COUNT++))
echo -e "${GREEN}╔══════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Device #$FINAL_COUNT${NC}"
echo -e "${GREEN}║ URL: $url${NC}"
echo -e "${GREEN}╚══════════════════════════════════════╗${NC}"
echo -e "${BLUE}📝 Informations:${NC}"
echo -e " Nom: ${YELLOW}${friendly_name:-Non trouvé}${NC}"
echo -e " Modèle: ${YELLOW}${model_name:-Non trouvé}${NC}"
echo -e " Fabricant: ${YELLOW}${manufacturer:-Non trouvé}${NC}"
# Codecs audio supportés
echo -e "${BLUE}🎵 Codecs audio:${NC}"
audio_codecs=$(echo "$curl_output" | extract_audio_codecs)
if [ -n "$audio_codecs" ]; then
echo "$audio_codecs" | while read codec; do
echo -e " ✅ ${GREEN}$codec${NC}"
done
else
echo -e " ❌ ${RED}Aucun codec audio détecté${NC}"
fi
# Services détectés
echo -e "${BLUE}🔌 Services principaux:${NC}"
services=$(echo "$curl_output" | grep -i "serviceType" | sed 's/.*<serviceType>\(.*\)<\/serviceType>.*/\1/' | head -3)
if [ -n "$services" ]; then
echo "$services" | while read service; do
echo -e " 🔧 ${PURPLE}$service${NC}"
done
fi
echo
fi
;;
URL:*)
url="${line#URL:}"
;;
FRIENDLY_NAME:*)
friendly_name="${line#FRIENDLY_NAME:}"
;;
MODEL:*)
model_name="${line#MODEL:}"
;;
MANUFACTURER:*)
manufacturer="${line#MANUFACTURER:}"
;;
CURL_OUTPUT:*)
curl_output="${line#CURL_OUTPUT:}"
;;
esac
done < "$TEMP_FILE"
# Résumé final
echo -e "${CYAN}"
echo "╔══════════════════════════════════════╗"
echo "║ SCAN TERMINÉ ║"
echo "╠══════════════════════════════════════╣"
echo "║ Devices uniques: $FINAL_COUNT ║"
echo "╚══════════════════════════════════════╝"
echo -e "${NC}"