diff --git a/pmoapp/webapp/src/components/AudioCacheManager.vue b/pmoapp/webapp/src/components/AudioCacheManager.vue
index 987d2eb7..e2613829 100644
--- a/pmoapp/webapp/src/components/AudioCacheManager.vue
+++ b/pmoapp/webapp/src/components/AudioCacheManager.vue
@@ -102,6 +102,9 @@
{{ formatBitrate(track.metadata.bitrate) }}
+
+ {{ conversionLabel(track) }}
+
{{ track.collection }}
@@ -153,6 +156,7 @@
Sample Rate: {{ formatSampleRate(selectedTrack.metadata.sample_rate) }}
Bitrate: {{ formatBitrate(selectedTrack.metadata.bitrate) }}
Channels: {{ selectedTrack.metadata.channels }}
+
Conversion: {{ conversionLabel(selectedTrack) }}
Cache Info
@@ -414,6 +418,37 @@ function formatDate(dateString: string) {
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(() => {
refreshTracks();
});
diff --git a/pmoapp/webapp/src/services/audioCache.ts b/pmoapp/webapp/src/services/audioCache.ts
index 18399480..5f35abba 100644
--- a/pmoapp/webapp/src/services/audioCache.ts
+++ b/pmoapp/webapp/src/services/audioCache.ts
@@ -18,6 +18,7 @@ export interface AudioCacheMetadata {
sample_rate?: number;
bitrate?: number;
channels?: number;
+ conversion?: ConversionInfo;
[key: string]: unknown;
}
@@ -30,6 +31,12 @@ export interface AudioCacheEntry {
metadata?: AudioCacheMetadata | null;
}
+export interface ConversionInfo {
+ mode: string;
+ input_codec?: string;
+ details?: string;
+}
+
export interface AddTrackRequest {
url: string;
collection?: string;
@@ -43,9 +50,13 @@ export interface AddTrackResponse {
export interface DownloadStatus {
pk: string;
- status: "pending" | "downloading" | "completed" | "failed";
- progress?: number;
+ in_progress: boolean;
+ finished: boolean;
+ current_size?: number;
+ transformed_size?: number;
+ expected_size?: number;
error?: string;
+ conversion?: ConversionInfo;
}
export interface ApiError {
diff --git a/pmoaudiocache/src/cache.rs b/pmoaudiocache/src/cache.rs
index 731d091a..5f168ebb 100644
--- a/pmoaudiocache/src/cache.rs
+++ b/pmoaudiocache/src/cache.rs
@@ -103,7 +103,16 @@ pub async fn add_with_metadata_extraction(
let flac_bytes = tokio::fs::read(&file_path).await?;
// 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)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
// Stocker dans la DB
diff --git a/pmoaudiocache/src/metadata.rs b/pmoaudiocache/src/metadata.rs
index abfcd454..51cea0d8 100644
--- a/pmoaudiocache/src/metadata.rs
+++ b/pmoaudiocache/src/metadata.rs
@@ -68,6 +68,23 @@ pub struct AudioMetadata {
/// Bitrate moyen (kbps)
#[cfg_attr(feature = "pmoserver", schema(example = 1411))]
pub bitrate: Option
,
+
+ /// 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,
+}
+
+/// 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,
}
impl AudioMetadata {
@@ -94,6 +111,7 @@ impl AudioMetadata {
sample_rate: properties.sample_rate(),
channels: properties.channels(),
bitrate: properties.audio_bitrate(),
+ conversion: None,
};
if let Some(tag) = tag {
@@ -232,6 +250,7 @@ impl Default for AudioMetadata {
sample_rate: None,
channels: None,
bitrate: None,
+ conversion: None,
}
}
}
@@ -256,6 +275,7 @@ mod tests {
sample_rate: Some(44100),
channels: Some(2),
bitrate: Some(1411),
+ conversion: None,
};
assert_eq!(
@@ -280,6 +300,7 @@ mod tests {
sample_rate: None,
channels: None,
bitrate: None,
+ conversion: None,
};
assert_eq!(metadata.collection_key(), None);
diff --git a/pmoaudiocache/src/streaming.rs b/pmoaudiocache/src/streaming.rs
index 8007adf9..86e9f40a 100644
--- a/pmoaudiocache/src/streaming.rs
+++ b/pmoaudiocache/src/streaming.rs
@@ -6,13 +6,14 @@
//! while native FLAC input is forwarded byte-for-byte without re-encoding.
use bytes::Bytes;
+use pmocache::download::TransformMetadata;
use pmocache::StreamTransformer;
use pmoflac::{transcode_to_flac_stream, AudioCodec, TranscodeOptions};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// Creates the transformer consumed by the audio cache.
pub fn create_streaming_flac_transformer() -> StreamTransformer {
- Box::new(|input, mut file, progress| {
+ Box::new(|input, mut file, context| {
Box::pin(async move {
let byte_stream = input.into_byte_stream();
let reader = StreamToAsyncRead::new(byte_stream);
@@ -21,7 +22,23 @@ pub fn create_streaming_flac_transformer() -> StreamTransformer {
.await
.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 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))?;
total_written += read as u64;
- progress(total_written);
+ context.report_progress(total_written);
}
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`.
struct StreamToAsyncRead {
stream: futures_util::stream::BoxStream<'static, Result>,
diff --git a/pmocache/examples/simple_transformer.rs b/pmocache/examples/simple_transformer.rs
index ac805b7a..dae705d0 100644
--- a/pmocache/examples/simple_transformer.rs
+++ b/pmocache/examples/simple_transformer.rs
@@ -11,9 +11,7 @@ fn main() {
println!(" dl.wait_until_finished().await?;\n");
println!("2. Téléchargement avec transformation:");
- println!(
- " let transformer: StreamTransformer = Box::new(|response, mut file, update_progress| {{"
- );
+ println!(" let transformer: StreamTransformer = Box::new(|response, mut file, ctx| {{");
println!(" Box::pin(async move {{");
println!(" let mut stream = response.bytes_stream();");
println!(" let mut total = 0u64;");
@@ -26,7 +24,7 @@ fn main() {
println!();
println!(" file.write_all(&transformed).await.map_err(|e| e.to_string())?;");
println!(" total += transformed.len() as u64;");
- println!(" update_progress(total);");
+ println!(" ctx.report_progress(total);");
println!(" }}");
println!();
println!(" file.flush().await.map_err(|e| e.to_string())?;");
diff --git a/pmocache/examples/test_download_transformer.rs b/pmocache/examples/test_download_transformer.rs
index 0d30645c..0e7bc937 100644
--- a/pmocache/examples/test_download_transformer.rs
+++ b/pmocache/examples/test_download_transformer.rs
@@ -19,7 +19,7 @@ fn create_gzip_transformer() -> StreamTransformer {
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 {
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))?;
total_written += chunk.len() as u64;
- update_progress(total_written);
+ context.report_progress(total_written);
}
encoder
@@ -52,7 +52,7 @@ fn create_gzip_transformer() -> StreamTransformer {
/// Exemple de transformer qui convertit les données en majuscules (exemple simple)
fn create_uppercase_transformer() -> StreamTransformer {
- Box::new(|input, mut file, update_progress| {
+ Box::new(|input, mut file, context| {
Box::pin(async move {
let mut stream = input.into_byte_stream();
let mut total_written = 0u64;
@@ -77,7 +77,7 @@ fn create_uppercase_transformer() -> StreamTransformer {
.map_err(|e| format!("Failed to write: {}", e))?;
total_written += transformed.len() as u64;
- update_progress(total_written);
+ context.report_progress(total_written);
}
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)
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 {
let mut stream = input.into_byte_stream();
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))?;
total_written += to_write.len() as u64;
- update_progress(total_written);
+ context.report_progress(total_written);
}
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
fn create_line_number_transformer() -> StreamTransformer {
- Box::new(|input, mut file, update_progress| {
+ Box::new(|input, mut file, context| {
Box::pin(async move {
let mut stream = input.into_byte_stream();
let mut line_number = 1u32;
@@ -162,7 +162,7 @@ fn create_line_number_transformer() -> StreamTransformer {
.map_err(|e| format!("Failed to write: {}", e))?;
total_written += numbered_line.len() as u64 + line.len() as u64 + 1;
- update_progress(total_written);
+ context.report_progress(total_written);
line_number += 1;
buffer.drain(..=newline_pos);
@@ -181,7 +181,7 @@ fn create_line_number_transformer() -> StreamTransformer {
.map_err(|e| format!("Failed to write: {}", e))?;
total_written += numbered_line.len() as u64 + buffer.len() as u64;
- update_progress(total_written);
+ context.report_progress(total_written);
}
file.flush()
diff --git a/pmocache/src/api.rs b/pmocache/src/api.rs
index 4d7cae53..87c5e953 100644
--- a/pmocache/src/api.rs
+++ b/pmocache/src/api.rs
@@ -15,6 +15,7 @@ use axum::{
Json,
};
use serde::{Deserialize, Serialize};
+use serde_json::Value;
use std::sync::Arc;
#[cfg(feature = "openapi")]
@@ -39,6 +40,21 @@ pub struct DownloadStatus {
pub finished: bool,
/// Erreur éventuelle
pub error: Option,
+ /// Informations sur la conversion
+ pub conversion: Option,
+}
+
+/// 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,
+ /// Informations complémentaires lisibles (optionnel)
+ pub details: Option,
}
/// Requête pour ajouter un item au cache
@@ -134,30 +150,74 @@ pub async fn get_download_status(
State(cache): State>>,
Path(pk): Path,
) -> impl IntoResponse {
- // Vérifier que l'item existe dans la DB
- if cache.db.get(&pk, false).is_err() {
- return (
- StatusCode::NOT_FOUND,
- Json(ErrorResponse {
- error: "NOT_FOUND".to_string(),
- message: format!("Item with pk '{}' not found in cache", pk),
- }),
- )
- .into_response();
- }
+ let entry = match cache.db.get(&pk, false) {
+ Ok(entry) => entry,
+ Err(_) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ErrorResponse {
+ error: "NOT_FOUND".to_string(),
+ message: format!("Item with pk '{}' not found in cache", pk),
+ }),
+ )
+ .into_response();
+ }
+ };
- let in_progress = cache.get_download(&pk).await.is_some();
- let current_size = cache.current_size(&pk).await;
- let transformed_size = cache.transformed_size(&pk).await;
- let expected_size = cache.expected_size(&pk).await;
- let finished = cache.is_finished(&pk).await;
+ let download = cache.get_download(&pk).await;
+ let file_path = cache.get_file_path(&pk);
+ let file_size = if file_path.exists() {
+ std::fs::metadata(&file_path).ok().map(|m| m.len())
+ } 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
} else {
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 {
pk,
in_progress,
@@ -166,11 +226,28 @@ pub async fn get_download_status(
expected_size,
finished,
error,
+ conversion,
};
(StatusCode::OK, Json(status)).into_response()
}
+impl From 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 {
+ value
+ .get("conversion")
+ .and_then(|conv| serde_json::from_value(conv.clone()).ok())
+}
+
/// Ajoute un item au cache depuis une URL
///
/// Télécharge l'item depuis l'URL fournie et l'ajoute au cache.
diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs
index dd72fdfe..afc40f84 100644
--- a/pmocache/src/cache.rs
+++ b/pmocache/src/cache.rs
@@ -94,9 +94,10 @@ impl Cache {
///
/// let transformer_factory = Arc::new(|| {
/// // Créer un transformer qui convertit les données
- /// Box::new(|input, file, progress| {
+ /// Box::new(|input, file, ctx| {
/// Box::pin(async move {
/// // Transformation personnalisée
+ /// ctx.report_progress(0);
/// Ok(())
/// })
/// }) as StreamTransformer
@@ -610,6 +611,15 @@ impl Cache {
}
}
+ /// Retourne les métadonnées de transformation (si disponibles)
+ pub async fn transform_metadata(&self, pk: &str) -> Option {
+ if let Some(download) = self.get_download(pk).await {
+ download.transform_metadata().await
+ } else {
+ None
+ }
+ }
+
/// Indique si le téléchargement est terminé
///
/// # Arguments
diff --git a/pmocache/src/download.rs b/pmocache/src/download.rs
index ab18607a..636e89b6 100644
--- a/pmocache/src/download.rs
+++ b/pmocache/src/download.rs
@@ -15,18 +15,20 @@ use tokio_util::io::ReaderStream;
/// La fonction reçoit :
/// - Un `CacheInput` abstrait (HTTP ou lecteur en streaming)
/// - 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`.
pub type StreamTransformer = Box<
dyn FnOnce(
CacheInput,
tokio::fs::File,
- Arc,
+ TransformContextHandle,
) -> Pin> + Send>>
+ Send,
>;
+pub type TransformContextHandle = Arc;
+
type ByteStream = Pin> + Send>>;
/// Source générique (HTTP ou lecteur) exposée aux transformers.
@@ -180,6 +182,7 @@ struct DownloadState {
finished: bool,
read_position: u64,
error: Option,
+ transform_metadata: Option,
}
/// Objet représentant un téléchargement en cours
@@ -200,6 +203,7 @@ impl Download {
finished: false,
read_position: 0,
error: None,
+ transform_metadata: None,
})),
})
}
@@ -274,6 +278,45 @@ impl Download {
let state = self.state.read().await;
state.error.clone()
}
+
+ pub async fn transform_metadata(&self) -> Option {
+ let state = self.state.read().await;
+ state.transform_metadata.clone()
+ }
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct TransformMetadata {
+ pub mode: Option,
+ pub input_codec: Option,
+ pub details: Option,
+}
+
+pub struct TransformContext {
+ state: Arc>,
+ progress_cb: Arc,
+}
+
+impl TransformContext {
+ fn new(state: Arc>, progress_cb: Arc) -> 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 {
+ 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.
@@ -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(_) => {
let mut s = state.write().await;
if s.current_size == 0 {
diff --git a/pmocache/src/lib.rs b/pmocache/src/lib.rs
index d6fac890..0c0843c4 100644
--- a/pmocache/src/lib.rs
+++ b/pmocache/src/lib.rs
@@ -134,7 +134,7 @@ pub use cache_trait::{pk_from_content_header, FileCache};
pub use db::{CacheEntry, DB};
pub use download::{
download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header,
- Download, StreamTransformer,
+ Download, StreamTransformer, TransformContextHandle, TransformMetadata,
};
#[cfg(feature = "pmoserver")]
diff --git a/pmocovers/src/cache.rs b/pmocovers/src/cache.rs
index 683ffd39..1cf5466d 100644
--- a/pmocovers/src/cache.rs
+++ b/pmocovers/src/cache.rs
@@ -31,7 +31,7 @@ pub type Cache = pmocache::Cache;
///
/// Convertit automatiquement toute image téléchargée en format WebP
fn create_webp_transformer() -> StreamTransformer {
- Box::new(|mut input, mut file, progress| {
+ Box::new(|mut input, mut file, context| {
Box::pin(async move {
// Télécharger tout en mémoire
let bytes = input.bytes().await?;
@@ -48,7 +48,7 @@ fn create_webp_transformer() -> StreamTransformer {
.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(())
})
diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs
index 6bcdffcb..e0916f7b 100644
--- a/pmoqobuz/src/source.rs
+++ b/pmoqobuz/src/source.rs
@@ -187,6 +187,7 @@ impl QobuzSource {
sample_rate: track.sample_rate,
channels: track.channels,
bitrate: None,
+ conversion: None,
};
// 3. Cache audio via manager
diff --git a/tools/sniff_upnp_devices b/tools/sniff_upnp_devices
new file mode 100755
index 00000000..c7ed5d77
--- /dev/null
+++ b/tools/sniff_upnp_devices
@@ -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>.*/\1/')
+ model_name=$(echo "$curl_output" | grep -i "modelName" | head -1 | sed 's/.*\(.*\)<\/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>.*/\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>.*/\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}"