From 68b092673b7c0afc298802877e546aba4f43ca87 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 28 Oct 2025 00:10:51 +0100 Subject: [PATCH] Update la web app pour tirer partie du nouveau systeme de cache --- .../src/components/AudioCacheManager.vue | 35 ++++ pmoapp/webapp/src/services/audioCache.ts | 15 +- pmoaudiocache/src/cache.rs | 11 +- pmoaudiocache/src/metadata.rs | 21 ++ pmoaudiocache/src/streaming.rs | 35 +++- pmocache/examples/simple_transformer.rs | 6 +- .../examples/test_download_transformer.rs | 18 +- pmocache/src/api.rs | 111 +++++++++-- pmocache/src/cache.rs | 12 +- pmocache/src/download.rs | 51 ++++- pmocache/src/lib.rs | 2 +- pmocovers/src/cache.rs | 4 +- pmoqobuz/src/source.rs | 1 + tools/sniff_upnp_devices | 180 ++++++++++++++++++ 14 files changed, 459 insertions(+), 43 deletions(-) create mode 100755 tools/sniff_upnp_devices 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}"