push-mqttpywkpspw #19
@@ -102,6 +102,9 @@
|
||||
<span v-if="track.metadata?.bitrate">
|
||||
{{ formatBitrate(track.metadata.bitrate) }}
|
||||
</span>
|
||||
<span v-if="conversionLabel(track)">
|
||||
{{ conversionLabel(track) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="collection" v-if="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.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="conversionLabel(selectedTrack)"><strong>Conversion:</strong> {{ conversionLabel(selectedTrack) }}</p>
|
||||
</div>
|
||||
<div class="cache-section">
|
||||
<h4>Cache Info</h4>
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,6 +68,23 @@ pub struct AudioMetadata {
|
||||
/// Bitrate moyen (kbps)
|
||||
#[cfg_attr(feature = "pmoserver", schema(example = 1411))]
|
||||
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 {
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Bytes, String>>,
|
||||
|
||||
@@ -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())?;");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<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
|
||||
@@ -134,30 +150,74 @@ pub async fn get_download_status<C: CacheConfig>(
|
||||
State(cache): State<Arc<Cache<C>>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> 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<C: CacheConfig>(
|
||||
expected_size,
|
||||
finished,
|
||||
error,
|
||||
conversion,
|
||||
};
|
||||
|
||||
(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
|
||||
///
|
||||
/// Télécharge l'item depuis l'URL fournie et l'ajoute au cache.
|
||||
|
||||
@@ -94,9 +94,10 @@ impl<C: CacheConfig> Cache<C> {
|
||||
///
|
||||
/// 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<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é
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -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<dyn Fn(u64) + Send + Sync>,
|
||||
TransformContextHandle,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>>
|
||||
+ Send,
|
||||
>;
|
||||
|
||||
pub type TransformContextHandle = Arc<TransformContext>;
|
||||
|
||||
type ByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, String>> + 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<String>,
|
||||
transform_metadata: Option<TransformMetadata>,
|
||||
}
|
||||
|
||||
/// 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<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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -31,7 +31,7 @@ pub type Cache = pmocache::Cache<CoversConfig>;
|
||||
///
|
||||
/// 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(())
|
||||
})
|
||||
|
||||
@@ -187,6 +187,7 @@ impl QobuzSource {
|
||||
sample_rate: track.sample_rate,
|
||||
channels: track.channels,
|
||||
bitrate: None,
|
||||
conversion: None,
|
||||
};
|
||||
|
||||
// 3. Cache audio via manager
|
||||
|
||||
180
tools/sniff_upnp_devices
Executable file
180
tools/sniff_upnp_devices
Executable 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}"
|
||||
Reference in New Issue
Block a user