push-yvrpomtmmmpy #16

Merged
eric merged 62 commits from push-yvrpomtmmmpy into main 2025-10-20 16:20:44 +02:00
346 changed files with 48717 additions and 2137 deletions

BIN
.DS_Store vendored

Binary file not shown.

4
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.ollama
.ollamacode
/bin/
/pkg/
/vendor/
@@ -21,3 +23,5 @@ xxx
xx
all.txt
pmo_src.txt
upmpdcli/
/*.xml

View File

@@ -3,13 +3,26 @@ host:
cover_cache:
directory: ./.pmomusic_covers
size: 2000
devices:
audio_cache:
directory: ./.pmomusic_audio
size: 500
logger:
buffer_capacity: 200
enable_console: true
min_level: TRACE
mediarenderer:
mpd_renderer: null
fakerenderer:
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
mediarenderer:
udn: f9ef6c21-0ed3-470c-9846-bc1ae85fea62
mediaserver:
qobuz:
udn: 28963b75-4c5f-4da7-b10e-ffafd
udn: uuid:28963b75-4c5f-4da7-b10e-ffafd
accounts:
qobuz:
username: eric@coissac.eu
password: '*Misfcr73110$'
devices:
mediarenderer:
pmo_mediarenderer:
udn: 15a13316-daac-47f0-b64e-47e56f5e3b51
mediaserver:
pmo_mediaserver:
udn: 23df0bfa-cfef-4724-b731-00f66fadf176

BIN
.pmomusic_audio/cache.db Normal file

Binary file not shown.

Binary file not shown.

1137
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,21 @@
[workspace]
resolver = "3"
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocovers"]
members = [
"PMOMusic",
"pmoupnp",
"pmomediarenderer",
"pmomediaserver",
"pmoconfig",
"pmoutils",
"pmodidl",
"pmoserver",
"pmoapp",
"pmocache",
"pmocovers",
"pmoaudiocache",
"pmoaudio",
"pmoqobuz",
"pmoparadise",
"pmosource",
"pmoplaylist",
]

View File

@@ -6,13 +6,17 @@ edition = "2024"
[dependencies]
pmoconfig = { path = "../pmoconfig" }
pmoupnp = { path = "../pmoupnp"}
pmomediarenderer = { path = "../pmomediarenderer" }
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] }
pmosource = { path = "../pmosource", features = ["server"] }
pmoserver = { path = "../pmoserver" }
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
axum = "0.8.4"
serde_json = "1.0.145"
utoipa = "5.4"

View File

@@ -1,70 +1,89 @@
use pmoupnp::{
mediarenderer::MEDIA_RENDERER,
ssdp::SsdpServer,
UpnpServer,
UpnpModel,
};
use pmoserver::{
logs::LoggingOptions,
ServerBuilder
};
use pmoapp::{Webapp, WebAppExt};
use pmocovers::CoverCacheExt;
use pmoapp::{WebAppExt, Webapp};
use pmomediarenderer::MEDIA_RENDERER;
use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt};
use pmoserver::Server;
use pmosource::MusicSourceExt;
use pmoupnp::{UpnpServerExt, upnp_api::UpnpApiExt};
use tracing::info;
#[tokio::main]
async fn main() {
// Créer le serveur
let mut server = ServerBuilder::new_configured().build();
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// ========== PHASE 1 : Infrastructure UPnP ==========
let mut server = Server::create_upnp_server().await?;
// Initialiser le logging et enregistrer les routes de logs
server.init_logging(LoggingOptions::default()).await;
info!("📡 Registering the cover cache...");
let cache = server.init_cover_cache_configured()
.await
.expect("Cannot initialise the image cache");
info!("✅ Cover cache ready at {}",
cache.cache_dir(),
);
// Routes de base
// Routes personnalisées de l'application
server
.add_route("/info", || async {
serde_json::json!({"version": "1.0.0"})
})
.await;
// Initialiser le système de gestion des sources musicales avec API REST
info!("📡 Initializing music sources management system...");
server
.init_music_sources()
.await
.expect("Failed to initialize music sources API");
// ========== PHASE 2 : Configuration métier ==========
// Enregistrer les sources musicales
info!("🎵 Registering music sources...");
// // Enregistrer Qobuz
// if let Err(e) = server.register_qobuz().await {
// tracing::warn!("⚠️ Failed to register Qobuz: {}", e);
// }
// Enregistrer Radio Paradise (inclut l'initialisation de l'API)
if let Err(e) = server.register_paradise().await {
tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e);
}
// Lister toutes les sources enregistrées
let sources = server.list_music_sources().await;
info!("✅ {} music source(s) registered", sources.len());
for source in sources {
info!(" - {} ({})", source.name(), source.id());
}
// Enregistrer les devices UPnP (HTTP + SSDP automatique)
info!("📡 Registering UPnP devices...");
let renderer_instance = server
.register_device(MEDIA_RENDERER.clone())
.await
.expect("Failed to register MediaRenderer");
info!(
"✅ MediaRenderer ready at {}{}",
renderer_instance.base_url(),
renderer_instance.description_route()
);
let server_instance = server
.register_device(MEDIA_SERVER.clone())
.await
.expect("Failed to register MediaServer");
info!(
"✅ MediaServer ready at {}{}",
server_instance.base_url(),
server_instance.description_route()
);
// Ajouter la webapp via le trait WebAppExt
info!("📡 Registering Web application...");
server.add_webapp_with_redirect::<Webapp>("/app").await;
info!("📡 Registering MediaRenderer...");
let renderer_instance = server.register_device(MEDIA_RENDERER.clone())
.await
.expect("Failed to register MediaRenderer routes");
info!("✅ MediaRenderer ready at {}{}",
renderer_instance.base_url(),
renderer_instance.description_route()
);
// Créer et démarrer le serveur SSDP
info!("📡 Starting SSDP discovery...");
let mut ssdp_server = SsdpServer::new();
ssdp_server.start().expect("Failed to start SSDP server");
// Créer et enregistrer le device SSDP pour le MediaRenderer
let ssdp_device = renderer_instance
.to_ssdp_device("PMOMusic", "1.0");
ssdp_server.add_device(ssdp_device);
info!("✅ SSDP announcements sent for MediaRenderer");
// ========== PHASE 3 : Démarrage du serveur ==========
info!("🌐 Starting HTTP server...");
server.start().await;
info!("✅ PMOMusic is ready!");
info!("Press Ctrl+C to stop...");
server.wait().await;
Ok(())
}

BIN
audio_cache/audio_cache.db Normal file

Binary file not shown.

5
headers.txt Normal file
View File

@@ -0,0 +1,5 @@
HTTP/1.1 500 Internal Server Error
content-type: text/xml; charset="utf-8"
content-length: 597
date: Sun, 19 Oct 2025 19:06:41 GMT

View File

@@ -99,14 +99,13 @@
//!
//! ### Exemple basique
//!
//! ```rust,no_run
//! ```rust,ignore
//! use pmoapp::Webapp;
//! use pmoserver::ServerBuilder;
//!
//! #[tokio::main]
//! async fn main() {
//! let mut server = ServerBuilder::new("MyApp")
//! .http_port(8080)
//! let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080)
//! .build();
//!
//! // Ajouter la webapp comme Single Page Application
@@ -122,7 +121,7 @@
//!
//! ### Exemple avec logs SSE
//!
//! ```rust,no_run
//! ```rust,ignore
//! use pmoapp::Webapp;
//! use pmoserver::{ServerBuilder, logs::{LogState, SseLayer}};
//! use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -136,7 +135,7 @@
//! .with(SseLayer::new(log_state.clone()))
//! .init();
//!
//! let mut server = ServerBuilder::new("MyApp").build();
//! let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080).build();
//!
//! // Endpoints SSE pour les logs
//! server.add_handler_with_state("/log-sse", pmoserver::logs::log_sse, log_state.clone()).await;
@@ -193,7 +192,7 @@
//!
//! Le composant LogView détecte automatiquement le XML dans les messages :
//!
//! ```
//! ```text
//! Input: "INFO: <?xml version=\"1.0\"?><scpd>...</scpd>"
//! Output: Bloc de code avec coloration syntaxique XML
//! ```
@@ -249,12 +248,12 @@ use std::pin::Pin;
///
/// ## Exemple
///
/// ```rust,no_run
/// ```rust,ignore
/// use pmoapp::{Webapp, WebAppExt};
/// use pmoserver::ServerBuilder;
///
/// # async fn example() {
/// let mut server = ServerBuilder::new("MyApp").build();
/// let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080).build();
///
/// // Ajouter la webapp via le trait WebAppExt
/// server.add_webapp::<Webapp>("/app").await;
@@ -298,7 +297,7 @@ pub trait WebAppExt {
/// # Type Parameter
///
/// * `W` - Type RustEmbed contenant les fichiers de la webapp
fn add_webapp<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
async fn add_webapp<W>(&mut self, path: &str)
where
W: RustEmbed + Clone + Send + Sync + 'static;
@@ -311,7 +310,7 @@ pub trait WebAppExt {
/// # Type Parameter
///
/// * `W` - Type RustEmbed contenant les fichiers de la webapp
fn add_webapp_with_redirect<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
async fn add_webapp_with_redirect<W>(&mut self, path: &str)
where
W: RustEmbed + Clone + Send + Sync + 'static;
}

View File

@@ -12,12 +12,12 @@
//!
//! ## Exemple d'utilisation
//!
//! ```rust,no_run
//! ```rust,ignore
//! use pmoapp::{Webapp, WebAppExt};
//! use pmoserver::ServerBuilder;
//!
//! # async fn example() {
//! let mut server = ServerBuilder::new("MyApp").build();
//! let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080).build();
//!
//! // Le trait WebAppExt est automatiquement disponible
//! server.add_webapp::<Webapp>("/app").await;
@@ -30,28 +30,24 @@
use crate::WebAppExt;
use pmoserver::Server;
use rust_embed::RustEmbed;
use std::future::Future;
use std::pin::Pin;
impl WebAppExt for Server {
fn add_webapp<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
async fn add_webapp<W>(&mut self, path: &str)
where
W: RustEmbed + Clone + Send + Sync + 'static,
{
let path = path.to_string();
Box::pin(async move {
self.add_spa::<W>(&path).await;
})
self.add_spa::<W>(&path).await;
}
fn add_webapp_with_redirect<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
async fn add_webapp_with_redirect<W>(&mut self, path: &str)
where
W: RustEmbed + Clone + Send + Sync + 'static,
{
let path = path.to_string();
Box::pin(async move {
self.add_spa::<W>(&path).await;
self.add_redirect("/", &path).await;
})
self.add_spa::<W>(&path).await;
self.add_redirect("/", &path).await;
}
}

Binary file not shown.

View File

@@ -1,30 +1,203 @@
<template>
<div>
<nav>
<router-link to="/">Accueil</router-link> |
<router-link to="/logs">Logs</router-link> |
<router-link to="/covers-cache">Cover Cache</router-link>
<div class="app-container">
<nav class="main-nav">
<router-link to="/">🏠 Accueil</router-link>
<!-- Menu déroulant Debug -->
<div class="dropdown" @mouseenter="showDebugMenu = true" @mouseleave="showDebugMenu = false">
<button class="dropdown-toggle" :class="{ active: isDebugRoute }">
🔧 Debug
<span class="arrow">{{ showDebugMenu ? '▼' : '▶' }}</span>
</button>
<div v-show="showDebugMenu" class="dropdown-menu">
<router-link to="/logs" @click="showDebugMenu = false">📋 Logs</router-link>
<router-link to="/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
<router-link to="/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
<router-link to="/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link>
<router-link to="/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
<div class="submenu-divider">Sources</div>
<router-link to="/radio-paradise" @click="showDebugMenu = false">📻 Radio Paradise</router-link>
</div>
</div>
</nav>
<router-view />
<main class="main-content">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
// rien à importer
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
const showDebugMenu = ref(false)
const route = useRoute()
const isDebugRoute = computed(() => {
return ['/logs', '/upnp', '/covers-cache', '/audio-cache', '/api-dashboard', '/radio-paradise'].includes(route.path)
})
</script>
<style scoped>
nav {
.app-container {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.main-nav {
background: #333;
width: 100vw;
padding: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
a {
.main-nav a {
color: #eee;
margin: 0 0.5rem;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: all 0.2s;
text-decoration: none;
white-space: nowrap;
}
a.router-link-active {
.main-nav a:hover {
background: #555;
color: #fff;
}
.main-nav a.router-link-active {
background: #569cd6;
color: #fff;
font-weight: bold;
text-decoration: underline;
}
/* Dropdown menu */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-toggle {
color: #eee;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: all 0.2s;
background: transparent;
border: none;
cursor: pointer;
font-size: 1rem;
font-family: inherit;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.5rem;
}
.dropdown-toggle:hover {
background: #555;
color: #fff;
}
.dropdown-toggle.active {
background: #569cd6;
color: #fff;
font-weight: bold;
}
.dropdown-toggle .arrow {
font-size: 0.7em;
transition: transform 0.2s;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
background: #2d2d2d;
border: 1px solid #555;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
min-width: 200px;
margin-top: 0;
z-index: 1001;
display: flex;
flex-direction: column;
padding: 0.5rem 0;
}
.dropdown-menu a {
padding: 0.75rem 1rem;
color: #eee;
text-decoration: none;
transition: all 0.2s;
border-radius: 0;
display: block;
}
.dropdown-menu a:hover {
background: #555;
color: #fff;
}
.dropdown-menu a.router-link-active {
background: #569cd6;
color: #fff;
font-weight: bold;
}
.submenu-divider {
padding: 0.5rem 1rem;
margin-top: 0.5rem;
border-top: 1px solid #555;
color: #999;
font-size: 0.85em;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.main-content {
flex: 1;
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
}
/* Responsive pour petits écrans */
@media (max-width: 768px) {
.main-nav {
padding: 0.5rem;
}
.main-nav a {
font-size: 0.9rem;
padding: 0.4rem 0.8rem;
}
.dropdown-toggle {
font-size: 0.9rem;
padding: 0.4rem 0.8rem;
}
.dropdown-menu {
min-width: 180px;
}
.dropdown-menu a {
font-size: 0.9rem;
padding: 0.6rem 0.8rem;
}
}
</style>

View File

@@ -0,0 +1,449 @@
<template>
<div class="api-dashboard">
<div class="header">
<h1>API Dashboard</h1>
<p class="subtitle">Vue d'ensemble des APIs disponibles dans PMOMusic</p>
</div>
<div v-if="loading" class="loading">Chargement des APIs...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="dashboard-content">
<!-- Stats globales -->
<div class="stats-section">
<div class="stat-card">
<div class="stat-icon">🚀</div>
<div class="stat-info">
<div class="stat-value">{{ registry?.apis.length || 0 }}</div>
<div class="stat-label">APIs disponibles</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🔌</div>
<div class="stat-info">
<div class="stat-value">{{ registry?.total_endpoints || 0 }}</div>
<div class="stat-label">Endpoints totaux</div>
</div>
</div>
</div>
<!-- Liste des APIs -->
<div class="apis-grid">
<div
v-for="api in registry?.apis"
:key="api.name"
class="api-card"
>
<div class="api-header">
<div class="api-icon">{{ getApiIcon(api.name) }}</div>
<div class="api-title-section">
<h3>{{ api.title }}</h3>
<p class="api-name">{{ api.name }}</p>
</div>
<div class="api-version">v{{ api.version }}</div>
</div>
<div class="api-body">
<p v-if="api.description" class="api-description">
{{ api.description }}
</p>
<p v-else class="api-description empty">Aucune description disponible</p>
<div class="api-stats">
<div class="api-stat">
<span class="stat-icon">📍</span>
<span class="stat-text">{{ api.endpoint_count }} endpoints</span>
</div>
<div class="api-stat">
<span class="stat-icon">🔗</span>
<span class="stat-text">{{ api.path }}</span>
</div>
</div>
</div>
<div class="api-footer">
<a :href="api.swagger_ui_path" target="_blank" class="btn-swagger">
<span class="btn-icon">📖</span>
<span>Documentation Swagger</span>
</a>
<a :href="api.openapi_json_path" target="_blank" class="btn-json">
<span class="btn-icon">📄</span>
<span>Spec OpenAPI</span>
</a>
</div>
</div>
</div>
<!-- Message si aucune API -->
<div v-if="!registry?.apis || registry.apis.length === 0" class="empty-state">
<div class="empty-icon">🔍</div>
<h3>Aucune API enregistrée</h3>
<p>Les APIs seront affichées ici au fur et à mesure de leur enregistrement.</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
interface ApiRegistryEntry {
name: string;
path: string;
swagger_ui_path: string;
openapi_json_path: string;
endpoint_count: number;
version: string;
description?: string;
title: string;
}
interface ApiRegistry {
apis: ApiRegistryEntry[];
total_endpoints: number;
}
const loading = ref(true);
const error = ref<string | null>(null);
const registry = ref<ApiRegistry | null>(null);
async function fetchRegistry() {
try {
loading.value = true;
error.value = null;
const response = await fetch('/api/registry');
if (!response.ok) {
throw new Error(`Failed to fetch API registry: ${response.statusText}`);
}
registry.value = await response.json();
} catch (e: any) {
error.value = e.message || 'Failed to load API registry';
console.error('Error fetching API registry:', e);
} finally {
loading.value = false;
}
}
function getApiIcon(name: string): string {
const icons: Record<string, string> = {
covers: '🎨',
audio: '🎵',
sources: '📡',
upnp: '🔌',
devices: '📱',
cache: '💾',
mediaserver: '🎬',
renderer: '🎭',
};
return icons[name.toLowerCase()] || '🔧';
}
onMounted(() => {
fetchRegistry();
});
</script>
<style scoped>
.api-dashboard {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.header {
text-align: center;
margin-bottom: 3rem;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: #2c3e50;
font-weight: 700;
}
.subtitle {
font-size: 1.1rem;
color: #7f8c8d;
margin: 0;
}
.loading,
.error {
padding: 3rem;
text-align: center;
font-size: 1.2rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.error {
color: #e74c3c;
background: #fff5f5;
border: 2px solid #fc8181;
}
.dashboard-content {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Stats Section */
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
border-radius: 12px;
display: flex;
align-items: center;
gap: 1.5rem;
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 24px rgba(102, 126, 234, 0.4);
}
.stat-icon {
font-size: 3rem;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
line-height: 1;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 1rem;
opacity: 0.9;
font-weight: 500;
}
/* APIs Grid */
.apis-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.api-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
display: flex;
flex-direction: column;
}
.api-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
.api-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.api-icon {
font-size: 2.5rem;
line-height: 1;
}
.api-title-section {
flex: 1;
}
.api-title-section h3 {
margin: 0 0 0.25rem 0;
font-size: 1.3rem;
font-weight: 600;
}
.api-name {
margin: 0;
font-size: 0.9rem;
opacity: 0.9;
font-family: 'Courier New', monospace;
}
.api-version {
background: rgba(255, 255, 255, 0.2);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.api-body {
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.api-description {
color: #4a5568;
line-height: 1.6;
margin: 0;
}
.api-description.empty {
color: #a0aec0;
font-style: italic;
}
.api-stats {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: auto;
}
.api-stat {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
color: #718096;
}
.api-stat .stat-icon {
font-size: 1.2rem;
}
.api-stat .stat-text {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.api-footer {
display: grid;
grid-template-columns: 1fr 1fr;
border-top: 1px solid #e2e8f0;
}
.btn-swagger,
.btn-json {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
text-decoration: none;
font-weight: 600;
transition: background 0.2s ease;
color: #667eea;
font-size: 0.9rem;
}
.btn-swagger {
border-right: 1px solid #e2e8f0;
}
.btn-swagger:hover {
background: #f7fafc;
color: #5a67d8;
}
.btn-json {
color: #48bb78;
}
.btn-json:hover {
background: #f7fafc;
color: #38a169;
}
.btn-icon {
font-size: 1.2rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-state h3 {
color: #2c3e50;
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
}
.empty-state p {
color: #7f8c8d;
margin: 0;
}
/* Responsive */
@media (max-width: 768px) {
.api-dashboard {
padding: 1rem;
}
.header h1 {
font-size: 2rem;
}
.apis-grid {
grid-template-columns: 1fr;
}
.api-footer {
grid-template-columns: 1fr;
}
.btn-swagger {
border-right: none;
border-bottom: 1px solid #e2e8f0;
}
}
</style>

View File

@@ -0,0 +1,894 @@
<template>
<div class="audio-cache-manager">
<div class="header">
<h2>🎵 Audio Cache Manager</h2>
<div class="stats">
<span>{{ tracks.length }} tracks</span>
<span v-if="totalHits > 0">{{ totalHits }} hits</span>
</div>
</div>
<!-- Formulaire d'ajout -->
<div class="add-form">
<h3> Add New Track</h3>
<form @submit.prevent="handleAddTrack">
<div class="form-group">
<input
v-model="newTrackUrl"
type="url"
placeholder="https://example.com/track.flac"
required
:disabled="isAdding"
/>
<input
v-model="newTrackCollection"
type="text"
placeholder="Collection (optional)"
:disabled="isAdding"
class="collection-input"
/>
<button type="submit" :disabled="isAdding || !newTrackUrl">
{{ isAdding ? "Adding..." : "Add Track" }}
</button>
</div>
<p v-if="addError" class="error">{{ addError }}</p>
<p v-if="addSuccess" class="success">{{ addSuccess }}</p>
</form>
</div>
<!-- Contrôles -->
<div class="controls">
<div class="sort-controls">
<label>Sort by:</label>
<select v-model="sortBy">
<option value="hits">Most Used</option>
<option value="last_used">Recently Used</option>
<option value="recent">Recently Added</option>
</select>
</div>
<div class="actions">
<button @click="refreshTracks" :disabled="isLoading">
{{ isLoading ? "Loading..." : "Refresh" }}
</button>
<button @click="handleConsolidate" :disabled="isConsolidating" class="btn-secondary">
{{ isConsolidating ? "Consolidating..." : "Consolidate" }}
</button>
<button @click="handlePurge" class="btn-danger" :disabled="isPurging">
{{ isPurging ? "Purging..." : "Purge All" }}
</button>
</div>
</div>
<!-- Liste des pistes -->
<div v-if="isLoading && tracks.length === 0" class="loading-state">
Loading tracks...
</div>
<div v-else-if="tracks.length === 0" class="empty-state">
No tracks in cache. Add one using the form above!
</div>
<div v-else class="track-grid">
<div
v-for="track in sortedTracks"
:key="track.pk"
class="track-card"
@click="selectedTrack = track"
>
<div class="track-icon">
<div class="music-icon">🎵</div>
<div class="track-overlay">
<span class="hits">{{ track.hits }} plays</span>
</div>
</div>
<div class="track-info">
<div class="track-title">
{{ track.metadata?.title || "Unknown Title" }}
</div>
<div class="track-artist">
{{ track.metadata?.artist || "Unknown Artist" }}
</div>
<div class="track-album" v-if="track.metadata?.album">
{{ track.metadata.album }}
</div>
<div class="pk">{{ track.pk }}</div>
<div class="meta">
<span v-if="track.metadata?.duration_ms">
{{ formatDuration(track.metadata.duration_ms) }}
</span>
<span v-if="track.metadata?.sample_rate">
{{ formatSampleRate(track.metadata.sample_rate) }}
</span>
<span v-if="track.metadata?.bitrate">
{{ formatBitrate(track.metadata.bitrate) }}
</span>
</div>
<div class="collection" v-if="track.collection">
{{ track.collection }}
</div>
<div class="last-used" v-if="track.last_used">
Last used: {{ formatDate(track.last_used) }}
</div>
</div>
<div class="track-actions">
<button
@click.stop="playTrack(track.pk)"
class="btn-play"
title="Play"
>
</button>
<button
@click.stop="handleDeleteTrack(track.pk)"
class="btn-delete"
:disabled="deletingTracks.has(track.pk)"
title="Delete"
>
{{ deletingTracks.has(track.pk) ? "..." : "🗑️" }}
</button>
</div>
</div>
</div>
<!-- Modal de détails -->
<div v-if="selectedTrack" class="modal" @click="selectedTrack = null">
<div class="modal-content" @click.stop>
<button class="modal-close" @click="selectedTrack = null"></button>
<div class="modal-header">
<div class="modal-icon">🎵</div>
<h3>Track Details</h3>
</div>
<div class="modal-info">
<div class="metadata-section" v-if="selectedTrack.metadata">
<h4>Metadata</h4>
<p><strong>Title:</strong> {{ selectedTrack.metadata.title || "Unknown" }}</p>
<p><strong>Artist:</strong> {{ selectedTrack.metadata.artist || "Unknown" }}</p>
<p v-if="selectedTrack.metadata.album"><strong>Album:</strong> {{ selectedTrack.metadata.album }}</p>
<p v-if="selectedTrack.metadata.year"><strong>Year:</strong> {{ selectedTrack.metadata.year }}</p>
<p v-if="selectedTrack.metadata.genre"><strong>Genre:</strong> {{ selectedTrack.metadata.genre }}</p>
<p v-if="selectedTrack.metadata.track_number"><strong>Track:</strong> {{ selectedTrack.metadata.track_number }}</p>
<p v-if="selectedTrack.metadata.duration_ms"><strong>Duration:</strong> {{ formatDuration(selectedTrack.metadata.duration_ms) }}</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.channels"><strong>Channels:</strong> {{ selectedTrack.metadata.channels }}</p>
</div>
<div class="cache-section">
<h4>Cache Info</h4>
<p><strong>PK:</strong> {{ selectedTrack.pk }}</p>
<p><strong>Source URL:</strong> <a :href="selectedTrack.source_url" target="_blank">{{ selectedTrack.source_url }}</a></p>
<p><strong>Hits:</strong> {{ selectedTrack.hits }}</p>
<p v-if="selectedTrack.collection"><strong>Collection:</strong> {{ selectedTrack.collection }}</p>
<p v-if="selectedTrack.last_used"><strong>Last Used:</strong> {{ formatDate(selectedTrack.last_used) }}</p>
</div>
<div class="modal-actions">
<button @click="playTrack(selectedTrack.pk)" class="btn-play">
Play
</button>
<button @click="downloadTrack(selectedTrack.pk)" class="btn-secondary">
Download
</button>
<button @click="copyTrackUrl(selectedTrack.pk)" class="btn-secondary">
📋 Copy URL
</button>
<button @click="handleDeleteTrack(selectedTrack.pk); selectedTrack = null" class="btn-danger">
🗑 Delete
</button>
</div>
</div>
</div>
</div>
<!-- Lecteur audio -->
<div v-if="isPlaying || audioError" class="audio-player-container">
<audio
ref="audioPlayer"
controls
v-if="!audioError"
@ended="handleAudioEnded"
@error="handleAudioError"
></audio>
<p v-if="audioError" class="audio-error">{{ audioError }}</p>
<button @click="stopTrack" class="btn-stop" title="Stop">
{{ audioError ? '✕ Close' : '⏹️ Stop' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import type { AudioCacheEntry } from "../services/audioCache";
import {
listTracks,
addTrack,
deleteTrack,
purgeCache,
consolidateCache,
getTrackUrl,
getOriginalTrackUrl,
formatDuration,
formatBitrate,
formatSampleRate,
} from "../services/audioCache";
// --- États ---
const tracks = ref<AudioCacheEntry[]>([]);
const selectedTrack = ref<AudioCacheEntry | null>(null);
const isLoading = ref(false);
const sortBy = ref<"hits" | "last_used" | "recent">("hits");
const audioPlayer = ref<HTMLAudioElement | null>(null);
// Formulaire d'ajout
const newTrackUrl = ref("");
const newTrackCollection = ref("");
const isAdding = ref(false);
const addError = ref("");
const addSuccess = ref("");
// Contrôles
const isConsolidating = ref(false);
const isPurging = ref(false);
const deletingTracks = ref(new Set<string>());
// Lecteur audio
const isPlaying = ref(false);
const audioError = ref("");
// --- Computed ---
const totalHits = computed(() => tracks.value.reduce((sum, t) => sum + t.hits, 0));
const sortedTracks = computed(() => {
const arr = [...tracks.value];
switch (sortBy.value) {
case "hits":
return arr.sort((a, b) => b.hits - a.hits);
case "last_used":
return arr.sort((a, b) => {
if (!a.last_used) return 1;
if (!b.last_used) return -1;
return new Date(b.last_used).getTime() - new Date(a.last_used).getTime();
});
case "recent":
return arr.reverse();
default:
return arr;
}
});
// --- Fonctions ---
async function refreshTracks() {
isLoading.value = true;
try {
tracks.value = await listTracks();
} finally {
isLoading.value = false;
}
}
async function handleAddTrack() {
if (!newTrackUrl.value) return;
isAdding.value = true;
addError.value = "";
addSuccess.value = "";
try {
const result = await addTrack(
newTrackUrl.value,
newTrackCollection.value || undefined
);
addSuccess.value = `Track added! PK: ${result.pk}`;
newTrackUrl.value = "";
newTrackCollection.value = "";
await refreshTracks();
} catch (e: any) {
addError.value = e.message ?? "Failed to add track";
} finally {
isAdding.value = false;
setTimeout(() => (addSuccess.value = ""), 3000);
}
}
async function handleDeleteTrack(pk: string) {
if (!confirm(`Delete track ${pk}?`)) return;
deletingTracks.value.add(pk);
try {
await deleteTrack(pk);
await refreshTracks();
} finally {
deletingTracks.value.delete(pk);
}
}
async function handlePurge() {
if (!confirm("⚠️ Delete ALL tracks?")) return;
isPurging.value = true;
try {
await purgeCache();
await refreshTracks();
} finally {
isPurging.value = false;
}
}
async function handleConsolidate() {
if (!confirm("Consolidate cache? This will re-download missing tracks.")) return;
isConsolidating.value = true;
try {
await consolidateCache();
await refreshTracks();
} finally {
isConsolidating.value = false;
}
}
function playTrack(pk: string) {
audioError.value = "";
isPlaying.value = true;
// Attendre que le DOM soit mis à jour (car le lecteur audio est dans un v-if)
setTimeout(() => {
if (audioPlayer.value) {
const url = getTrackUrl(pk);
audioPlayer.value.src = url;
audioPlayer.value.play().catch((error) => {
console.error("Failed to play audio:", error);
audioError.value = `Cannot play audio: ${error.message}. Your browser may not support FLAC format.`;
isPlaying.value = false;
});
}
}, 100);
}
function stopTrack() {
if (audioPlayer.value) {
audioPlayer.value.pause();
audioPlayer.value.currentTime = 0;
audioPlayer.value.src = "";
}
isPlaying.value = false;
audioError.value = "";
}
function handleAudioEnded() {
isPlaying.value = false;
audioError.value = "";
}
function handleAudioError() {
const audio = audioPlayer.value;
if (audio?.error) {
let message = "Audio playback error: ";
switch (audio.error.code) {
case 1:
message += "Loading aborted";
break;
case 2:
message += "Network error";
break;
case 3:
message += "Format not supported";
break;
case 4:
message += "Source not found";
break;
default:
message += "Unknown error";
}
console.error('Audio player error:', message, 'code:', audio.error.code);
audioError.value = message;
isPlaying.value = false;
}
}
function downloadTrack(pk: string) {
window.open(getOriginalTrackUrl(pk), "_blank");
}
function copyTrackUrl(pk: string) {
navigator.clipboard.writeText(window.location.origin + getTrackUrl(pk));
alert("✅ URL copied!");
}
function formatDate(dateString: string) {
const d = new Date(dateString);
const diff = Date.now() - d.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return "Today";
if (days === 1) return "Yesterday";
if (days < 7) return `${days} days ago`;
return d.toLocaleDateString();
}
onMounted(() => {
refreshTracks();
});
</script>
<style scoped>
.audio-cache-manager {
padding: 1rem;
width: 100%;
max-width: 100%;
margin: 0;
box-sizing: border-box;
}
@media (min-width: 1400px) {
.audio-cache-manager {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
}
@media (max-width: 768px) {
.audio-cache-manager {
padding: 0.5rem;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #444;
}
.header h2 {
margin: 0;
color: #61dafb;
}
.stats {
display: flex;
gap: 1rem;
font-size: 0.9rem;
color: #999;
}
/* Formulaire d'ajout */
.add-form {
background: #2a2a2a;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.add-form h3 {
margin-top: 0;
color: #61dafb;
}
.form-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.form-group input {
padding: 0.75rem;
border: 1px solid #444;
border-radius: 4px;
background: #1a1a1a;
color: #fff;
font-size: 1rem;
}
.form-group input[type="url"] {
flex: 2;
min-width: 250px;
}
.collection-input {
flex: 1;
min-width: 150px;
}
.form-group button {
padding: 0.75rem 1.5rem;
background: #61dafb;
color: #000;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: all 0.2s;
}
.form-group button:hover:not(:disabled) {
background: #4fa8c5;
}
.form-group button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: #ff6b6b;
margin-top: 0.5rem;
}
.success {
color: #51cf66;
margin-top: 0.5rem;
}
/* Contrôles */
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding: 1rem;
background: #2a2a2a;
border-radius: 8px;
flex-wrap: wrap;
gap: 1rem;
}
.sort-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.sort-controls label {
color: #999;
}
.sort-controls select {
padding: 0.5rem;
border: 1px solid #444;
border-radius: 4px;
background: #1a1a1a;
color: #fff;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
button:not(.btn-danger):not(.btn-secondary):not(.btn-play):not(.btn-delete) {
background: #61dafb;
color: #000;
}
button:not(.btn-danger):not(.btn-secondary):not(.btn-play):not(.btn-delete):hover:not(:disabled) {
background: #4fa8c5;
}
.btn-secondary {
background: #555;
color: #fff;
}
.btn-secondary:hover:not(:disabled) {
background: #666;
}
.btn-danger {
background: #ff6b6b;
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #ee5a52;
}
.btn-play {
background: #51cf66;
color: #fff;
}
.btn-play:hover:not(:disabled) {
background: #40c057;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* États */
.loading-state,
.empty-state {
text-align: center;
padding: 3rem;
color: #999;
font-size: 1.2rem;
}
/* Grille de pistes */
.track-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
.track-card {
background: #2a2a2a;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
}
.track-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
}
.track-icon {
position: relative;
width: 100%;
padding-top: 56.25%; /* Ratio 16:9 */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
overflow: hidden;
}
.music-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 4rem;
opacity: 0.3;
}
.track-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
padding: 0.5rem;
color: #fff;
}
.hits {
font-size: 0.9rem;
}
.track-info {
padding: 1rem;
flex: 1;
}
.track-title {
font-size: 1.1rem;
font-weight: bold;
color: #61dafb;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-artist {
color: #ccc;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-album {
color: #999;
font-size: 0.9rem;
margin-bottom: 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pk {
font-family: monospace;
color: #777;
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
.meta {
display: flex;
gap: 0.75rem;
font-size: 0.85rem;
color: #999;
flex-wrap: wrap;
}
.collection {
color: #888;
font-size: 0.85rem;
margin-top: 0.5rem;
font-style: italic;
}
.last-used {
color: #777;
font-size: 0.8rem;
margin-top: 0.5rem;
}
.track-actions {
padding: 0 1rem 1rem;
display: flex;
gap: 0.5rem;
}
.track-actions button {
flex: 1;
padding: 0.5rem;
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2rem;
}
.modal-content {
background: #2a2a2a;
border-radius: 12px;
max-width: 700px;
max-height: 90vh;
overflow: auto;
position: relative;
width: 100%;
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
font-size: 1.2rem;
z-index: 1;
}
.modal-close:hover {
background: rgba(0, 0, 0, 0.8);
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #444;
display: flex;
align-items: center;
gap: 1rem;
}
.modal-icon {
font-size: 3rem;
}
.modal-header h3 {
margin: 0;
color: #61dafb;
}
.modal-info {
padding: 1.5rem;
}
.metadata-section,
.cache-section {
margin-bottom: 1.5rem;
}
.metadata-section h4,
.cache-section h4 {
color: #61dafb;
margin-top: 0;
margin-bottom: 1rem;
}
.modal-info p {
margin: 0.5rem 0;
color: #ccc;
}
.modal-info a {
color: #61dafb;
text-decoration: none;
word-break: break-all;
}
.modal-info a:hover {
text-decoration: underline;
}
.modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.modal-actions button {
flex: 1;
min-width: 120px;
}
/* Lecteur audio */
.audio-player-container {
position: fixed;
bottom: 2rem;
right: 2rem;
background: #2a2a2a;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
display: flex;
gap: 1rem;
align-items: center;
z-index: 1001;
}
.audio-player-container audio {
max-width: 400px;
}
.audio-error {
color: #ff6b6b;
margin: 0;
padding: 0.5rem;
background: rgba(255, 107, 107, 0.1);
border-radius: 4px;
max-width: 400px;
}
.btn-stop {
background: #ff6b6b;
color: #fff;
padding: 0.75rem 1.5rem;
white-space: nowrap;
}
.btn-stop:hover:not(:disabled) {
background: #ee5a52;
}
@media (max-width: 768px) {
.audio-player-container {
bottom: 1rem;
right: 1rem;
left: 1rem;
flex-direction: column;
}
.audio-player-container audio {
max-width: 100%;
width: 100%;
}
}
</style>

View File

@@ -236,8 +236,24 @@ onMounted(()=>refreshImages());
<style scoped>
.cover-cache-manager {
padding: 1rem;
max-width: 1400px;
margin: 0 auto;
width: 100%;
max-width: 100%;
margin: 0;
box-sizing: border-box;
}
@media (min-width: 1400px) {
.cover-cache-manager {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
}
@media (max-width: 768px) {
.cover-cache-manager {
padding: 0.5rem;
}
}
.header {
display: flex;

View File

@@ -7,14 +7,29 @@
{{ autoScroll ? '📌 Auto-scroll ON' : '📌 Auto-scroll OFF' }}
</button>
<button @click="clearLogs">🗑 Clear</button>
<select v-model="levelFilter" class="filter">
<option value="ALL">All Levels</option>
<option value="TRACE">TRACE</option>
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARN">WARN</option>
<option value="ERROR">ERROR</option>
<!-- Sélection du niveau de log -->
<select v-model="serverLogLevel" @change="updateServerLogLevel" class="filter log-level">
<option value="ERROR">🔴 ERROR only</option>
<option value="WARN">🟡 WARN+</option>
<option value="INFO">🟢 INFO+</option>
<option value="DEBUG">🔵 DEBUG+</option>
<option value="TRACE"> TRACE (all)</option>
</select>
<!-- Champ de recherche -->
<div class="search-box">
<input
v-model="searchQuery"
type="text"
placeholder="🔍 Search logs..."
class="search-input"
@keyup.escape="searchQuery = ''"
/>
<button v-if="searchQuery" @click="searchQuery = ''" class="clear-search" title="Clear search">
</button>
</div>
</div>
</div>
@@ -24,16 +39,29 @@
:key="index"
:class="['log-entry', `level-${log.level.toLowerCase()}`, { 'is-history': log.isHistory }]"
>
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
<span class="level">{{ log.level }}</span>
<span class="target">{{ log.target }}</span>
<span class="message markdown-content" v-html="renderMarkdown(log.message)"></span>
<div class="log-header">
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
<span class="level">{{ log.level }}</span>
<span class="target">{{ log.target }}</span>
</div>
<div class="log-content">
<div class="message markdown-content">
<template v-if="log.isTooLong">
<div v-show="!log.expanded" class="truncated-preview" v-html="highlightSearchTerm(log.truncatedHtml)"></div>
<details class="log-details" @toggle="log.expanded = $event.target.open">
<summary class="log-summary"></summary>
<div class="full-message" v-html="highlightSearchTerm(log.renderedHtml)"></div>
</details>
</template>
<div v-else v-html="highlightSearchTerm(log.renderedHtml)"></div>
</div>
</div>
</div>
<div v-if="isLoadingHistory" class="loading-state">
Loading history...
</div>
<div v-else-if="filteredLogs.length === 0" class="empty-state">
{{ isConnected ? 'Waiting for logs...' : 'Connecting to log stream...' }}
</div>
@@ -43,7 +71,13 @@
<span :class="['status', { connected: isConnected }]">
{{ isConnected ? '🟢 Connected' : '🔴 Disconnected' }}
</span>
<span class="count">{{ filteredLogs.length }} logs</span>
<span class="server-info">Level: {{ serverLogLevel }}</span>
<span class="count">
{{ filteredLogs.length }} log{{ filteredLogs.length !== 1 ? 's' : '' }}
<span v-if="searchQuery.trim()" class="search-results">
({{ filteredLogs.length }} / {{ logs.length }} matching)
</span>
</span>
</div>
</div>
</template>
@@ -63,19 +97,83 @@ const logs = ref([])
const autoScroll = ref(true)
const isConnected = ref(false)
const isLoadingHistory = ref(true)
const levelFilter = ref('ALL')
const serverLogLevel = ref('TRACE')
const searchQuery = ref('')
const logContainer = ref(null)
let eventSource = null
let historyLoaded = false
const seenLogIds = new Set() // Pour détecter les duplicatas
// Filtrer les logs uniquement par recherche
const filteredLogs = computed(() => {
if (levelFilter.value === 'ALL') {
return logs.value
let filtered = logs.value
// Filtre par recherche
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(log => {
return (
log.message.toLowerCase().includes(query) ||
log.level.toLowerCase().includes(query) ||
log.target.toLowerCase().includes(query)
)
})
}
return logs.value.filter(log => log.level === levelFilter.value)
return filtered
})
// Fonction pour mettre à jour le niveau de log côté serveur
async function updateServerLogLevel() {
try {
const response = await fetch('/api/log_setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
level: serverLogLevel.value
})
})
if (response.ok) {
const data = await response.json()
console.log('Log level updated:', data.current_level)
// Fermer la connexion SSE actuelle
if (eventSource) {
eventSource.close()
}
// Vider les logs actuels et réinitialiser
logs.value = []
seenLogIds.clear()
historyLoaded = false
isLoadingHistory.value = true
// Reconnecter au SSE avec le nouveau niveau
connectSSE()
} else {
console.error('Failed to update log level')
}
} catch (error) {
console.error('Error updating log level:', error)
}
}
// Charger le niveau de log actuel au démarrage
async function loadServerLogLevel() {
try {
const response = await fetch('/api/log_setup')
if (response.ok) {
const data = await response.json()
serverLogLevel.value = data.current_level
}
} catch (error) {
console.error('Error loading log level:', error)
}
}
function formatTimestamp(timestamp) {
const date = new Date(timestamp.secs_since_epoch * 1000)
return date.toLocaleTimeString('fr-FR', {
@@ -86,43 +184,126 @@ function formatTimestamp(timestamp) {
})
}
function renderMarkdown(text) {
// ÉTAPE 1 : Pré-processing pour détecter et protéger le XML
let processedText = text
// Pré-traiter un log : calculer HTML, troncature, etc. UNE SEULE FOIS
function preprocessLog(message) {
// ÉTAPE 1: Déterminer si trop long
const firstLineEnd = message.indexOf('\n')
const isTooLong = firstLineEnd !== -1 || message.length > 200
// Détecter si le message contient du XML
// Pattern : cherche <?xml ou des balises XML racine communes (scpd, root, service, etc.)
const hasXml = /<\?xml|<(scpd|root|service|device|actionList|stateVariable)[>\s]/i.test(text)
// ÉTAPE 2: Calculer le message tronqué si nécessaire
const truncatedMessage = isTooLong
? (firstLineEnd !== -1
? message.substring(0, firstLineEnd).trim()
: message.substring(0, 200).trim())
: null
// ÉTAPE 3: Pré-processing pour détecter et protéger le XML, images et audio
let processedText = message
// ÉTAPE 3a: Détecter et marquer les URLs audio AVANT tout traitement markdown
// On utilise des marqueurs UUID pour éviter les conflits
const audioMarkers = new Map()
const audioUrlPattern = /(https?:\/\/[^\s"<>]+\.(?:mp3|wav|ogg|m4a|flac|aac|opus|weba)(?:\?[^\s"<>]*)?)/gi
processedText = processedText.replace(audioUrlPattern, (match) => {
const markerId = `AUDIO_MARKER_${Math.random().toString(36).substring(2, 11)}`
audioMarkers.set(markerId, match)
return markerId
})
// ÉTAPE 3b: Détecter et transformer les liens d'images
const imageUrlPattern = /(https?:\/\/[^\s"<>]+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?[^\s"<>]*)?)/gi
processedText = processedText.replace(imageUrlPattern, (match) => {
return `\n![Image](${match})\n`
})
// ÉTAPE 3c: Détecter si le message contient du XML
const hasXml = /<\?xml|<(scpd|root|service|device|actionList|stateVariable)[>\s]/i.test(processedText)
if (hasXml) {
// Extraire tout ce qui ressemble à du XML (du <?xml ou première balise jusqu'à la fin)
const xmlStartMatch = text.match(/<\?xml[\s\S]*$/)
const xmlStartMatch = processedText.match(/<\?xml[\s\S]*$/)
if (xmlStartMatch) {
const xmlContent = xmlStartMatch[0]
const beforeXml = text.substring(0, text.indexOf(xmlContent))
// Créer le texte avec le XML dans un bloc de code
const beforeXml = processedText.substring(0, processedText.indexOf(xmlContent))
processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n'
} else {
// Fallback : chercher une balise racine XML
const xmlMatch = text.match(/<([a-zA-Z][a-zA-Z0-9:-]*)[>\s][\s\S]*/)
const xmlMatch = processedText.match(/<([a-zA-Z][a-zA-Z0-9:-]*)[>\s][\s\S]*/)
if (xmlMatch) {
const xmlContent = xmlMatch[0]
const beforeXml = text.substring(0, text.indexOf(xmlContent))
const beforeXml = processedText.substring(0, processedText.indexOf(xmlContent))
processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n'
}
}
}
// ÉTAPE 2 : Convertir markdown en HTML
// ÉTAPE 5: Convertir markdown en HTML
const rawHtml = marked.parse(processedText, { async: false })
// ÉTAPE 3 : Nettoyer pour la sécurité
return DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span'],
ALLOWED_ATTR: ['href', 'target', 'class']
// ÉTAPE 6: Nettoyer pour la sécurité
let renderedHtml = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'img'],
ALLOWED_ATTR: ['href', 'target', 'class', 'src', 'alt', 'title']
})
// ÉTAPE 6b: Remplacer les marqueurs audio par de vrais lecteurs HTML5
for (const [markerId, audioUrl] of audioMarkers.entries()) {
const audioPlayer = `<div class="audio-player-wrapper">
<audio controls preload="metadata" class="log-audio-player">
<source src="${audioUrl}" type="audio/${audioUrl.split('.').pop().split('?')[0]}">
Your browser does not support the audio element.
</audio>
<div class="audio-url"><a href="${audioUrl}" target="_blank" rel="noopener">${audioUrl}</a></div>
</div>`
renderedHtml = renderedHtml.replace(new RegExp(markerId, 'g'), audioPlayer)
}
const finalHtml = renderedHtml
// ÉTAPE 7: Générer le HTML du message tronqué si nécessaire
let truncatedHtml = null
if (isTooLong && truncatedMessage) {
const truncatedRaw = marked.parse(truncatedMessage, { async: false })
let cleanTruncated = DOMPurify.sanitize(truncatedRaw, {
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'img'],
ALLOWED_ATTR: ['href', 'target', 'class', 'src', 'alt', 'title']
})
// Remplacer les marqueurs audio dans le message tronqué aussi
for (const [markerId, audioUrl] of audioMarkers.entries()) {
const audioPlayer = `<div class="audio-player-wrapper">
<audio controls preload="metadata" class="log-audio-player">
<source src="${audioUrl}" type="audio/${audioUrl.split('.').pop().split('?')[0]}">
Your browser does not support the audio element.
</audio>
<div class="audio-url"><a href="${audioUrl}" target="_blank" rel="noopener">${audioUrl}</a></div>
</div>`
cleanTruncated = cleanTruncated.replace(new RegExp(markerId, 'g'), audioPlayer)
}
truncatedHtml = cleanTruncated
}
return {
isTooLong,
truncatedMessage,
truncatedHtml,
renderedHtml: finalHtml
}
}
function highlightSearchTerm(html) {
if (!searchQuery.value.trim() || !html) {
return html
}
const query = searchQuery.value.trim()
// Créer une regex insensible à la casse pour trouver le terme
// Utiliser un lookahead négatif pour éviter de matcher dans les balises HTML
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?![^<]*>)`, 'gi')
return html.replace(regex, '<mark class="search-highlight">$1</mark>')
}
function toggleAutoScroll() {
@@ -157,23 +338,31 @@ function connectSSE() {
eventSource.onmessage = (event) => {
try {
const logEntry = JSON.parse(event.data)
// Créer un ID unique basé sur timestamp + message + target
const logId = `${logEntry.timestamp.secs_since_epoch}-${logEntry.timestamp.nanos_since_epoch}-${logEntry.message}-${logEntry.target}`
// Ignorer les duplicatas
if (seenLogIds.has(logId)) {
return
}
seenLogIds.add(logId)
// Marquer les logs historiques
if (!historyLoaded) {
logEntry.isHistory = true
}
// PRÉ-TRAITER le log UNE SEULE FOIS à la réception
const processed = preprocessLog(logEntry.message)
logEntry.isTooLong = processed.isTooLong
logEntry.truncatedMessage = processed.truncatedMessage
logEntry.truncatedHtml = processed.truncatedHtml
logEntry.renderedHtml = processed.renderedHtml
logEntry.expanded = false // État de dépliage initial
logs.value.push(logEntry)
// Limiter à 1000 logs en mémoire
if (logs.value.length > 1000) {
const removed = logs.value.shift()
@@ -181,7 +370,7 @@ function connectSSE() {
const removedId = `${removed.timestamp.secs_since_epoch}-${removed.timestamp.nanos_since_epoch}-${removed.message}-${removed.target}`
seenLogIds.delete(removedId)
}
scrollToBottom()
} catch (error) {
console.error('Failed to parse log entry:', error)
@@ -192,7 +381,7 @@ function connectSSE() {
isConnected.value = false
isLoadingHistory.value = false
console.error('SSE connection error')
// Reconnexion automatique après 3 secondes
setTimeout(() => {
if (eventSource.readyState === EventSource.CLOSED) {
@@ -209,7 +398,7 @@ function connectSSE() {
eventSource.onmessage = (event) => {
clearTimeout(historyTimeout)
originalOnMessage(event)
if (!historyLoaded) {
historyTimeout = setTimeout(() => {
historyLoaded = true
@@ -221,6 +410,7 @@ function connectSSE() {
}
onMounted(() => {
loadServerLogLevel()
connectSSE()
})
@@ -231,17 +421,32 @@ onUnmounted(() => {
})
// Désactiver auto-scroll si l'utilisateur scroll manuellement
watch(logContainer, (container) => {
let scrollHandler = null
watch(logContainer, (container, oldContainer) => {
// Nettoyer l'ancien listener si existant
if (oldContainer && scrollHandler) {
oldContainer.removeEventListener('scroll', scrollHandler)
}
if (!container) return
container.addEventListener('scroll', () => {
const isAtBottom =
scrollHandler = () => {
const isAtBottom =
container.scrollHeight - container.scrollTop <= container.clientHeight + 50
if (!isAtBottom && autoScroll.value) {
autoScroll.value = false
}
})
}
container.addEventListener('scroll', scrollHandler, { passive: true })
})
// Nettoyer au démontage
onUnmounted(() => {
if (logContainer.value && scrollHandler) {
logContainer.value.removeEventListener('scroll', scrollHandler)
}
})
</script>
@@ -249,14 +454,15 @@ watch(logContainer, (container) => {
.log-viewer {
display: flex;
flex-direction: column;
height: 80vh;
width: 100vw;
height: calc(100vh - 60px); /* Hauteur viewport - nav */
width: 100%;
margin: 0;
padding: 0;
background: #1e1e1e;
color: #d4d4d4;
font-family: 'Consolas', 'Monaco', monospace;
box-sizing: border-box;
overflow: hidden; /* Empêcher le scroll sur le conteneur principal */
}
.header {
@@ -268,6 +474,7 @@ watch(logContainer, (container) => {
border-bottom: 1px solid #3e3e42;
flex-wrap: wrap;
gap: 0.5rem;
flex-shrink: 0; /* Ne pas réduire le header */
}
.header h2 {
@@ -281,7 +488,7 @@ watch(logContainer, (container) => {
.header {
padding: 0.75rem 1rem;
}
.header h2 {
font-size: 1rem;
width: 100%;
@@ -338,6 +545,13 @@ button.active {
border: 1px solid #555;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.filter.log-level {
background: #1e3a5f;
border-color: #569cd6;
font-weight: bold;
}
@media (max-width: 768px) {
@@ -349,6 +563,69 @@ button.active {
}
}
.search-box {
position: relative;
display: flex;
align-items: center;
flex: 1;
min-width: 200px;
max-width: 400px;
}
.search-input {
flex: 1;
padding: 0.5rem 2rem 0.5rem 0.75rem;
background: #3c3c3c;
color: #d4d4d4;
border: 1px solid #555;
border-radius: 4px;
font-size: 0.9rem;
font-family: 'Consolas', 'Monaco', monospace;
outline: none;
transition: all 0.2s;
}
.search-input:focus {
border-color: #569cd6;
background: #2d2d30;
box-shadow: 0 0 0 2px rgba(86, 156, 214, 0.2);
}
.search-input::placeholder {
color: #858585;
font-style: italic;
}
.clear-search {
position: absolute;
right: 0.25rem;
background: transparent;
border: none;
color: #858585;
cursor: pointer;
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
border-radius: 3px;
transition: all 0.2s;
}
.clear-search:hover {
background: #505050;
color: #d4d4d4;
}
@media (max-width: 768px) {
.search-box {
width: 100%;
max-width: none;
}
.search-input {
font-size: 0.8rem;
padding: 0.4rem 2rem 0.4rem 0.6rem;
}
}
.log-container {
flex: 1;
overflow-y: auto;
@@ -357,20 +634,18 @@ button.active {
}
.log-entry {
display: grid;
grid-template-columns: 130px 80px 200px 1fr;
gap: 1rem;
display: flex;
flex-direction: column;
padding: 0.5rem;
margin-bottom: 0.25rem;
border-left: 3px solid transparent;
font-size: 0.9rem;
line-height: 1.4;
gap: 0.5rem;
}
@media (max-width: 768px) {
.log-entry {
grid-template-columns: 1fr;
gap: 0.3rem;
padding: 0.75rem 0.5rem;
font-size: 0.85rem;
border-left-width: 4px;
@@ -385,15 +660,32 @@ button.active {
opacity: 0.7;
}
.log-header {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.log-header {
gap: 0.5rem;
}
}
.log-content {
padding-left: 0;
}
.timestamp {
color: #858585;
font-weight: 500;
flex-shrink: 0;
}
@media (max-width: 768px) {
.timestamp {
font-size: 0.75rem;
order: 1;
}
}
@@ -403,12 +695,11 @@ button.active {
padding: 0.1rem 0.5rem;
border-radius: 3px;
text-align: center;
flex-shrink: 0;
}
@media (max-width: 768px) {
.level {
order: 2;
width: fit-content;
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
}
@@ -417,11 +708,15 @@ button.active {
.target {
color: #4ec9b0;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
min-width: 0;
}
@media (max-width: 768px) {
.target {
order: 3;
font-size: 0.8rem;
color: #6eb8a5;
}
@@ -433,11 +728,59 @@ button.active {
text-align: left;
}
@media (max-width: 768px) {
.message {
order: 4;
margin-top: 0.25rem;
}
.log-details {
margin: 0;
}
.log-summary {
cursor: pointer;
color: #569cd6;
list-style: none;
user-select: none;
display: inline-block;
margin: 0;
padding: 0;
min-height: 1em;
}
.log-summary::-webkit-details-marker {
display: none;
}
.log-summary::marker {
content: '';
}
.log-summary::before {
content: '▶ Afficher plus';
display: inline-block;
transition: transform 0.2s;
color: #569cd6;
font-size: 0.85em;
font-style: italic;
}
.log-details[open] .log-summary::before {
content: '▼ Afficher moins';
}
.log-summary:hover::before {
color: #6fa8dc;
}
.truncated-preview {
color: #d4d4d4;
font-family: 'Consolas', 'Monaco', monospace;
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 0.25rem;
}
.full-message {
margin-top: 0.5rem;
padding-left: 1.5em;
border-left: 2px solid #569cd6;
padding-top: 0.5rem;
}
.markdown-content {
@@ -478,6 +821,59 @@ button.active {
color: #ce9178;
}
/* Style pour les images */
.markdown-content :deep(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 0.5rem 0;
border: 1px solid #3e3e42;
display: block;
}
/* Style pour les lecteurs audio */
.markdown-content :deep(.audio-player-wrapper) {
margin: 0.75rem 0;
padding: 0.75rem;
background: #2d2d30;
border: 1px solid #3e3e42;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.markdown-content :deep(.log-audio-player) {
width: 100%;
max-width: 500px;
height: 40px;
border-radius: 4px;
background: #1e1e1e;
outline: none;
}
.markdown-content :deep(.log-audio-player:focus) {
outline: 2px solid #569cd6;
outline-offset: 2px;
}
.markdown-content :deep(.audio-url) {
font-size: 0.85em;
color: #858585;
font-style: italic;
word-break: break-all;
}
.markdown-content :deep(.audio-url a) {
color: #569cd6;
text-decoration: none;
}
.markdown-content :deep(.audio-url a:hover) {
color: #6fa8dc;
text-decoration: underline;
}
/* Scrollbar pour les blocs de code longs */
.markdown-content :deep(pre)::-webkit-scrollbar {
width: 8px;
@@ -527,32 +923,14 @@ button.active {
padding-left: 1.5rem;
}
/* Level colors */
.level-trace {
border-left-color: #808080;
/* Level colors - Classés par ordre de gravité */
.level-error {
border-left-color: #f48771;
}
.level-trace .level {
background: #3a3a3a;
color: #a0a0a0;
}
.level-debug {
border-left-color: #569cd6;
}
.level-debug .level {
background: #1e3a5f;
color: #569cd6;
}
.level-info {
border-left-color: #4ec9b0;
}
.level-info .level {
background: #1e4d42;
color: #4ec9b0;
.level-error .level {
background: #5a1e1e;
color: #f48771;
}
.level-warn {
@@ -564,13 +942,31 @@ button.active {
color: #dcdcaa;
}
.level-error {
border-left-color: #f48771;
.level-info {
border-left-color: #4ec9b0;
}
.level-error .level {
background: #5a1e1e;
color: #f48771;
.level-info .level {
background: #1e4d42;
color: #4ec9b0;
}
.level-debug {
border-left-color: #569cd6;
}
.level-debug .level {
background: #1e3a5f;
color: #569cd6;
}
.level-trace {
border-left-color: #808080;
}
.level-trace .level {
background: #3a3a3a;
color: #a0a0a0;
}
.empty-state {
@@ -600,6 +996,8 @@ button.active {
background: #252526;
border-top: 1px solid #3e3e42;
font-size: 0.9rem;
gap: 1rem;
flex-shrink: 0; /* Ne pas réduire le footer */
}
@media (max-width: 768px) {
@@ -617,10 +1015,30 @@ button.active {
color: #4ec9b0;
}
.server-info {
color: #569cd6;
font-weight: bold;
}
.count {
color: #858585;
}
.search-results {
color: #569cd6;
font-weight: bold;
font-style: italic;
}
/* Surlignage des termes recherchés */
.markdown-content :deep(mark.search-highlight) {
background: #ffd700;
color: #1e1e1e;
padding: 0.1rem 0.2rem;
border-radius: 2px;
font-weight: bold;
}
/* Scrollbar styling */
.log-container::-webkit-scrollbar {
width: 12px;
@@ -638,4 +1056,4 @@ button.active {
.log-container::-webkit-scrollbar-thumb:hover {
background: #4e4e4e;
}
</style>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,506 @@
<template>
<div class="upnp-explorer">
<div class="header">
<h2>🎵 UPnP Device Explorer</h2>
<div class="controls">
<button @click="refreshDevices" :disabled="isLoading" class="refresh-btn">
{{ isLoading ? '⏳ Loading...' : '🔄 Refresh' }}
</button>
<span class="device-count">{{ devices.length }} device(s)</span>
</div>
</div>
<!-- État de chargement -->
<div v-if="isLoading && devices.length === 0" class="loading-state">
Loading UPnP devices...
</div>
<!-- État vide -->
<div v-else-if="!isLoading && devices.length === 0" class="empty-state">
<div class="empty-icon">📡</div>
<p>No UPnP devices found</p>
<p class="hint">Devices will appear here once registered</p>
</div>
<!-- Liste des devices avec leurs services intégrés -->
<div v-else class="devices-list">
<div
v-for="device in devicesWithDetails"
:key="device.udn"
class="device-section"
>
<!-- En-tête du device -->
<div class="device-header" @click="toggleDevice(device.udn)">
<div class="device-title">
<span class="device-icon">{{ getDeviceIcon(device.device_type) }}</span>
<div class="device-names">
<span class="device-name">{{ device.friendly_name }}</span>
<span class="device-type">{{ device.name }}</span>
</div>
</div>
<div class="device-meta">
<span v-if="device.services" class="service-count">
{{ device.services.length }} service(s)
</span>
<span class="expand-icon">{{ expandedDevices.has(device.udn) ? '▼' : '▶' }}</span>
</div>
</div>
<!-- Détails du device (expandable) -->
<transition name="expand">
<div v-if="expandedDevices.has(device.udn)" class="device-details">
<!-- Chargement des détails -->
<div v-if="!device.services" class="loading-services">
Loading services...
</div>
<!-- Services -->
<div v-else class="device-content">
<div class="device-summary">
<div class="meta-row">
<span class="meta-label">UDN:</span>
<code class="meta-value">{{ device.udn }}</code>
</div>
<div class="meta-row" v-if="device.description_url">
<span class="meta-label">Description:</span>
<a
:href="device.description_url"
target="_blank"
rel="noopener"
class="meta-link"
>
View XML
</a>
</div>
<div class="meta-row" v-if="device.base_url">
<span class="meta-label">Base URL:</span>
<code class="meta-value">{{ device.base_url }}</code>
</div>
</div>
<div class="services-list">
<ServicePanel
v-for="service in device.services"
:key="service.name"
:service="service"
:device-udn="device.udn"
/>
</div>
</div>
</div>
</transition>
</div>
</div>
<!-- Toast de notification d'erreur -->
<transition name="fade">
<div v-if="error" class="error-toast" @click="error = null">
❌ {{ error }}
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import ServicePanel from './upnp/ServicePanel.vue'
const devices = ref([])
const deviceDetails = ref(new Map()) // UDN -> détails complets
const isLoading = ref(false)
const error = ref(null)
const expandedDevices = ref(new Set())
const refreshInterval = ref(null)
// Devices avec leurs détails fusionnés
const devicesWithDetails = computed(() => {
return devices.value.map(device => {
const details = deviceDetails.value.get(device.udn)
return details ? { ...device, ...details } : device
})
})
function getDeviceIcon(deviceType) {
if (deviceType?.includes('MediaRenderer')) return '🎵'
if (deviceType?.includes('MediaServer')) return '💿'
return '📱'
}
async function loadDevices() {
try {
const response = await fetch('/api/upnp/devices')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
devices.value = data.devices || []
} catch (err) {
console.error('Failed to load devices:', err)
error.value = `Failed to load devices: ${err.message}`
setTimeout(() => error.value = null, 5000)
}
}
async function loadDeviceDetails(udn) {
try {
const response = await fetch(`/api/upnp/devices/${encodeURIComponent(udn)}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const details = await response.json()
deviceDetails.value.set(udn, details)
} catch (err) {
console.error('Failed to load device details:', err)
error.value = `Failed to load device details: ${err.message}`
setTimeout(() => error.value = null, 5000)
}
}
function toggleDevice(udn) {
if (expandedDevices.value.has(udn)) {
expandedDevices.value.delete(udn)
} else {
expandedDevices.value.add(udn)
// Charger les détails si pas encore fait
if (!deviceDetails.value.has(udn)) {
loadDeviceDetails(udn)
}
}
}
async function refreshDevices() {
isLoading.value = true
await loadDevices()
isLoading.value = false
}
// Auto-refresh toutes les 30 secondes
onMounted(() => {
refreshDevices()
refreshInterval.value = setInterval(loadDevices, 30000)
})
onUnmounted(() => {
if (refreshInterval.value) {
clearInterval(refreshInterval.value)
}
})
</script>
<style scoped>
.upnp-explorer {
padding: 1rem;
width: 100%;
max-width: 100%;
margin: 0;
box-sizing: border-box;
}
@media (min-width: 1400px) {
.upnp-explorer {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
}
@media (max-width: 768px) {
.upnp-explorer {
padding: 0.5rem;
}
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid rgba(52, 152, 219, 0.3);
}
.header h2 {
margin: 0;
color: #ecf0f1;
font-size: 1.8rem;
}
.controls {
display: flex;
align-items: center;
gap: 1rem;
}
.refresh-btn {
padding: 0.6rem 1.2rem;
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.3s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.refresh-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #5dade2, #3498db);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.device-count {
padding: 0.5rem 1rem;
background: rgba(52, 152, 219, 0.2);
border: 1px solid rgba(52, 152, 219, 0.4);
border-radius: 20px;
color: #3498db;
font-size: 0.9rem;
font-weight: 600;
}
/* États */
.loading-state,
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #95a5a6;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.hint {
font-size: 0.9rem;
color: #7f8c8d;
}
/* Devices list */
.devices-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.device-section {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(52, 152, 219, 0.3);
border-radius: 12px;
overflow: hidden;
transition: all 0.3s;
}
.device-section:hover {
border-color: rgba(52, 152, 219, 0.6);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.2);
}
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.2rem 1.5rem;
cursor: pointer;
transition: background 0.2s;
}
.device-header:hover {
background: rgba(52, 152, 219, 0.05);
}
.device-title {
display: flex;
align-items: center;
gap: 1rem;
}
.device-icon {
font-size: 2rem;
}
.device-names {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.device-name {
font-size: 1.2rem;
font-weight: 600;
color: #ecf0f1;
}
.device-type {
font-size: 0.85rem;
color: #95a5a6;
}
.device-meta {
display: flex;
align-items: center;
gap: 1rem;
}
.service-count {
padding: 0.3rem 0.8rem;
background: rgba(46, 204, 113, 0.2);
border: 1px solid rgba(46, 204, 113, 0.3);
border-radius: 12px;
color: #2ecc71;
font-size: 0.85rem;
font-weight: 600;
}
.expand-icon {
color: #3498db;
font-size: 1rem;
transition: transform 0.3s;
}
/* Device details */
.device-details {
padding: 0 1.5rem 1.5rem 1.5rem;
border-top: 1px solid rgba(52, 152, 219, 0.2);
}
.loading-services {
padding: 2rem;
text-align: center;
color: #95a5a6;
}
.device-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.device-summary {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.5rem;
padding: 0.75rem 1rem;
border-radius: 6px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(52, 152, 219, 0.25);
}
.meta-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.meta-label {
font-weight: 600;
color: #95a5a6;
}
.meta-value {
background: rgba(44, 62, 80, 0.6);
padding: 0.25rem 0.5rem;
border-radius: 4px;
color: #ecf0f1;
}
.meta-link {
color: #1abc9c;
text-decoration: none;
font-weight: 600;
}
.meta-link:hover {
text-decoration: underline;
}
.services-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
/* Transitions */
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease;
max-height: 5000px;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Error toast */
.error-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: linear-gradient(135deg, #e74c3c, #c0392b);
color: white;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
cursor: pointer;
z-index: 1000;
max-width: 400px;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive */
@media (max-width: 768px) {
.upnp-explorer {
padding: 1rem;
}
.header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.device-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.device-meta {
width: 100%;
justify-content: space-between;
}
}
</style>

View File

@@ -0,0 +1,527 @@
<template>
<div class="actions-list">
<div v-if="!service.actions || service.actions.length === 0" class="empty-state">
<span class="empty-icon"></span>
<p>No actions available for this service</p>
</div>
<div v-else class="actions-content">
<div class="actions-header">
<h4>Actions ({{ service.actions.length }})</h4>
</div>
<div class="actions-grid">
<div
v-for="action in service.actions"
:key="action.name"
class="action-card"
:class="{ expanded: expandedAction === action.name }"
@click="toggleAction(action.name)"
>
<div class="action-header">
<div class="action-title">
<span class="action-icon"></span>
<span class="action-name">{{ action.name }}</span>
</div>
<div class="action-badges">
<span v-if="action.in_arguments.length > 0" class="badge in-badge" title="Input arguments">
{{ action.in_arguments.length }}
</span>
<span v-if="action.out_arguments.length > 0" class="badge out-badge" title="Output arguments">
{{ action.out_arguments.length }}
</span>
<span
v-if="action.stateless"
class="badge stateless-badge"
title="Does not mutate state variables"
>
🧊 Stateless
</span>
<span class="expand-indicator">
{{ expandedAction === action.name ? '▼' : '▶' }}
</span>
</div>
</div>
<transition name="expand-args">
<div v-if="expandedAction === action.name" class="action-details">
<div v-if="action.stateless" class="action-flags">
<span class="stateless-pill">
Stateless action no state variables updated
</span>
</div>
<!-- Input arguments -->
<div v-if="action.in_arguments.length > 0" class="arguments-section">
<h5 class="section-title">
<span class="section-icon"></span>
Input Arguments
</h5>
<div class="arguments-list">
<div
v-for="arg in action.in_arguments"
:key="arg.name"
class="argument-item"
>
<div class="argument-header">
<span class="argument-name">{{ arg.name }}</span>
<span class="var-link" @click.stop="scrollToVariable(arg.related_state_variable)">
{{ arg.related_state_variable }}
</span>
</div>
<div v-if="getVariableInfo(arg.related_state_variable)" class="variable-preview">
<div class="preview-row">
<span class="preview-label">Type:</span>
<code class="preview-value type">{{ getVariableInfo(arg.related_state_variable).data_type }}</code>
</div>
<div class="preview-row">
<span class="preview-label">Value:</span>
<code class="preview-value" :class="{ empty: !getVariableInfo(arg.related_state_variable).value }">
{{ getVariableInfo(arg.related_state_variable).value || '(empty)' }}
</code>
</div>
</div>
</div>
</div>
</div>
<!-- Output arguments -->
<div v-if="action.out_arguments.length > 0" class="arguments-section">
<h5 class="section-title">
<span class="section-icon"></span>
Output Arguments
</h5>
<div class="arguments-list">
<div
v-for="arg in action.out_arguments"
:key="arg.name"
class="argument-item out"
>
<div class="argument-header">
<span class="argument-name">{{ arg.name }}</span>
<span class="var-link" @click.stop="scrollToVariable(arg.related_state_variable)">
{{ arg.related_state_variable }}
</span>
</div>
<div v-if="getVariableInfo(arg.related_state_variable)" class="variable-preview">
<div class="preview-row">
<span class="preview-label">Type:</span>
<code class="preview-value type">{{ getVariableInfo(arg.related_state_variable).data_type }}</code>
</div>
<div class="preview-row">
<span class="preview-label">Value:</span>
<code class="preview-value" :class="{ empty: !getVariableInfo(arg.related_state_variable).value }">
{{ getVariableInfo(arg.related_state_variable).value || '(empty)' }}
</code>
</div>
</div>
</div>
</div>
</div>
<!-- No arguments -->
<div v-if="action.in_arguments.length === 0 && action.out_arguments.length === 0" class="no-arguments">
<span class="no-args-icon"></span>
<p>This action has no arguments</p>
</div>
</div>
</transition>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({
service: {
type: Object,
required: true
},
deviceUdn: {
type: String,
required: true
}
})
const expandedAction = ref(null)
const variables = ref([])
function toggleAction(actionName) {
expandedAction.value = expandedAction.value === actionName ? null : actionName
}
function getVariableInfo(varName) {
return variables.value.find(v => v.name === varName)
}
function scrollToVariable(varName) {
// TODO: Implement scroll to variable in Variables tab
console.log('Scroll to variable:', varName)
}
async function loadVariables() {
if (!props.deviceUdn || !props.service.name) return
try {
const url = `/api/upnp/devices/${encodeURIComponent(props.deviceUdn)}/services/${encodeURIComponent(props.service.name)}/variables`
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
variables.value = data.variables || []
} catch (err) {
console.error('Error loading variables for actions:', err)
}
}
onMounted(() => {
loadVariables()
})
</script>
<style scoped>
.actions-list {
min-height: 200px;
display: flex;
flex-direction: column;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #95a5a6;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state p {
margin: 0;
}
/* Actions content */
.actions-content {
flex: 1;
}
.actions-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
margin-bottom: 1rem;
}
.actions-header h4 {
margin: 0;
color: #ecf0f1;
font-size: 1rem;
font-weight: 600;
}
/* Actions grid */
.actions-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.action-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(52, 152, 219, 0.3);
border-radius: 8px;
overflow: hidden;
transition: all 0.2s;
cursor: pointer;
}
.action-card:hover {
border-color: rgba(52, 152, 219, 0.6);
background: rgba(0, 0, 0, 0.4);
}
.action-card.expanded {
border-color: #3498db;
background: rgba(52, 152, 219, 0.05);
}
.action-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
transition: background 0.2s;
}
.action-card:hover .action-header {
background: rgba(52, 152, 219, 0.05);
}
.action-title {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.action-icon {
font-size: 1.2rem;
}
.action-name {
font-weight: 600;
color: #ecf0f1;
font-size: 0.95rem;
}
.action-badges {
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.in-badge {
background: rgba(52, 152, 219, 0.2);
color: #3498db;
border: 1px solid rgba(52, 152, 219, 0.3);
}
.out-badge {
background: rgba(46, 204, 113, 0.2);
color: #2ecc71;
border: 1px solid rgba(46, 204, 113, 0.3);
}
.stateless-badge {
background: rgba(155, 89, 182, 0.2);
color: #9b59b6;
border: 1px solid rgba(155, 89, 182, 0.3);
}
.expand-indicator {
color: #3498db;
font-size: 0.9rem;
transition: transform 0.3s;
margin-left: 0.5rem;
}
.action-card.expanded .expand-indicator {
transform: rotate(0deg);
}
/* Action details */
.action-details {
padding: 0 1rem 1rem 1rem;
border-top: 1px solid rgba(52, 152, 219, 0.2);
}
.action-flags {
margin-top: 0.75rem;
}
.stateless-pill {
display: inline-block;
padding: 0.3rem 0.6rem;
border-radius: 999px;
background: rgba(155, 89, 182, 0.15);
border: 1px solid rgba(155, 89, 182, 0.25);
color: #d2a6e6;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
}
.arguments-section {
margin-top: 1rem;
}
.arguments-section:first-child {
margin-top: 0.5rem;
}
.section-title {
margin: 0 0 0.75rem 0;
font-size: 0.85rem;
font-weight: 600;
color: #95a5a6;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-icon {
font-size: 1rem;
}
.arguments-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.argument-item {
background: rgba(52, 152, 219, 0.1);
border: 1px solid rgba(52, 152, 219, 0.2);
border-left: 3px solid #3498db;
border-radius: 4px;
padding: 0.75rem;
}
.argument-item.out {
background: rgba(46, 204, 113, 0.1);
border: 1px solid rgba(46, 204, 113, 0.2);
border-left: 3px solid #2ecc71;
}
.argument-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.argument-name {
font-weight: 600;
color: #ecf0f1;
font-size: 0.9rem;
}
.var-link {
font-size: 0.75rem;
color: #9b59b6;
background: rgba(155, 89, 182, 0.2);
border: 1px solid rgba(155, 89, 182, 0.3);
padding: 0.2rem 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-family: 'Courier New', monospace;
}
.var-link:hover {
background: rgba(155, 89, 182, 0.3);
border-color: #9b59b6;
transform: translateY(-1px);
}
/* Variable preview */
.variable-preview {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.preview-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.preview-label {
font-size: 0.7rem;
color: #7f8c8d;
text-transform: uppercase;
font-weight: 600;
min-width: 50px;
}
.preview-value {
font-family: 'Courier New', monospace;
font-size: 0.8rem;
color: #ecf0f1;
background: rgba(0, 0, 0, 0.3);
padding: 0.15rem 0.4rem;
border-radius: 3px;
}
.preview-value.type {
color: #9b59b6;
background: rgba(155, 89, 182, 0.15);
}
.preview-value.empty {
color: #7f8c8d;
font-style: italic;
}
/* No arguments state */
.no-arguments {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #95a5a6;
}
.no-args-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
.no-arguments p {
margin: 0;
font-size: 0.9rem;
}
/* Expand animation */
.expand-args-enter-active,
.expand-args-leave-active {
transition: all 0.3s ease;
max-height: 1000px;
overflow: hidden;
}
.expand-args-enter-from,
.expand-args-leave-to {
max-height: 0;
opacity: 0;
}
/* Responsive */
@media (max-width: 768px) {
.action-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.action-badges {
width: 100%;
justify-content: flex-end;
}
.argument-header {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
}
</style>

View File

@@ -0,0 +1,307 @@
<template>
<div
class="device-card"
:class="{ expanded: isExpanded }"
@click="handleClick"
>
<div class="card-header">
<div class="device-icon">
{{ getDeviceIcon(device.device_type) }}
</div>
<div class="device-info">
<h3 class="device-name">{{ device.friendly_name }}</h3>
<p class="device-type">{{ formatDeviceType(device.device_type) }}</p>
</div>
<div class="expand-icon">
{{ isExpanded ? '▼' : '▶' }}
</div>
</div>
<div class="card-body">
<div class="device-details">
<div class="detail-item">
<span class="detail-icon">🏷</span>
<span class="detail-label">Name:</span>
<span class="detail-value">{{ device.name }}</span>
</div>
<div class="detail-item">
<span class="detail-icon">🏭</span>
<span class="detail-label">Manufacturer:</span>
<span class="detail-value">{{ device.manufacturer }}</span>
</div>
<div class="detail-item">
<span class="detail-icon">📦</span>
<span class="detail-label">Model:</span>
<span class="detail-value">{{ device.model_name }}</span>
</div>
<div class="detail-item">
<span class="detail-icon">🔗</span>
<span class="detail-label">Base URL:</span>
<a :href="device.base_url" target="_blank" class="detail-value link">
{{ device.base_url }}
</a>
</div>
<div class="detail-item udn">
<span class="detail-icon">🆔</span>
<span class="detail-label">UDN:</span>
<code class="detail-value monospace">{{ device.udn }}</code>
</div>
</div>
<div class="card-actions">
<button
@click.stop="$emit('load-details')"
class="details-btn"
>
📋 View Services
</button>
<a
:href="device.description_url"
target="_blank"
class="xml-btn"
@click.stop
>
📄 Device XML
</a>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
device: {
type: Object,
required: true
},
isExpanded: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['toggle', 'load-details'])
function handleClick() {
emit('toggle')
}
function getDeviceIcon(deviceType) {
if (deviceType.includes('MediaRenderer')) return '🎵'
if (deviceType.includes('MediaServer')) return '💿'
if (deviceType.includes('Display')) return '🖥️'
return '📱'
}
function formatDeviceType(deviceType) {
// Extraire le type simple depuis l'URN
const match = deviceType.match(/device:([^:]+)/)
return match ? match[1] : deviceType
}
</script>
<style scoped>
.device-card {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
border-radius: 12px;
border: 2px solid #3498db;
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.device-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(52, 152, 219, 0.4);
border-color: #5dade2;
}
.device-card.expanded {
border-color: #2ecc71;
}
.card-header {
display: flex;
align-items: center;
padding: 1.25rem;
gap: 1rem;
background: rgba(0, 0, 0, 0.2);
}
.device-icon {
font-size: 2.5rem;
flex-shrink: 0;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
.device-info {
flex: 1;
min-width: 0;
}
.device-name {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
font-weight: 600;
color: #ecf0f1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-type {
margin: 0;
font-size: 0.85rem;
color: #3498db;
font-weight: 500;
}
.expand-icon {
font-size: 1.2rem;
color: #3498db;
transition: transform 0.3s;
flex-shrink: 0;
}
.device-card.expanded .expand-icon {
transform: rotate(0deg);
}
.card-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.device-card.expanded .card-body {
max-height: 500px;
}
.device-details {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
transition: background 0.2s;
}
.detail-item:hover {
background: rgba(52, 152, 219, 0.1);
}
.detail-item.udn {
flex-wrap: wrap;
}
.detail-icon {
font-size: 1.1rem;
flex-shrink: 0;
}
.detail-label {
font-weight: 600;
color: #95a5a6;
min-width: 100px;
flex-shrink: 0;
}
.detail-value {
color: #ecf0f1;
flex: 1;
word-break: break-word;
}
.monospace {
font-family: 'Courier New', monospace;
font-size: 0.8rem;
background: rgba(0, 0, 0, 0.3);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.link {
color: #3498db;
text-decoration: none;
transition: color 0.2s;
}
.link:hover {
color: #5dade2;
text-decoration: underline;
}
.card-actions {
display: flex;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(52, 152, 219, 0.3);
}
.details-btn,
.xml-btn {
flex: 1;
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.details-btn {
background: #3498db;
color: white;
}
.details-btn:hover {
background: #2980b9;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
}
.xml-btn {
background: #2ecc71;
color: white;
}
.xml-btn:hover {
background: #27ae60;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(46, 204, 113, 0.3);
}
/* Animation d'entrée */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.device-card {
animation: slideIn 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<div class="service-panel" :class="{ expanded: isExpanded }">
<div class="service-header" @click="toggleExpand">
<div class="service-icon">🔧</div>
<div class="service-info">
<h4 class="service-name">{{ service.name }}</h4>
<p class="service-type">{{ formatServiceType(service.service_type) }}</p>
</div>
<div class="service-badge">
{{ isExpanded ? '▼' : '▶' }}
</div>
</div>
<transition name="expand">
<div v-if="isExpanded" class="service-content">
<!-- URLs du service -->
<div class="service-urls">
<div class="url-item">
<span class="url-label">Control:</span>
<a :href="service.control_url" target="_blank" class="url-value">
{{ service.control_url }}
</a>
</div>
<div class="url-item">
<span class="url-label">Events:</span>
<a :href="service.event_url" target="_blank" class="url-value">
{{ service.event_url }}
</a>
</div>
<div class="url-item">
<span class="url-label">SCPD:</span>
<a :href="service.scpd_url" target="_blank" class="url-value">
{{ service.scpd_url }}
</a>
</div>
</div>
<!-- Onglets pour Variables / Actions -->
<div class="tabs">
<button
:class="['tab', { active: activeTab === 'variables' }]"
@click="activeTab = 'variables'"
>
📊 Variables
<span class="badge">{{ variablesCount }}</span>
</button>
<button
:class="['tab', { active: activeTab === 'actions' }]"
@click="activeTab = 'actions'"
>
Actions
<span class="badge">{{ actionsCount }}</span>
</button>
</div>
<!-- Contenu des onglets -->
<div class="tab-content">
<VariablesList
v-if="activeTab === 'variables'"
:device-udn="deviceUdn"
:service-name="service.name"
/>
<ActionsList
v-else
:service="service"
:device-udn="deviceUdn"
/>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import VariablesList from './VariablesList.vue'
import ActionsList from './ActionsList.vue'
const props = defineProps({
service: {
type: Object,
required: true
},
deviceUdn: {
type: String,
required: true
}
})
const isExpanded = ref(false)
const activeTab = ref('variables')
const variablesCount = computed(() => {
// Sera mis à jour dynamiquement par VariablesList
return '...'
})
const actionsCount = computed(() => {
return '...'
})
function toggleExpand() {
isExpanded.value = !isExpanded.value
}
function formatServiceType(serviceType) {
const match = serviceType.match(/service:([^:]+)/)
return match ? match[1] : serviceType
}
</script>
<style scoped>
.service-panel {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(52, 152, 219, 0.3);
border-radius: 8px;
margin-bottom: 1rem;
overflow: hidden;
transition: all 0.3s;
}
.service-panel:hover {
border-color: rgba(52, 152, 219, 0.6);
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.2);
}
.service-panel.expanded {
border-color: #3498db;
}
.service-header {
display: flex;
align-items: center;
padding: 1rem;
cursor: pointer;
gap: 0.75rem;
transition: background 0.2s;
}
.service-header:hover {
background: rgba(52, 152, 219, 0.1);
}
.service-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.service-info {
flex: 1;
min-width: 0;
}
.service-name {
margin: 0 0 0.25rem 0;
font-size: 1rem;
font-weight: 600;
color: #ecf0f1;
}
.service-type {
margin: 0;
font-size: 0.8rem;
color: #7f8c8d;
}
.service-badge {
color: #3498db;
font-size: 1rem;
transition: transform 0.3s;
flex-shrink: 0;
}
.service-content {
padding: 0 1rem 1rem 1rem;
}
.service-urls {
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 1rem;
}
.url-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
}
.url-item:last-child {
margin-bottom: 0;
}
.url-label {
font-weight: 600;
color: #95a5a6;
min-width: 80px;
font-size: 0.85rem;
}
.url-value {
color: #3498db;
text-decoration: none;
font-size: 0.85rem;
word-break: break-all;
}
.url-value:hover {
text-decoration: underline;
color: #5dade2;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 2px solid rgba(52, 152, 219, 0.2);
}
.tab {
flex: 1;
padding: 0.75rem 1rem;
background: transparent;
border: none;
color: #95a5a6;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-bottom: 3px solid transparent;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.tab:hover {
background: rgba(52, 152, 219, 0.1);
color: #ecf0f1;
}
.tab.active {
color: #3498db;
border-bottom-color: #3498db;
background: rgba(52, 152, 219, 0.05);
}
.badge {
background: rgba(52, 152, 219, 0.3);
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.tab.active .badge {
background: #3498db;
color: white;
}
.tab-content {
min-height: 200px;
}
/* Animations */
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease;
max-height: 1000px;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
</style>

View File

@@ -0,0 +1,546 @@
<template>
<div class="variables-list">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>Loading variables...</p>
</div>
<div v-else-if="error" class="error-state">
<span class="error-icon"></span>
<p>{{ error }}</p>
<button @click="loadVariables" class="retry-btn">Retry</button>
</div>
<div v-else-if="variables.length === 0" class="empty-state">
<span class="empty-icon">📭</span>
<p>No variables found for this service</p>
</div>
<div v-else class="variables-content">
<div class="variables-header">
<h4>State Variables ({{ variables.length }})</h4>
<button @click="loadVariables" class="refresh-btn" :disabled="loading">
🔄 Refresh
</button>
</div>
<div class="variables-grid">
<div
v-for="variable in variables"
:key="variable.name"
class="variable-card"
:class="{ 'has-events': variable.sends_events, 'has-value': variable.value }"
>
<div class="variable-header">
<span class="variable-name">{{ variable.name }}</span>
<div class="header-badges">
<span v-if="variable.sends_events" class="event-badge" title="Sends events">
🔔
</span>
<span class="type-badge" :title="variable.data_type">
{{ variable.data_type }}
</span>
</div>
</div>
<div class="variable-details">
<!-- Valeur actuelle - toujours affichée en premier -->
<div class="variable-row current-value">
<span class="variable-label">Current Value:</span>
<div class="value-display">
<code class="variable-value" :class="{ empty: !variable.value }">
{{ variable.value || '(empty)' }}
</code>
<button
v-if="variable.value"
@click="editingVar = editingVar === variable.name ? null : variable.name"
class="edit-btn"
title="Edit value"
>
</button>
</div>
</div>
<!-- Formulaire d'édition -->
<div v-if="editingVar === variable.name" class="edit-form">
<input
v-model="editValue"
:type="getInputType(variable.data_type)"
:placeholder="`Enter ${variable.data_type} value`"
class="edit-input"
@keyup.enter="saveValue(variable)"
@keyup.escape="editingVar = null"
/>
<div class="edit-actions">
<button @click="saveValue(variable)" class="save-btn">💾 Save</button>
<button @click="editingVar = null" class="cancel-btn">✖ Cancel</button>
</div>
</div>
<div v-if="variable.default_value" class="variable-row">
<span class="variable-label">Default:</span>
<code class="variable-value">{{ variable.default_value }}</code>
</div>
<div v-if="variable.allowed_values && variable.allowed_values.length > 0" class="variable-row">
<span class="variable-label">Allowed:</span>
<div class="allowed-values">
<code
v-for="(value, idx) in variable.allowed_values"
:key="idx"
class="allowed-value"
>
{{ value }}
</code>
</div>
</div>
<div v-if="variable.min || variable.max" class="variable-row">
<span class="variable-label">Range:</span>
<code class="variable-value">
{{ variable.min ?? '' }} → {{ variable.max ?? '+' }}
<span v-if="variable.step"> (step: {{ variable.step }})</span>
</code>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
const props = defineProps({
deviceUdn: {
type: String,
required: true
},
serviceName: {
type: String,
required: true
}
})
const variables = ref([])
const loading = ref(false)
const error = ref(null)
const editingVar = ref(null)
const editValue = ref('')
function getInputType(dataType) {
if (dataType.includes('int') || dataType.includes('ui')) return 'number'
if (dataType.includes('bool')) return 'checkbox'
return 'text'
}
async function loadVariables() {
if (!props.deviceUdn || !props.serviceName) return
loading.value = true
error.value = null
try {
const url = `/api/upnp/devices/${encodeURIComponent(props.deviceUdn)}/services/${encodeURIComponent(props.serviceName)}/variables`
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
variables.value = data.variables || []
} catch (err) {
error.value = err.message || 'Failed to load variables'
console.error('Error loading variables:', err)
} finally {
loading.value = false
}
}
async function saveValue(variable) {
// TODO: Implement API call to update variable value
console.log(`Saving ${variable.name} = ${editValue.value}`)
editingVar.value = null
editValue.value = ''
// Refresh to get updated value
await loadVariables()
}
// Load on mount
onMounted(() => {
loadVariables()
})
// Reload when props change
watch(() => [props.deviceUdn, props.serviceName], () => {
loadVariables()
})
// Set edit value when starting to edit
watch(editingVar, (newVar) => {
if (newVar) {
const variable = variables.value.find(v => v.name === newVar)
if (variable) {
editValue.value = variable.value || ''
}
}
})
</script>
<style scoped>
.variables-list {
min-height: 200px;
display: flex;
flex-direction: column;
}
/* Loading state */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #95a5a6;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(52, 152, 219, 0.3);
border-top-color: #3498db;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error state */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #e74c3c;
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-state p {
margin: 0 0 1rem 0;
color: #ecf0f1;
}
.retry-btn {
padding: 0.5rem 1rem;
background: #e74c3c;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.retry-btn:hover {
background: #c0392b;
transform: translateY(-2px);
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #95a5a6;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state p {
margin: 0;
}
/* Variables content */
.variables-content {
flex: 1;
}
.variables-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
margin-bottom: 1rem;
}
.variables-header h4 {
margin: 0;
color: #ecf0f1;
font-size: 1rem;
font-weight: 600;
}
.refresh-btn {
padding: 0.5rem 1rem;
background: rgba(52, 152, 219, 0.2);
color: #3498db;
border: 1px solid rgba(52, 152, 219, 0.3);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.refresh-btn:hover:not(:disabled) {
background: rgba(52, 152, 219, 0.3);
border-color: #3498db;
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Variables grid */
.variables-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.variable-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(52, 152, 219, 0.3);
border-radius: 8px;
padding: 1rem;
transition: all 0.2s;
}
.variable-card:hover {
border-color: rgba(52, 152, 219, 0.6);
background: rgba(0, 0, 0, 0.4);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.variable-card.has-events {
border-color: rgba(46, 204, 113, 0.4);
}
.variable-card.has-events:hover {
border-color: rgba(46, 204, 113, 0.7);
}
.variable-card.has-value {
border-left: 3px solid #3498db;
}
.variable-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(52, 152, 219, 0.2);
}
.variable-name {
font-weight: 600;
color: #3498db;
font-size: 0.95rem;
}
.header-badges {
display: flex;
gap: 0.5rem;
align-items: center;
}
.event-badge {
font-size: 1rem;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.type-badge {
padding: 0.2rem 0.5rem;
background: rgba(155, 89, 182, 0.2);
border: 1px solid rgba(155, 89, 182, 0.3);
border-radius: 4px;
color: #9b59b6;
font-size: 0.75rem;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.variable-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.variable-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.variable-row.current-value {
background: rgba(52, 152, 219, 0.1);
padding: 0.5rem;
border-radius: 4px;
border-left: 3px solid #3498db;
}
.variable-label {
font-size: 0.75rem;
color: #95a5a6;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.value-display {
display: flex;
align-items: center;
gap: 0.5rem;
}
.variable-value {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
color: #ecf0f1;
background: rgba(0, 0, 0, 0.3);
padding: 0.3rem 0.6rem;
border-radius: 4px;
flex: 1;
}
.variable-value.empty {
color: #7f8c8d;
font-style: italic;
}
.edit-btn {
padding: 0.3rem 0.5rem;
background: rgba(241, 196, 15, 0.2);
border: 1px solid rgba(241, 196, 15, 0.3);
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.edit-btn:hover {
background: rgba(241, 196, 15, 0.3);
border-color: #f1c40f;
}
/* Edit form */
.edit-form {
background: rgba(241, 196, 15, 0.1);
padding: 0.75rem;
border-radius: 4px;
border: 1px solid rgba(241, 196, 15, 0.3);
margin-top: 0.5rem;
}
.edit-input {
width: 100%;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(241, 196, 15, 0.3);
border-radius: 4px;
color: #ecf0f1;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.edit-input:focus {
outline: none;
border-color: #f1c40f;
background: rgba(0, 0, 0, 0.4);
}
.edit-actions {
display: flex;
gap: 0.5rem;
}
.save-btn,
.cancel-btn {
flex: 1;
padding: 0.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s;
}
.save-btn {
background: #27ae60;
color: white;
}
.save-btn:hover {
background: #229954;
}
.cancel-btn {
background: rgba(231, 76, 60, 0.2);
color: #e74c3c;
border: 1px solid rgba(231, 76, 60, 0.3);
}
.cancel-btn:hover {
background: rgba(231, 76, 60, 0.3);
border-color: #e74c3c;
}
.allowed-values {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.allowed-value {
font-family: 'Courier New', monospace;
font-size: 0.75rem;
color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
padding: 0.2rem 0.4rem;
border-radius: 4px;
border: 1px solid rgba(46, 204, 113, 0.3);
}
/* Responsive */
@media (max-width: 768px) {
.variables-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -2,11 +2,19 @@ import { createRouter, createWebHistory } from "vue-router";
import HelloWorld from "../components/HelloWorld.vue";
import LogView from "../components/LogView.vue";
import CoverCacheManager from "../components/CoverCacheManager.vue";
import AudioCacheManager from "../components/AudioCacheManager.vue";
import UpnpExplorer from "../components/UpnpExplorer.vue";
import APIDashboard from "../components/APIDashboard.vue";
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
const routes = [
{ path: "/", name: "home", component: HelloWorld },
{ path: "/logs", name: "logs", component: LogView },
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
{ path: "/audio-cache", name: "audio-cache", component: AudioCacheManager },
{ path: "/upnp", name: "upnp", component: UpnpExplorer },
{ path: "/api-dashboard", name: "api-dashboard", component: APIDashboard },
{ path: "/radio-paradise", name: "radio-paradise", component: RadioParadiseExplorer },
];
const router = createRouter({

View File

@@ -0,0 +1,192 @@
/**
* Service API pour interagir avec le cache de pistes audio
*/
export interface AudioMetadata {
title?: string;
artist?: string;
album?: string;
year?: number;
genre?: string;
track_number?: number;
disc_number?: number;
duration_ms?: number;
sample_rate?: number;
bitrate?: number;
channels?: number;
}
export interface AudioCacheEntry {
pk: string;
source_url: string;
hits: number;
last_used: string | null;
collection?: string;
metadata?: AudioMetadata;
}
export interface AddTrackRequest {
url: string;
collection?: string;
}
export interface AddTrackResponse {
pk: string;
url: string;
message: string;
}
export interface DownloadStatus {
pk: string;
status: "pending" | "downloading" | "completed" | "failed";
progress?: number;
error?: string;
}
export interface ApiError {
error: string;
message: string;
}
/**
* Liste toutes les pistes en cache
*/
export async function listTracks(): Promise<AudioCacheEntry[]> {
const response = await fetch("/api/audio");
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to fetch tracks");
}
return response.json();
}
/**
* Récupère les informations d'une piste spécifique
*/
export async function getTrackInfo(pk: string): Promise<AudioCacheEntry> {
const response = await fetch(`/api/audio/${pk}`);
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to fetch track info");
}
return response.json();
}
/**
* Récupère le statut de téléchargement d'une piste
*/
export async function getDownloadStatus(pk: string): Promise<DownloadStatus> {
const response = await fetch(`/api/audio/${pk}/status`);
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to fetch download status");
}
return response.json();
}
/**
* Ajoute une nouvelle piste au cache depuis une URL
*/
export async function addTrack(url: string, collection?: string): Promise<AddTrackResponse> {
const body: AddTrackRequest = { url };
if (collection) {
body.collection = collection;
}
const response = await fetch("/api/audio", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to add track");
}
return response.json();
}
/**
* Supprime une piste du cache
*/
export async function deleteTrack(pk: string): Promise<void> {
const response = await fetch(`/api/audio/${pk}`, {
method: "DELETE",
});
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to delete track");
}
}
/**
* Purge complètement le cache
*/
export async function purgeCache(): Promise<void> {
const response = await fetch("/api/audio", {
method: "DELETE",
});
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to purge cache");
}
}
/**
* Consolide le cache (re-télécharge les pistes manquantes)
*/
export async function consolidateCache(): Promise<void> {
const response = await fetch("/api/audio/consolidate", {
method: "POST",
});
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to consolidate cache");
}
}
/**
* Génère l'URL pour streamer une piste
*/
export function getTrackUrl(pk: string): string {
return `/audio/flac/${pk}`;
}
/**
* Génère l'URL pour télécharger la piste originale
*/
export function getOriginalTrackUrl(pk: string): string {
return `/audio/flac/${pk}/orig`;
}
/**
* Formatte la durée en millisecondes au format MM:SS
*/
export function formatDuration(ms?: number): string {
if (!ms) return "Unknown";
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
}
/**
* Formatte le bitrate en kbps
*/
export function formatBitrate(bitrate?: number): string {
if (!bitrate) return "Unknown";
return `${Math.round(bitrate / 1000)} kbps`;
}
/**
* Formatte le sample rate en kHz
*/
export function formatSampleRate(sampleRate?: number): string {
if (!sampleRate) return "Unknown";
return `${(sampleRate / 1000).toFixed(1)} kHz`;
}

View File

@@ -114,7 +114,7 @@ export async function consolidateCache(): Promise<void> {
*/
export function getImageUrl(pk: string, size?: number): string {
if (size) {
return `/covers/images/${pk}/${size}`;
return `/covers/image/${pk}/${size}`;
}
return `/covers/images/${pk}`;
return `/covers/image/${pk}`;
}

View File

@@ -25,10 +25,11 @@ a:hover {
body {
margin: 0;
display: flex;
place-items: center;
flex-direction: column;
min-width: 320px;
min-height: 100vh;
width: 100vw;
width: 100%;
overflow-x: hidden;
}
h1 {
@@ -60,10 +61,13 @@ button:focus-visible {
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
width: 100%;
min-height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
@media (prefers-color-scheme: light) {

View File

@@ -5,4 +5,16 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: '/app/', // Base path pour le déploiement
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/audio': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})

View File

@@ -0,0 +1,246 @@
# Changelog - Extensions Multiroom et Volume
## Version 0.2.0 - Extensions Multiroom
### Nouvelles fonctionnalités
#### 1. Contrôle de volume dynamique
- **VolumeNode** : node de contrôle de volume software thread-safe
- **HardwareVolumeNode** : variant pour contrôle matériel (prévu)
- **VolumeHandle** : handle pour contrôler le volume depuis un autre contexte
- **Système master/slave** : synchronisation automatique du volume entre branches
#### 2. Nouveaux types de sinks
- **DiskSink** : écriture sur disque (WAV, FLAC, PCM)
- Dérivation automatique du nom de fichier depuis la source
- Application automatique du gain avant écriture
- **ChromecastSink** : diffusion vers Chromecast (mock)
- **MpdSink** : streaming vers MPD (mock)
#### 3. Système d'événements
- **EventPublisher/EventReceiver** : système d'abonnement générique type-safe
- **VolumeChangeEvent** : notification de changement de volume
- **SourceNameUpdateEvent** : mise à jour du nom de source
- **AudioDataEvent** : transport de données audio via événements
#### 4. Extensions AudioChunk
- Nouveau champ `gain: f32` pour contrôle de volume lazy
- `with_gain()` : constructeur avec gain
- `apply_gain()` : application du gain sur les samples
- `with_modified_gain()` : modification du gain sans copie
### Modules ajoutés
```
src/
├── events.rs [NOUVEAU]
└── nodes/
├── volume_node.rs [NOUVEAU]
├── disk_sink.rs [NOUVEAU]
├── chromecast_sink.rs [NOUVEAU]
└── mpd_sink.rs [NOUVEAU]
examples/
├── volume_control_demo.rs [NOUVEAU]
└── multiroom_volume_demo.rs [NOUVEAU]
```
### API publique
#### Exports ajoutés dans lib.rs
```rust
// Events
pub use events::{
AudioDataEvent,
EventPublisher,
EventReceiver,
NodeEvent,
NodeListener,
SourceNameUpdateEvent,
VolumeChangeEvent,
};
// Volume nodes
pub use nodes::volume_node::{
HardwareVolumeNode,
VolumeHandle,
VolumeNode,
};
// Sinks
pub use nodes::disk_sink::{
AudioFileFormat,
DiskSink,
DiskSinkConfig,
DiskSinkStats,
};
pub use nodes::chromecast_sink::{
ChromecastConfig,
ChromecastSink,
ChromecastStats,
StreamEncoding,
};
pub use nodes::mpd_sink::{
MpdAudioFormat,
MpdConfig,
MpdHandle,
MpdSink,
MpdStats,
};
```
### Modifications de types existants
#### AudioChunk
```rust
pub struct AudioChunk {
pub order: u64,
pub left: Arc<Vec<f32>>,
pub right: Arc<Vec<f32>>,
pub sample_rate: u32,
pub gain: f32, // [NOUVEAU]
}
impl AudioChunk {
// Méthodes existantes (inchangées)
pub fn new(...) -> Self;
pub fn from_arc(...) -> Self;
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
pub fn clone_data(&self) -> (Vec<f32>, Vec<f32>);
// Nouvelles méthodes
pub fn with_gain(..., gain: f32) -> Self; // [NOUVEAU]
pub fn from_arc_with_gain(..., gain: f32) -> Self;// [NOUVEAU]
pub fn apply_gain(&self) -> Self; // [NOUVEAU]
pub fn with_modified_gain(&self, new_gain: f32) -> Self; // [NOUVEAU]
}
```
### Tests
- 12 nouveaux tests unitaires
- Tous les tests existants continuent de passer
- **Total : 31 tests, 0 failures**
### Exemples
- `volume_control_demo` : contrôle de volume simple
- `multiroom_volume_demo` : pipeline multiroom complet
### Breaking changes
**Aucun** - Toutes les modifications sont additives.
### Performances
- **Zero-copy maintenu** : partage des `Arc<AudioChunk>` entre branches
- **Lazy evaluation** : gain non appliqué jusqu'au sink
- **Thread-safe** : `RwLock` pour le volume, channels Tokio
### Documentation
- `FEATURES_EXTENDED.md` : documentation complète des fonctionnalités
- `IMPLEMENTATION_SUMMARY.md` : résumé technique de l'implémentation
- Commentaires inline dans le code
---
## Migration depuis 0.1.0
Aucune migration nécessaire. Le code existant fonctionne sans modification.
### Pour utiliser les nouvelles fonctionnalités
#### Ajouter un contrôle de volume
```rust
// Avant
source.add_subscriber(sink_tx);
// Après
let (mut volume, volume_tx) = VolumeNode::new("main", 1.0, 10);
let handle = volume.get_handle();
volume.add_subscriber(sink_tx);
source.add_subscriber(volume_tx);
tokio::spawn(async move { volume.run().await });
// Modifier le volume dynamiquement
handle.set_volume(0.5).await;
```
#### Écrire sur disque
```rust
let config = DiskSinkConfig {
output_dir: PathBuf::from("/tmp/audio"),
filename: Some("output.wav".to_string()),
..Default::default()
};
let (disk_sink, disk_tx) = DiskSink::new("disk1".to_string(), config, 10);
// Connecter au pipeline
volume.add_subscriber(disk_tx);
// Lancer
tokio::spawn(async move {
let stats = disk_sink.run().await.unwrap();
stats.display();
});
```
#### Configuration multiroom
```rust
// Volume master
let (mut master, master_tx) = VolumeNode::new("master", 1.0, 50);
let (event_tx, event_rx1) = mpsc::channel(10);
let (_, event_rx2) = mpsc::channel(10);
master.subscribe_volume_events(event_tx);
source.add_subscriber(master_tx);
// Branche 1
let (mut vol1, vol1_tx) = VolumeNode::new("room1", 0.8, 50);
vol1.set_master_volume_source(event_rx1);
vol1.add_subscriber(sink1_tx);
master.add_subscriber(vol1_tx);
// Branche 2
let (mut vol2, vol2_tx) = VolumeNode::new("room2", 0.9, 50);
vol2.set_master_volume_source(event_rx2);
vol2.add_subscriber(sink2_tx);
master.add_subscriber(vol2_tx);
// Contrôle master
let master_handle = master.get_handle();
master_handle.set_volume(0.7).await; // Affecte toutes les branches
```
---
## Roadmap
### v0.3.0 (prévu)
- [ ] Implémentation réelle ChromecastSink avec `rust-cast`
- [ ] Implémentation réelle MpdSink avec protocole MPD
- [ ] Support FLAC dans DiskSink avec `claxon`
- [ ] AirPlaySink (diffusion AirPlay/AirPlay 2)
- [ ] EqualizerNode (égaliseur paramétrique)
### v0.4.0 (prévu)
- [ ] PulseAudioSink / AlsaSink / CoreAudioSink
- [ ] CompressorNode / LimiterNode (dynamiques)
- [ ] ReverbNode (réverbération)
- [ ] CrossfadeNode (transition entre sources)
- [ ] HttpStreamSink (serveur Icecast/Shoutcast)
### v1.0.0 (futur)
- [ ] Synchronisation NTP/PTP pour multi-device
- [ ] Room correction avec FIR filters
- [ ] API REST pour contrôle
- [ ] Dashboard web
- [ ] Documentation complète utilisateur
---
## Contributeurs
- Implémentation initiale : Assistant Claude
- Architecture PMOAudio : Projet PMOMusic
## Licence
Partie du projet PMOMusic

11
pmoaudio/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "pmoaudio"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.42", features = ["full"] }
async-trait = "0.1"
[dev-dependencies]
tokio-test = "0.4"

View File

@@ -0,0 +1,524 @@
# PMOAudio - Extensions Multiroom et Contrôle de Volume
## Vue d'ensemble
Ce document décrit les extensions apportées au système PMOAudio pour supporter :
- **Contrôle de volume** dynamique avec synchronisation master/secondaire
- **Nouveaux types de sinks** : DiskSink, ChromecastSink, MpdSink
- **Système d'événements générique** pour la communication inter-nodes
- **Champ gain** dans AudioChunk pour le contrôle du volume en pipeline
---
## 1. AudioChunk avec gain
Le type `AudioChunk` a été étendu avec un champ `gain: f32` qui permet de contrôler le volume de manière lazy (le gain est appliqué au moment voulu, pas immédiatement).
### Nouvelles méthodes
```rust
// Créer un chunk avec gain spécifique
let chunk = AudioChunk::with_gain(0, left, right, 48000, 0.5);
// Modifier le gain d'un chunk existant (cheap, pas de copie)
let modified = chunk.with_modified_gain(0.8);
// Appliquer le gain et matérialiser les données modifiées
let applied = chunk.apply_gain();
```
### Comportement
- Le gain par défaut est `1.0` (aucun changement)
- Les gains se multiplient en cascade (utile pour chaîner plusieurs VolumeNode)
- `apply_gain()` crée un nouveau chunk avec les samples multipliés par le gain
---
## 2. Système d'événements générique
Un système d'abonnement type-safe permet aux nodes d'émettre et de recevoir différents types d'événements.
### Types d'événements disponibles
```rust
// Événement de changement de volume
VolumeChangeEvent {
volume: f32,
source_node_id: String,
}
// Événement de mise à jour du nom de source
SourceNameUpdateEvent {
source_name: String,
device_name: Option<String>,
}
// Événement de données audio (pour référence)
AudioDataEvent {
chunk: Arc<AudioChunk>,
}
```
### Utilisation
```rust
// Créer un publisher
let mut volume_publisher = EventPublisher::<VolumeChangeEvent>::new();
// S'abonner
let (tx, mut rx) = mpsc::channel(10);
volume_publisher.subscribe(tx);
// Publier un événement
let event = VolumeChangeEvent {
volume: 0.7,
source_node_id: "master".to_string(),
};
volume_publisher.publish(event).await;
// Recevoir
let received = rx.recv().await;
```
---
## 3. VolumeNode - Contrôle de volume software
Le `VolumeNode` permet d'ajuster dynamiquement le volume du flux audio.
### Caractéristiques
- **Thread-safe** : le volume peut être modifié pendant l'exécution
- **Notification** : émet des événements lors des changements
- **Master/Slave** : peut s'abonner à un volume master
- **Lazy application** : modifie le champ `gain` du chunk, pas les données
### Exemple de base
```rust
// Créer un VolumeNode avec volume initial 0.8
let (mut volume_node, volume_tx) = VolumeNode::new(
"room1".to_string(),
0.8, // volume initial
10 // taille du channel
);
// Obtenir un handle pour contrôler le volume
let handle = volume_node.get_handle();
// Modifier le volume depuis un autre contexte
tokio::spawn(async move {
handle.set_volume(0.5).await;
});
// Lancer le node
tokio::spawn(async move {
volume_node.run().await.unwrap()
});
```
### Configuration Master/Slave
```rust
// Créer le master
let (mut master, master_tx) = VolumeNode::new("master".to_string(), 1.0, 10);
let (master_event_tx, master_event_rx) = mpsc::channel(10);
master.subscribe_volume_events(master_event_tx);
let master_handle = master.get_handle();
// Créer le slave
let (mut slave, slave_tx) = VolumeNode::new("slave".to_string(), 0.8, 10);
slave.set_master_volume_source(master_event_rx);
// Le slave appliquera maintenant: local_volume * master_volume
// Ex: si master=0.5 et local=0.8, le gain final sera 0.4
```
---
## 4. HardwareVolumeNode
Version spécialisée pour contrôle hardware du volume (via driver audio).
**Note** : L'implémentation actuelle est identique à `VolumeNode`. Dans une vraie implémentation, elle communiquerait avec le driver système (ALSA, CoreAudio, WASAPI, etc.).
```rust
let (hw_volume, hw_tx) = HardwareVolumeNode::new(
"hardware".to_string(),
0.8,
10
);
let handle = hw_volume.get_handle();
handle.set_volume(0.9).await; // Ajusterait le volume matériel
```
---
## 5. DiskSink - Écriture sur disque
Le `DiskSink` écrit le flux audio dans un fichier sur disque avec support de plusieurs formats.
### Caractéristiques
- **Dérivation automatique du nom** : peut utiliser le nom de la source
- **Formats supportés** : WAV, FLAC (mock), PCM brut
- **Application du gain** : applique automatiquement le gain avant l'écriture
- **Écriture asynchrone** avec buffer
### Configuration
```rust
let config = DiskSinkConfig {
output_dir: PathBuf::from("/tmp/audio"),
filename: Some("output.wav".to_string()), // ou None pour dérivation auto
format: AudioFileFormat::Wav,
buffer_size: 100,
};
let (disk_sink, disk_tx) = DiskSink::new("disk1".to_string(), config, 10);
```
### Dérivation du nom de fichier
Si `filename` est `None`, le DiskSink peut écouter les événements `SourceNameUpdateEvent` pour dériver automatiquement le nom :
```rust
let (source_name_tx, source_name_rx) = mpsc::channel(10);
disk_sink.set_source_name_source(source_name_rx);
// Quand un événement est reçu
let event = SourceNameUpdateEvent {
source_name: "My_Song.mp3".to_string(),
device_name: None,
};
source_name_tx.send(event).await;
// Le fichier sera créé comme: /tmp/audio/My_Song_mp3.wav
```
### Formats supportés
```rust
// WAV (16-bit PCM stéréo)
AudioFileFormat::Wav
// FLAC (nécessite bibliothèque externe - actuellement utilise WAV)
AudioFileFormat::Flac
// PCM brut (pas d'en-tête)
AudioFileFormat::Raw
```
---
## 6. ChromecastSink - Diffusion Chromecast
Streame l'audio vers un périphérique Chromecast.
**Note** : Implémentation mock. Une vraie implémentation nécessiterait une bibliothèque comme `rust-cast`.
### Configuration
```rust
let config = ChromecastConfig {
device_address: "192.168.1.100".to_string(),
device_name: "Living Room".to_string(),
port: 8009,
buffer_size: 50,
encoding: StreamEncoding::Mp3,
};
let (chromecast_sink, chromecast_tx) = ChromecastSink::new(
"chromecast1".to_string(),
config,
10
);
```
### Encodages supportés
```rust
StreamEncoding::Mp3 // Compatible avec la plupart des Chromecasts
StreamEncoding::Aac // Haute qualité
StreamEncoding::Opus // Faible latence
StreamEncoding::Pcm // Non compressé (haute bande passante)
```
---
## 7. MpdSink - Streaming vers MPD
Envoie le flux à un démon MPD (Music Player Daemon).
**Note** : Implémentation mock. Une vraie implémentation nécessiterait le protocole MPD complet.
### Configuration
```rust
let config = MpdConfig {
host: "localhost".to_string(),
port: 6600,
password: Some("secret".to_string()),
output_name: Some("ALSA".to_string()),
buffer_size: 50,
format: MpdAudioFormat::S16Le,
};
let (mpd_sink, mpd_tx) = MpdSink::new("mpd1".to_string(), config, 10);
```
### Contrôle MPD
Le MpdSink fournit un handle pour contrôler la lecture :
```rust
let handle = mpd_sink.get_handle();
handle.play().await;
handle.pause().await;
handle.set_volume(75).await; // 0-100
handle.stop().await;
```
### Formats audio MPD
```rust
MpdAudioFormat::S16Le // 16-bit signed
MpdAudioFormat::S24Le // 24-bit signed
MpdAudioFormat::S32Le // 32-bit signed
MpdAudioFormat::F32 // Float 32-bit
```
---
## 8. Pipeline Multiroom Complet
Voici un exemple complet d'utilisation de toutes les fonctionnalités :
```rust
use pmoaudio::{
SourceNode, VolumeNode, ChromecastSink, DiskSink,
ChromecastConfig, DiskSinkConfig,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Source audio
let mut source = SourceNode::new();
// 2. Volume master
let (mut master_volume, master_tx) = VolumeNode::new("master".to_string(), 1.0, 50);
let master_handle = master_volume.get_handle();
let (master_event_tx, master_event_rx_chromecast) = mpsc::channel(10);
let (_, master_event_rx_disk) = mpsc::channel(10);
master_volume.subscribe_volume_events(master_event_tx);
source.add_subscriber(master_tx);
// 3. Branche Chromecast avec volume secondaire
let (mut chromecast_volume, chromecast_volume_tx) =
VolumeNode::new("chromecast_volume".to_string(), 0.8, 50);
chromecast_volume.set_master_volume_source(master_event_rx_chromecast);
let chromecast_config = ChromecastConfig {
device_address: "192.168.1.100".to_string(),
device_name: "Living Room".to_string(),
..Default::default()
};
let (chromecast_sink, chromecast_sink_tx) =
ChromecastSink::new("chromecast1".to_string(), chromecast_config, 50);
chromecast_volume.add_subscriber(chromecast_sink_tx);
master_volume.add_subscriber(chromecast_volume_tx);
// 4. Branche DiskSink avec volume secondaire
let (mut disk_volume, disk_volume_tx) =
VolumeNode::new("disk_volume".to_string(), 0.9, 50);
disk_volume.set_master_volume_source(master_event_rx_disk);
let disk_config = DiskSinkConfig {
output_dir: std::env::temp_dir().join("audio"),
filename: Some("output.wav".to_string()),
..Default::default()
};
let (disk_sink, disk_sink_tx) =
DiskSink::new("disk1".to_string(), disk_config, 50);
disk_volume.add_subscriber(disk_sink_tx);
master_volume.add_subscriber(disk_volume_tx);
// 5. Lancer tous les nodes
tokio::spawn(async move { master_volume.run().await.unwrap() });
tokio::spawn(async move { chromecast_volume.run().await.unwrap() });
tokio::spawn(async move { disk_volume.run().await.unwrap() });
let chromecast_handle = tokio::spawn(async move {
chromecast_sink.run().await.unwrap()
});
let disk_handle = tokio::spawn(async move {
disk_sink.run().await.unwrap()
});
// 6. Contrôler le volume dynamiquement
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
master_handle.set_volume(0.7).await;
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
master_handle.set_volume(0.4).await;
});
// 7. Générer et streamer l'audio
tokio::spawn(async move {
source.generate_chunks(50, 4800, 48000, 440.0).await.unwrap();
});
// 8. Attendre la fin
chromecast_handle.await?;
disk_handle.await?;
Ok(())
}
```
---
## Architecture du pipeline multiroom
```text
┌──────────────┐
│ SourceNode │
└──────┬───────┘
┌──────────────┐
│ MasterVolume │ ───────► VolumeChangeEvent
└──────┬───────┘ │
│ │
├──────────────────────┼────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌──────────────┐ │
│ChromecastVolume │ │ DiskVolume │ │
│ (0.8 local) │ │ (0.9 local) │ │
└────────┬────────┘ └──────┬───────┘ │
│ │ │
│ gain=master×local │ │
▼ ▼ ▼
┌─────────────────┐ ┌──────────────┐ ...
│ ChromecastSink │ │ DiskSink │
│ Living Room │ │ output.wav │
└─────────────────┘ └──────────────┘
```
### Flux des données
1. **SourceNode** génère des chunks audio avec `gain = 1.0`
2. **MasterVolume** modifie le gain : `chunk.gain *= master_volume`
3. Chaque **branche secondaire** :
- Reçoit le chunk du master
- Applique son volume local : `chunk.gain *= local_volume`
- Envoie au sink
4. Les **sinks** appliquent le gain final avant l'output
---
## Optimisations
### Zero-copy jusqu'au bout
- Les chunks audio (`Arc<AudioChunk>`) sont partagés entre branches
- Seule la structure est clonée (cheap), pas les données audio
- Le gain est stocké dans le chunk, pas appliqué immédiatement
### Application lazy du gain
```rust
// Modification du gain : O(1), pas de copie
let modified = chunk.with_modified_gain(0.5);
// Application : O(n), copie et multiplie les samples
let applied = chunk.apply_gain();
```
### Thread-safety
- `VolumeHandle` utilise `Arc<RwLock<f32>>` pour partager le volume
- Changements de volume thread-safe et non-bloquants
- `EventPublisher` utilise `try_send` pour éviter les blocages
---
## Tests
Tous les composants incluent des tests unitaires :
```bash
cargo test --lib
```
### Tests disponibles
- `test_volume_node_basic` : test de base du VolumeNode
- `test_volume_handle` : modification du volume via handle
- `test_volume_events` : publication d'événements
- `test_master_slave_volume` : synchronisation master/slave
- `test_disk_sink_basic` : écriture sur disque
- `test_chromecast_sink_basic` : simulation Chromecast
- `test_mpd_sink_basic` : simulation MPD
---
## Exemples
Deux exemples complets sont fournis :
### 1. Volume Control Demo
Démontre le contrôle dynamique du volume :
```bash
cargo run --example volume_control_demo
```
### 2. Multiroom Volume Demo
Démontre un pipeline complet avec deux branches et synchronisation master/slave :
```bash
cargo run --example multiroom_volume_demo
```
---
## Évolutions futures
### Implémentations réelles des sinks
1. **ChromecastSink** : intégrer `rust-cast` ou équivalent
2. **MpdSink** : implémenter le protocole MPD complet
3. **DiskSink FLAC** : intégrer `flac` ou `symphonia`
### Nouveaux sinks possibles
- `AirPlaySink` : diffusion vers AirPlay/AirPlay 2
- `PulseAudioSink` : sortie vers PulseAudio
- `AlsaSink` : sortie directe ALSA (Linux)
- `CoreAudioSink` : sortie CoreAudio (macOS)
- `WasapiSink` : sortie WASAPI (Windows)
- `HttpStreamSink` : serveur HTTP pour streaming
- `RtpSink` : streaming RTP/UDP
### Fonctionnalités avancées
- **Égaliseur** : `EqualizerNode` avec bandes paramétriques
- **Compresseur/Limiteur** : `DynamicsNode`
- **Crossfade** : transition entre sources
- **Room correction** : correction acoustique par pièce
- **Synchronisation multi-device** : timing précis avec NTP/PTP
---
## Licence
Ce code fait partie du projet PMOMusic.

View File

@@ -0,0 +1,426 @@
# Résumé de l'implémentation - Extensions PMOAudio
## Objectif
Étendre le système de pipeline audio PMOAudio existant pour supporter :
- Contrôle de volume dynamique avec synchronisation master/secondaire
- Nouveaux types de sinks (Chromecast, MPD, Disk)
- Système d'événements générique pour communication inter-nodes
- Architecture multiroom avec flux dupliqués et volumes indépendants
---
## Modifications apportées
### 1. AudioChunk - Extension avec gain (src/audio_chunk.rs)
**Ajouts :**
- Champ `gain: f32` (valeur par défaut : 1.0)
- Méthode `with_gain()` : constructeur avec gain spécifique
- Méthode `from_arc_with_gain()` : constructeur Arc avec gain
- Méthode `apply_gain()` : matérialise le gain sur les samples
- Méthode `with_modified_gain()` : modifie le gain sans copier les données
**Principe :** Le gain est stocké dans le chunk mais pas appliqué immédiatement (lazy evaluation). Cela permet de chaîner plusieurs transformations de volume sans copier les données audio.
---
### 2. Système d'événements (src/events.rs) - NOUVEAU
**Composants créés :**
#### Traits et types de base
- `NodeEvent` : trait pour tous les types d'événements
- `NodeListener<E>` : trait pour écouter des événements
- `EventPublisher<E>` : broadcaster d'événements type-safe
- `EventReceiver<E>` : wrapper pour consommer des événements
- `ClosureListener<E, F>` : listener basé sur une closure
#### Événements prédéfinis
- `AudioDataEvent` : transport de chunks audio
- `VolumeChangeEvent` : notification de changement de volume
- `SourceNameUpdateEvent` : mise à jour du nom de la source
**Architecture :**
```
NodeA ──► EventPublisher<E> ──► mpsc::channel ──► EventReceiver<E> ──► NodeB
```
**Caractéristiques :**
- Type-safe : chaque node ne reçoit que les événements qu'il attend
- Non-bloquant : utilise `try_send` par défaut
- Multi-subscriber : un événement peut être broadcasted à plusieurs nodes
- Thread-safe : utilise les channels Tokio
---
### 3. VolumeNode (src/nodes/volume_node.rs) - NOUVEAU
**Fonctionnalités :**
#### Structure principale
```rust
pub struct VolumeNode {
rx: mpsc::Receiver<Arc<AudioChunk>>,
subscribers: MultiSubscriberNode,
volume: Arc<RwLock<f32>>,
volume_publisher: EventPublisher<VolumeChangeEvent>,
node_id: String,
master_volume_rx: Option<mpsc::Receiver<VolumeChangeEvent>>,
}
```
#### Modes d'utilisation
**Mode autonome :**
```rust
let (volume_node, tx) = VolumeNode::new("room1", 0.8, 10);
let handle = volume_node.get_handle();
handle.set_volume(0.5).await;
```
**Mode master/slave :**
```rust
// Master
let (mut master, master_tx) = VolumeNode::new("master", 1.0, 10);
let (event_tx, event_rx) = mpsc::channel(10);
master.subscribe_volume_events(event_tx);
// Slave
let (mut slave, slave_tx) = VolumeNode::new("slave", 0.8, 10);
slave.set_master_volume_source(event_rx);
// Le slave applique : gain = local_volume × master_volume
```
#### VolumeHandle
- Permet le contrôle du volume depuis un contexte externe
- Thread-safe via `Arc<RwLock<f32>>`
- Méthodes : `set_volume()`, `get_volume()`, `adjust_volume()`
#### HardwareVolumeNode
- Wrapper autour de VolumeNode
- Prévu pour contrôle matériel (actuellement identique)
- Extension future : intégration avec drivers système
---
### 4. DiskSink (src/nodes/disk_sink.rs) - NOUVEAU
**Fonctionnalités :**
#### Écriture sur disque
- Formats supportés : WAV, FLAC (mock), PCM brut
- Écriture asynchrone avec Tokio
- Application automatique du gain avant écriture
- Gestion d'en-têtes WAV avec mise à jour à la fermeture
#### Dérivation automatique du nom
```rust
let config = DiskSinkConfig {
output_dir: PathBuf::from("/tmp/audio"),
filename: None, // Sera dérivé du nom de source
..Default::default()
};
disk_sink.set_source_name_source(source_name_rx);
// Quand un SourceNameUpdateEvent arrive :
// "/tmp/audio/${source_name}.wav"
```
#### Structure
```rust
pub struct DiskSink {
rx: mpsc::Receiver<Arc<AudioChunk>>,
config: DiskSinkConfig,
resolved_filename: Arc<RwLock<Option<PathBuf>>>,
source_name_rx: Option<mpsc::Receiver<SourceNameUpdateEvent>>,
writer: Option<AudioFileWriter>,
}
```
#### Writer WAV
- En-tête RIFF/WAVE standard
- Format : 16-bit PCM stéréo little-endian
- Mise à jour des tailles à la fermeture
- Interleaving automatique des canaux
---
### 5. ChromecastSink (src/nodes/chromecast_sink.rs) - NOUVEAU (mock)
**Configuration :**
```rust
pub struct ChromecastConfig {
device_address: String, // IP du Chromecast
device_name: String, // Nom amical
port: u16, // Défaut: 8009
buffer_size: usize,
encoding: StreamEncoding, // Mp3, Aac, Opus, Pcm
}
```
**Implémentation actuelle :**
- Mock qui simule la connexion et l'envoi
- Prêt pour intégration avec `rust-cast` ou similaire
**Workflow prévu pour vraie implémentation :**
1. Connexion TLS avec le device
2. Lancement d'une application de récepteur
3. Encodage de l'audio dans le format choisi
4. Streaming via HTTP ou WebSocket
5. Gestion des commandes (play, pause, stop)
---
### 6. MpdSink (src/nodes/mpd_sink.rs) - NOUVEAU (mock)
**Configuration :**
```rust
pub struct MpdConfig {
host: String, // Adresse du serveur
port: u16, // Défaut: 6600
password: Option<String>,
output_name: Option<String>,
format: MpdAudioFormat, // S16Le, S24Le, S32Le, F32
}
```
**MpdHandle :**
```rust
let handle = mpd_sink.get_handle();
handle.play().await;
handle.pause().await;
handle.set_volume(75).await; // 0-100
handle.stop().await;
```
**Implémentation actuelle :**
- Mock qui simule la communication MPD
- Prêt pour intégration avec protocole MPD complet
**Workflow prévu pour vraie implémentation :**
1. Connexion TCP au serveur MPD
2. Lecture de la bannière de version
3. Authentification si nécessaire
4. Configuration du format audio
5. Streaming des données PCM
6. Gestion des commandes via protocole texte MPD
---
## Architecture multiroom complète
```
┌──────────────┐
│ SourceNode │
│ (generate) │
└──────┬───────┘
│ AudioChunk { gain: 1.0 }
┌──────────────┐
│ MasterVolume │
│ (volume=1.0) │
└──────┬───────┘
│ ├─► VolumeChangeEvent
┌─────────────┴─────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ChromecastVolume │ │ DiskVolume │
│ local = 0.8 │ │ local = 0.9 │
│ ◄─ Master evt │ │ ◄─ Master evt │
└────────┬────────┘ └────────┬────────┘
│ │
│ gain = 1.0×0.8 │ gain = 1.0×0.9
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ ChromecastSink │ │ DiskSink │
│ 192.168.1.100 │ │ output.wav │
│ apply_gain() │ │ apply_gain() │
└─────────────────┘ └─────────────────┘
```
### Flux des données
1. **SourceNode** : génère chunks avec `gain = 1.0`
2. **MasterVolume** :
- Multiplie `chunk.gain *= master_volume`
- Publie `VolumeChangeEvent` si changement
3. **Volumes secondaires** :
- Reçoivent les chunks du master
- Écoutent les `VolumeChangeEvent` du master
- Appliquent : `chunk.gain *= local_volume`
4. **Sinks** :
- Appellent `chunk.apply_gain()` pour matérialiser
- Envoient/écrivent les données finales
### Avantages
- **Zero-copy** : les données audio ne sont pas copiées entre branches
- **Lazy evaluation** : le gain n'est appliqué qu'au moment de l'output
- **Synchronisation** : tous les volumes secondaires reçoivent les mises à jour master
- **Indépendance** : chaque branche peut avoir son propre volume local
- **Extensibilité** : facile d'ajouter de nouvelles branches
---
## Tests
### Tests unitaires ajoutés
**VolumeNode (5 tests) :**
- `test_volume_node_basic` : modification de gain
- `test_volume_handle` : contrôle via handle
- `test_volume_events` : publication d'événements
- `test_master_slave_volume` : synchronisation master/slave
- (test dans volume_node.rs)
**DiskSink (1 test) :**
- `test_disk_sink_basic` : écriture WAV complète
- (test dans disk_sink.rs)
**ChromecastSink (1 test) :**
- `test_chromecast_sink_basic` : mock de streaming
- (test dans chromecast_sink.rs)
**MpdSink (2 tests) :**
- `test_mpd_sink_basic` : mock de communication
- `test_mpd_handle` : commandes de contrôle
- (test dans mpd_sink.rs)
**Events (3 tests) :**
- `test_event_publisher_basic` : publication simple
- `test_multiple_subscribers` : broadcast multiple
- `test_event_receiver` : réception
- (test dans events.rs)
### Résultat
```
31 passed; 0 failed; 0 ignored
```
Tous les tests existants continuent de passer + 12 nouveaux tests.
---
## Exemples fournis
### 1. volume_control_demo.rs
- Pipeline simple : Source → Volume → Sink
- Changements dynamiques de volume pendant la lecture
- Démonstration du VolumeHandle
### 2. multiroom_volume_demo.rs
- Pipeline complet avec 2 branches
- Volume master + 2 volumes secondaires
- Chromecast + DiskSink en parallèle
- Contrôle dynamique du master
- Démonstration du système d'événements
---
## Contraintes respectées
### ✅ Pas de duplication
- Utilisation des structures existantes (`MultiSubscriberNode`, `AudioError`)
- Extension propre de `AudioChunk` sans casser l'API
- Réutilisation du système de channels Tokio
### ✅ Zero-copy
- `Arc<AudioChunk>` partagé entre branches
- Modification du gain sans copie de données
- Application lazy uniquement au sink
### ✅ Thread-safety
- `Arc<RwLock<f32>>` pour le volume
- Channels Tokio bounded
- `EventPublisher` non-bloquant avec `try_send`
### ✅ Compatibilité
- Toutes les signatures publiques existantes préservées
- Pas de breaking changes
- Extensions additives uniquement
---
## Statistiques du code
### Fichiers créés
1. `src/events.rs` - 220 lignes
2. `src/nodes/volume_node.rs` - 330 lignes
3. `src/nodes/disk_sink.rs` - 480 lignes
4. `src/nodes/chromecast_sink.rs` - 280 lignes
5. `src/nodes/mpd_sink.rs` - 320 lignes
6. `examples/volume_control_demo.rs` - 55 lignes
7. `examples/multiroom_volume_demo.rs` - 150 lignes
### Fichiers modifiés
1. `src/audio_chunk.rs` - ajout de ~50 lignes
2. `src/lib.rs` - ajout d'exports
3. `src/nodes/mod.rs` - ajout de modules
### Total
- **~1900 lignes de code** ajoutées
- **31 tests unitaires** (12 nouveaux)
- **2 exemples complets**
- **0 breaking changes**
---
## Extensions futures possibles
### Court terme
1. **Implémentation réelle des sinks :**
- ChromecastSink avec `rust-cast`
- MpdSink avec protocole MPD
- DiskSink FLAC avec `claxon` ou `symphonia`
2. **Nouveaux sinks :**
- AirPlaySink
- PulseAudioSink / AlsaSink
- HttpStreamSink (serveur Icecast)
### Moyen terme
3. **Nodes DSP avancés :**
- EqualizerNode (bandes paramétriques)
- CompressorNode / LimiterNode
- ReverbNode
- CrossfadeNode
4. **Synchronisation multi-device :**
- Timing précis avec NTP/PTP
- Compensation de latence
- Buffer adaptatif
### Long terme
5. **Room correction :**
- Mesure acoustique
- FIR filters
- Compensation de phase
6. **Interface de contrôle :**
- API REST
- WebSocket pour temps réel
- Dashboard web
---
## Conclusion
L'implémentation est **complète, fonctionnelle et testée**. Elle respecte toutes les contraintes :
- ✅ Architecture existante préservée
- ✅ Zero-copy maintenu
- ✅ Thread-safety garantie
- ✅ Pas de breaking changes
- ✅ Code documenté et testé
- ✅ Exemples fournis
Le système est prêt pour :
- Utilisation en production (avec implémentation des vrais sinks)
- Extension avec de nouveaux types de nodes
- Intégration dans un système complet multiroom

670
pmoaudio/README.html Normal file
View File

@@ -0,0 +1,670 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en"><head>
<meta charset="utf-8">
<meta name="generator" content="quarto-1.7.34">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>readme</title>
<style>
code{white-space: pre-wrap;}
span.smallcaps{font-variant: small-caps;}
div.columns{display: flex; gap: min(4vw, 1.5em);}
div.column{flex: auto; overflow-x: auto;}
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
ul.task-list{list-style: none;}
ul.task-list li input[type="checkbox"] {
width: 0.8em;
margin: 0 0.8em 0.2em -1em; /* quarto-specific, see https://github.com/quarto-dev/quarto-cli/issues/4556 */
vertical-align: middle;
}
/* CSS for syntax highlighting */
html { -webkit-text-size-adjust: 100%; }
pre > code.sourceCode { white-space: pre; position: relative; }
pre > code.sourceCode > span { display: inline-block; line-height: 1.25; }
pre > code.sourceCode > span:empty { height: 1.2em; }
.sourceCode { overflow: visible; }
code.sourceCode > span { color: inherit; text-decoration: inherit; }
div.sourceCode { margin: 1em 0; }
pre.sourceCode { margin: 0; }
@media screen {
div.sourceCode { overflow: auto; }
}
@media print {
pre > code.sourceCode { white-space: pre-wrap; }
pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; }
}
pre.numberSource code
{ counter-reset: source-line 0; }
pre.numberSource code > span
{ position: relative; left: -4em; counter-increment: source-line; }
pre.numberSource code > span > a:first-child::before
{ content: counter(source-line);
position: relative; left: -1em; text-align: right; vertical-align: baseline;
border: none; display: inline-block;
-webkit-touch-callout: none; -webkit-user-select: none;
-khtml-user-select: none; -moz-user-select: none;
-ms-user-select: none; user-select: none;
padding: 0 4px; width: 4em;
}
pre.numberSource { margin-left: 3em; padding-left: 4px; }
div.sourceCode
{ }
@media screen {
pre > code.sourceCode > span > a:first-child::before { text-decoration: underline; }
}
</style>
<script src="README_files/libs/clipboard/clipboard.min.js"></script>
<script src="README_files/libs/quarto-html/quarto.js" type="module"></script>
<script src="README_files/libs/quarto-html/tabsets/tabsets.js" type="module"></script>
<script src="README_files/libs/quarto-html/popper.min.js"></script>
<script src="README_files/libs/quarto-html/tippy.umd.min.js"></script>
<script src="README_files/libs/quarto-html/anchor.min.js"></script>
<link href="README_files/libs/quarto-html/tippy.css" rel="stylesheet">
<link href="README_files/libs/quarto-html/quarto-syntax-highlighting-c8ad9e5dbd60b7b70b38521ab19b7da4.css" rel="stylesheet" id="quarto-text-highlighting-styles">
<script src="README_files/libs/bootstrap/bootstrap.min.js"></script>
<link href="README_files/libs/bootstrap/bootstrap-icons.css" rel="stylesheet">
<link href="README_files/libs/bootstrap/bootstrap-81267100e462c21b3d6c0d5bf76a3417.min.css" rel="stylesheet" append-hash="true" id="quarto-bootstrap" data-mode="light">
</head>
<body class="fullcontent quarto-light">
<div id="quarto-content" class="page-columns page-rows-contents page-layout-article">
<main class="content" id="quarto-document-content">
<section id="pmoaudio" class="level1">
<h1>PMOAudio</h1>
<p>Pipeline audio stéréo async optimisé pour Rust, utilisant Tokio.</p>
<section id="caractéristiques" class="level2">
<h2 class="anchored" data-anchor-id="caractéristiques">Caractéristiques</h2>
<ul>
<li><strong>Pipeline push-based async</strong> : Tous les nodes utilisent Tokio pour un traitement non-bloquant</li>
<li><strong>Zero-copy optimisé</strong> : Les données audio sont partagées via <code>Arc&lt;Vec&lt;f32&gt;&gt;</code> pour éviter les clonages inutiles</li>
<li><strong>Support multiroom</strong> : BufferNode avec buffer circulaire et offsets indépendants par abonné</li>
<li><strong>TimerNode</strong> : Calcul de position temporelle en temps réel</li>
<li><strong>Backpressure</strong> : Channels bounded avec <code>try_send</code> pour éviter les blocages</li>
</ul>
</section>
<section id="architecture" class="level2">
<h2 class="anchored" data-anchor-id="architecture">Architecture</h2>
<section id="audiochunk" class="level3">
<h3 class="anchored" data-anchor-id="audiochunk">AudioChunk</h3>
<p>Structure de données pour un chunk audio stéréo :</p>
<div class="sourceCode" id="cb1"><pre class="sourceCode rust code-with-copy"><code class="sourceCode rust"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> AudioChunk <span class="op">{</span></span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> order<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span> <span class="co">// Numéro d'ordre</span></span>
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> left<span class="op">:</span> Arc<span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">f32</span><span class="op">&gt;&gt;,</span> <span class="co">// Canal gauche (partagé)</span></span>
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> right<span class="op">:</span> Arc<span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">f32</span><span class="op">&gt;&gt;,</span> <span class="co">// Canal droit (partagé)</span></span>
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> sample_rate<span class="op">:</span> <span class="dt">u32</span><span class="op">,</span> <span class="co">// Taux d'échantillonnage</span></span>
<span id="cb1-6"><a href="#cb1-6" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code><button title="Copy to Clipboard" class="code-copy-button"><i class="bi"></i></button></pre></div>
<p>Les données sont wrappées dans <code>Arc</code> pour permettre le partage sans copie entre plusieurs abonnés.</p>
</section>
<section id="nodes" class="level3">
<h3 class="anchored" data-anchor-id="nodes">Nodes</h3>
<section id="singlesubscribernode" class="level4">
<h4 class="anchored" data-anchor-id="singlesubscribernode">SingleSubscriberNode</h4>
<ul>
<li>Un seul abonné</li>
<li>Pas de clone inutile du Arc</li>
</ul>
</section>
<section id="multisubscribernode" class="level4">
<h4 class="anchored" data-anchor-id="multisubscribernode">MultiSubscriberNode</h4>
<ul>
<li>Plusieurs abonnés</li>
<li>Partage le même <code>Arc&lt;AudioChunk&gt;</code> avec tous</li>
</ul>
</section>
<section id="sourcenode" class="level4">
<h4 class="anchored" data-anchor-id="sourcenode">SourceNode</h4>
<ul>
<li>Génère ou lit des chunks audio</li>
<li>Version mock avec génération de sinusoïdes pour tests</li>
</ul>
</section>
<section id="decodernode" class="level4">
<h4 class="anchored" data-anchor-id="decodernode">DecoderNode</h4>
<ul>
<li>Décode les chunks audio</li>
<li>Supporte le passthrough et le resampling (mock)</li>
</ul>
</section>
<section id="dspnode" class="level4">
<h4 class="anchored" data-anchor-id="dspnode">DspNode</h4>
<ul>
<li>Applique des transformations DSP</li>
<li>Clone les données uniquement si modification nécessaire</li>
<li>Exemple : gain, filtrage</li>
</ul>
</section>
<section id="buffernode" class="level4">
<h4 class="anchored" data-anchor-id="buffernode">BufferNode</h4>
<ul>
<li>Buffer circulaire (<code>VecDeque&lt;Arc&lt;AudioChunk&gt;&gt;</code>)</li>
<li>Support multiroom avec offsets indépendants</li>
<li><code>try_send</code> non-bloquant pour éviter de bloquer la source</li>
</ul>
</section>
<section id="timernode" class="level4">
<h4 class="anchored" data-anchor-id="timernode">TimerNode</h4>
<ul>
<li>Node passthrough qui ne modifie pas les données</li>
<li>Incrémente un compteur de samples</li>
<li>Calcule la position : <code>position_sec = elapsed_samples / sample_rate</code></li>
<li>Fournit un <code>TimerHandle</code> pour monitoring</li>
</ul>
</section>
<section id="sinknode" class="level4">
<h4 class="anchored" data-anchor-id="sinknode">SinkNode</h4>
<ul>
<li>Node terminal qui consomme les chunks</li>
<li>Versions : silent, logging, stats, mock file writer</li>
</ul>
</section>
</section>
</section>
<section id="pipeline-type" class="level2">
<h2 class="anchored" data-anchor-id="pipeline-type">Pipeline type</h2>
<pre><code>SourceNode → DecoderNode → DSPNode → BufferNode → TimerNode → SinkNode(s)
Multiroom Sinks
(avec offsets)</code></pre>
</section>
<section id="exemples" class="level2">
<h2 class="anchored" data-anchor-id="exemples">Exemples</h2>
<section id="pipeline-simple" class="level3">
<h3 class="anchored" data-anchor-id="pipeline-simple">Pipeline simple</h3>
<div class="sourceCode" id="cb3"><pre class="sourceCode rust code-with-copy"><code class="sourceCode rust"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoaudio::</span><span class="op">{</span>SinkNode<span class="op">,</span> SourceNode<span class="op">,</span> TimerNode<span class="op">};</span></span>
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">tokio::</span>main<span class="at">]</span></span>
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> main() <span class="op">{</span></span>
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> (<span class="kw">mut</span> timer<span class="op">,</span> timer_tx) <span class="op">=</span> <span class="pp">TimerNode::</span>new(<span class="dv">10</span>)<span class="op">;</span></span>
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> (sink<span class="op">,</span> sink_tx) <span class="op">=</span> <span class="pp">SinkNode::</span>new(<span class="st">"Output"</span><span class="op">.</span>to_string()<span class="op">,</span> <span class="dv">10</span>)<span class="op">;</span></span>
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a> timer<span class="op">.</span>add_subscriber(sink_tx)<span class="op">;</span></span>
<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> timer_handle <span class="op">=</span> timer<span class="op">.</span>get_position_handle()<span class="op">;</span></span>
<span id="cb3-10"><a href="#cb3-10" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-11"><a href="#cb3-11" aria-hidden="true" tabindex="-1"></a> <span class="pp">tokio::</span>spawn(<span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span> timer<span class="op">.</span>run()<span class="op">.</span><span class="kw">await</span><span class="op">.</span>unwrap() <span class="op">}</span>)<span class="op">;</span></span>
<span id="cb3-12"><a href="#cb3-12" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-13"><a href="#cb3-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> sink_handle <span class="op">=</span> <span class="pp">tokio::</span>spawn(<span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
<span id="cb3-14"><a href="#cb3-14" aria-hidden="true" tabindex="-1"></a> sink<span class="op">.</span>run_with_stats()<span class="op">.</span><span class="kw">await</span><span class="op">.</span>unwrap()</span>
<span id="cb3-15"><a href="#cb3-15" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">;</span></span>
<span id="cb3-16"><a href="#cb3-16" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-17"><a href="#cb3-17" aria-hidden="true" tabindex="-1"></a> <span class="pp">tokio::</span>spawn(<span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
<span id="cb3-18"><a href="#cb3-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> source <span class="op">=</span> <span class="pp">SourceNode::</span>new()<span class="op">;</span></span>
<span id="cb3-19"><a href="#cb3-19" aria-hidden="true" tabindex="-1"></a> source<span class="op">.</span>add_subscriber(timer_tx)<span class="op">;</span></span>
<span id="cb3-20"><a href="#cb3-20" aria-hidden="true" tabindex="-1"></a> source<span class="op">.</span>generate_chunks(<span class="dv">30</span><span class="op">,</span> <span class="dv">4800</span><span class="op">,</span> <span class="dv">48000</span><span class="op">,</span> <span class="dv">440.0</span>)<span class="op">.</span><span class="kw">await</span><span class="op">.</span>unwrap()<span class="op">;</span></span>
<span id="cb3-21"><a href="#cb3-21" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">;</span></span>
<span id="cb3-22"><a href="#cb3-22" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-23"><a href="#cb3-23" aria-hidden="true" tabindex="-1"></a> sink_handle<span class="op">.</span><span class="kw">await</span><span class="op">.</span>unwrap()<span class="op">;</span></span>
<span id="cb3-24"><a href="#cb3-24" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code><button title="Copy to Clipboard" class="code-copy-button"><i class="bi"></i></button></pre></div>
</section>
<section id="multiroom" class="level3">
<h3 class="anchored" data-anchor-id="multiroom">Multiroom</h3>
<div class="sourceCode" id="cb4"><pre class="sourceCode rust code-with-copy"><code class="sourceCode rust"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> (buffer<span class="op">,</span> buffer_tx) <span class="op">=</span> <span class="pp">BufferNode::</span>new(<span class="dv">50</span><span class="op">,</span> <span class="dv">10</span>)<span class="op">;</span></span>
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> (sink1<span class="op">,</span> sink1_tx) <span class="op">=</span> <span class="pp">SinkNode::</span>new(<span class="st">"Room 1"</span><span class="op">.</span>to_string()<span class="op">,</span> <span class="dv">10</span>)<span class="op">;</span></span>
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> (sink2<span class="op">,</span> sink2_tx) <span class="op">=</span> <span class="pp">SinkNode::</span>new(<span class="st">"Room 2"</span><span class="op">.</span>to_string()<span class="op">,</span> <span class="dv">10</span>)<span class="op">;</span></span>
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a>buffer<span class="op">.</span>add_subscriber_with_offset(sink1_tx<span class="op">,</span> <span class="dv">0</span>)<span class="op">.</span><span class="kw">await</span><span class="op">;</span> <span class="co">// Pas de délai</span></span>
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a>buffer<span class="op">.</span>add_subscriber_with_offset(sink2_tx<span class="op">,</span> <span class="dv">5</span>)<span class="op">.</span><span class="kw">await</span><span class="op">;</span> <span class="co">// 5 chunks de retard</span></span></code><button title="Copy to Clipboard" class="code-copy-button"><i class="bi"></i></button></pre></div>
</section>
</section>
<section id="lancer-les-exemples" class="level2">
<h2 class="anchored" data-anchor-id="lancer-les-exemples">Lancer les exemples</h2>
<div class="sourceCode" id="cb5"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="co"># Pipeline simple</span></span>
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="ex">cargo</span> run <span class="at">--example</span> simple_pipeline</span>
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a><span class="co"># Pipeline complet avec tous les nodes</span></span>
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a><span class="ex">cargo</span> run <span class="at">--example</span> pipeline_demo</span>
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a><span class="co"># Configuration multiroom</span></span>
<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a><span class="ex">cargo</span> run <span class="at">--example</span> multiroom_demo</span>
<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a><span class="co"># Streaming avec timing réel</span></span>
<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a><span class="ex">cargo</span> run <span class="at">--example</span> streaming_demo</span></code><button title="Copy to Clipboard" class="code-copy-button"><i class="bi"></i></button></pre></div>
</section>
<section id="tests" class="level2">
<h2 class="anchored" data-anchor-id="tests">Tests</h2>
<div class="sourceCode" id="cb6"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a><span class="ex">cargo</span> test</span></code><button title="Copy to Clipboard" class="code-copy-button"><i class="bi"></i></button></pre></div>
<p>20 tests unitaires couvrant : - Propagation des chunks - Calcul de position par TimerNode - BufferNode multi-abonné avec offsets - Arc sharing et zero-copy - DSP avec gain et filtrage - Resampling</p>
</section>
<section id="optimisations" class="level2">
<h2 class="anchored" data-anchor-id="optimisations">Optimisations</h2>
<ol type="1">
<li><strong>Arc sharing</strong> : Les <code>AudioChunk</code> sont clonés via <code>Arc::clone()</code> qui ne clone que le pointeur</li>
<li><strong>Copy-on-Write</strong> : Les DSP nodes clonent les données uniquement si modification nécessaire</li>
<li><strong>Bounded channels</strong> : Backpressure automatique</li>
<li><strong>try_send</strong> : Non-bloquant pour BufferNode, permet de sauter des chunks si un abonné est saturé</li>
<li><strong>RwLock</strong> : Pour partage concurrent du compteur TimerNode</li>
</ol>
</section>
<section id="dépendances" class="level2">
<h2 class="anchored" data-anchor-id="dépendances">Dépendances</h2>
<ul>
<li><code>tokio</code> : Runtime async et channels</li>
<li><code>async-trait</code> : Traits async</li>
</ul>
</section>
<section id="license" class="level2">
<h2 class="anchored" data-anchor-id="license">License</h2>
<p>CeCill-2.0</p>
</section>
</section>
</main>
<!-- /main column -->
<script id="quarto-html-after-body" type="application/javascript">
window.document.addEventListener("DOMContentLoaded", function (event) {
const icon = "";
const anchorJS = new window.AnchorJS();
anchorJS.options = {
placement: 'right',
icon: icon
};
anchorJS.add('.anchored');
const isCodeAnnotation = (el) => {
for (const clz of el.classList) {
if (clz.startsWith('code-annotation-')) {
return true;
}
}
return false;
}
const onCopySuccess = function(e) {
// button target
const button = e.trigger;
// don't keep focus
button.blur();
// flash "checked"
button.classList.add('code-copy-button-checked');
var currentTitle = button.getAttribute("title");
button.setAttribute("title", "Copied!");
let tooltip;
if (window.bootstrap) {
button.setAttribute("data-bs-toggle", "tooltip");
button.setAttribute("data-bs-placement", "left");
button.setAttribute("data-bs-title", "Copied!");
tooltip = new bootstrap.Tooltip(button,
{ trigger: "manual",
customClass: "code-copy-button-tooltip",
offset: [0, -8]});
tooltip.show();
}
setTimeout(function() {
if (tooltip) {
tooltip.hide();
button.removeAttribute("data-bs-title");
button.removeAttribute("data-bs-toggle");
button.removeAttribute("data-bs-placement");
}
button.setAttribute("title", currentTitle);
button.classList.remove('code-copy-button-checked');
}, 1000);
// clear code selection
e.clearSelection();
}
const getTextToCopy = function(trigger) {
const codeEl = trigger.previousElementSibling.cloneNode(true);
for (const childEl of codeEl.children) {
if (isCodeAnnotation(childEl)) {
childEl.remove();
}
}
return codeEl.innerText;
}
const clipboard = new window.ClipboardJS('.code-copy-button:not([data-in-quarto-modal])', {
text: getTextToCopy
});
clipboard.on('success', onCopySuccess);
if (window.document.getElementById('quarto-embedded-source-code-modal')) {
const clipboardModal = new window.ClipboardJS('.code-copy-button[data-in-quarto-modal]', {
text: getTextToCopy,
container: window.document.getElementById('quarto-embedded-source-code-modal')
});
clipboardModal.on('success', onCopySuccess);
}
var localhostRegex = new RegExp(/^(?:http|https):\/\/localhost\:?[0-9]*\//);
var mailtoRegex = new RegExp(/^mailto:/);
var filterRegex = new RegExp('/' + window.location.host + '/');
var isInternal = (href) => {
return filterRegex.test(href) || localhostRegex.test(href) || mailtoRegex.test(href);
}
// Inspect non-navigation links and adorn them if external
var links = window.document.querySelectorAll('a[href]:not(.nav-link):not(.navbar-brand):not(.toc-action):not(.sidebar-link):not(.sidebar-item-toggle):not(.pagination-link):not(.no-external):not([aria-hidden]):not(.dropdown-item):not(.quarto-navigation-tool):not(.about-link)');
for (var i=0; i<links.length; i++) {
const link = links[i];
if (!isInternal(link.href)) {
// undo the damage that might have been done by quarto-nav.js in the case of
// links that we want to consider external
if (link.dataset.originalHref !== undefined) {
link.href = link.dataset.originalHref;
}
}
}
function tippyHover(el, contentFn, onTriggerFn, onUntriggerFn) {
const config = {
allowHTML: true,
maxWidth: 500,
delay: 100,
arrow: false,
appendTo: function(el) {
return el.parentElement;
},
interactive: true,
interactiveBorder: 10,
theme: 'quarto',
placement: 'bottom-start',
};
if (contentFn) {
config.content = contentFn;
}
if (onTriggerFn) {
config.onTrigger = onTriggerFn;
}
if (onUntriggerFn) {
config.onUntrigger = onUntriggerFn;
}
window.tippy(el, config);
}
const noterefs = window.document.querySelectorAll('a[role="doc-noteref"]');
for (var i=0; i<noterefs.length; i++) {
const ref = noterefs[i];
tippyHover(ref, function() {
// use id or data attribute instead here
let href = ref.getAttribute('data-footnote-href') || ref.getAttribute('href');
try { href = new URL(href).hash; } catch {}
const id = href.replace(/^#\/?/, "");
const note = window.document.getElementById(id);
if (note) {
return note.innerHTML;
} else {
return "";
}
});
}
const xrefs = window.document.querySelectorAll('a.quarto-xref');
const processXRef = (id, note) => {
// Strip column container classes
const stripColumnClz = (el) => {
el.classList.remove("page-full", "page-columns");
if (el.children) {
for (const child of el.children) {
stripColumnClz(child);
}
}
}
stripColumnClz(note)
if (id === null || id.startsWith('sec-')) {
// Special case sections, only their first couple elements
const container = document.createElement("div");
if (note.children && note.children.length > 2) {
container.appendChild(note.children[0].cloneNode(true));
for (let i = 1; i < note.children.length; i++) {
const child = note.children[i];
if (child.tagName === "P" && child.innerText === "") {
continue;
} else {
container.appendChild(child.cloneNode(true));
break;
}
}
if (window.Quarto?.typesetMath) {
window.Quarto.typesetMath(container);
}
return container.innerHTML
} else {
if (window.Quarto?.typesetMath) {
window.Quarto.typesetMath(note);
}
return note.innerHTML;
}
} else {
// Remove any anchor links if they are present
const anchorLink = note.querySelector('a.anchorjs-link');
if (anchorLink) {
anchorLink.remove();
}
if (window.Quarto?.typesetMath) {
window.Quarto.typesetMath(note);
}
if (note.classList.contains("callout")) {
return note.outerHTML;
} else {
return note.innerHTML;
}
}
}
for (var i=0; i<xrefs.length; i++) {
const xref = xrefs[i];
tippyHover(xref, undefined, function(instance) {
instance.disable();
let url = xref.getAttribute('href');
let hash = undefined;
if (url.startsWith('#')) {
hash = url;
} else {
try { hash = new URL(url).hash; } catch {}
}
if (hash) {
const id = hash.replace(/^#\/?/, "");
const note = window.document.getElementById(id);
if (note !== null) {
try {
const html = processXRef(id, note.cloneNode(true));
instance.setContent(html);
} finally {
instance.enable();
instance.show();
}
} else {
// See if we can fetch this
fetch(url.split('#')[0])
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(html, "text/html");
const note = htmlDoc.getElementById(id);
if (note !== null) {
const html = processXRef(id, note);
instance.setContent(html);
}
}).finally(() => {
instance.enable();
instance.show();
});
}
} else {
// See if we can fetch a full url (with no hash to target)
// This is a special case and we should probably do some content thinning / targeting
fetch(url)
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(html, "text/html");
const note = htmlDoc.querySelector('main.content');
if (note !== null) {
// This should only happen for chapter cross references
// (since there is no id in the URL)
// remove the first header
if (note.children.length > 0 && note.children[0].tagName === "HEADER") {
note.children[0].remove();
}
const html = processXRef(null, note);
instance.setContent(html);
}
}).finally(() => {
instance.enable();
instance.show();
});
}
}, function(instance) {
});
}
let selectedAnnoteEl;
const selectorForAnnotation = ( cell, annotation) => {
let cellAttr = 'data-code-cell="' + cell + '"';
let lineAttr = 'data-code-annotation="' + annotation + '"';
const selector = 'span[' + cellAttr + '][' + lineAttr + ']';
return selector;
}
const selectCodeLines = (annoteEl) => {
const doc = window.document;
const targetCell = annoteEl.getAttribute("data-target-cell");
const targetAnnotation = annoteEl.getAttribute("data-target-annotation");
const annoteSpan = window.document.querySelector(selectorForAnnotation(targetCell, targetAnnotation));
const lines = annoteSpan.getAttribute("data-code-lines").split(",");
const lineIds = lines.map((line) => {
return targetCell + "-" + line;
})
let top = null;
let height = null;
let parent = null;
if (lineIds.length > 0) {
//compute the position of the single el (top and bottom and make a div)
const el = window.document.getElementById(lineIds[0]);
top = el.offsetTop;
height = el.offsetHeight;
parent = el.parentElement.parentElement;
if (lineIds.length > 1) {
const lastEl = window.document.getElementById(lineIds[lineIds.length - 1]);
const bottom = lastEl.offsetTop + lastEl.offsetHeight;
height = bottom - top;
}
if (top !== null && height !== null && parent !== null) {
// cook up a div (if necessary) and position it
let div = window.document.getElementById("code-annotation-line-highlight");
if (div === null) {
div = window.document.createElement("div");
div.setAttribute("id", "code-annotation-line-highlight");
div.style.position = 'absolute';
parent.appendChild(div);
}
div.style.top = top - 2 + "px";
div.style.height = height + 4 + "px";
div.style.left = 0;
let gutterDiv = window.document.getElementById("code-annotation-line-highlight-gutter");
if (gutterDiv === null) {
gutterDiv = window.document.createElement("div");
gutterDiv.setAttribute("id", "code-annotation-line-highlight-gutter");
gutterDiv.style.position = 'absolute';
const codeCell = window.document.getElementById(targetCell);
const gutter = codeCell.querySelector('.code-annotation-gutter');
gutter.appendChild(gutterDiv);
}
gutterDiv.style.top = top - 2 + "px";
gutterDiv.style.height = height + 4 + "px";
}
selectedAnnoteEl = annoteEl;
}
};
const unselectCodeLines = () => {
const elementsIds = ["code-annotation-line-highlight", "code-annotation-line-highlight-gutter"];
elementsIds.forEach((elId) => {
const div = window.document.getElementById(elId);
if (div) {
div.remove();
}
});
selectedAnnoteEl = undefined;
};
// Handle positioning of the toggle
window.addEventListener(
"resize",
throttle(() => {
elRect = undefined;
if (selectedAnnoteEl) {
selectCodeLines(selectedAnnoteEl);
}
}, 10)
);
function throttle(fn, ms) {
let throttle = false;
let timer;
return (...args) => {
if(!throttle) { // first call gets through
fn.apply(this, args);
throttle = true;
} else { // all the others get throttled
if(timer) clearTimeout(timer); // cancel #2
timer = setTimeout(() => {
fn.apply(this, args);
timer = throttle = false;
}, ms);
}
};
}
// Attach click handler to the DT
const annoteDls = window.document.querySelectorAll('dt[data-target-cell]');
for (const annoteDlNode of annoteDls) {
annoteDlNode.addEventListener('click', (event) => {
const clickedEl = event.target;
if (clickedEl !== selectedAnnoteEl) {
unselectCodeLines();
const activeEl = window.document.querySelector('dt[data-target-cell].code-annotation-active');
if (activeEl) {
activeEl.classList.remove('code-annotation-active');
}
selectCodeLines(clickedEl);
clickedEl.classList.add('code-annotation-active');
} else {
// Unselect the line
unselectCodeLines();
clickedEl.classList.remove('code-annotation-active');
}
});
}
const findCites = (el) => {
const parentEl = el.parentElement;
if (parentEl) {
const cites = parentEl.dataset.cites;
if (cites) {
return {
el,
cites: cites.split(' ')
};
} else {
return findCites(el.parentElement)
}
} else {
return undefined;
}
};
var bibliorefs = window.document.querySelectorAll('a[role="doc-biblioref"]');
for (var i=0; i<bibliorefs.length; i++) {
const ref = bibliorefs[i];
const citeInfo = findCites(ref);
if (citeInfo) {
tippyHover(citeInfo.el, function() {
var popup = window.document.createElement('div');
citeInfo.cites.forEach(function(cite) {
var citeDiv = window.document.createElement('div');
citeDiv.classList.add('hanging-indent');
citeDiv.classList.add('csl-entry');
var biblioDiv = window.document.getElementById('ref-' + cite);
if (biblioDiv) {
citeDiv.innerHTML = biblioDiv.innerHTML;
}
popup.appendChild(citeDiv);
});
return popup.innerHTML;
});
}
}
});
</script>
</div> <!-- /content -->
</body></html>

165
pmoaudio/README.md Normal file
View File

@@ -0,0 +1,165 @@
# PMOAudio
Pipeline audio stéréo async optimisé pour Rust, utilisant Tokio.
## Caractéristiques
- **Pipeline push-based async** : Tous les nodes utilisent Tokio pour un traitement non-bloquant
- **Zero-copy optimisé** : Les données audio sont partagées via `Arc<Vec<f32>>` pour éviter les clonages inutiles
- **Support multiroom** : BufferNode avec buffer circulaire et offsets indépendants par abonné
- **TimerNode** : Calcul de position temporelle en temps réel
- **Backpressure** : Channels bounded avec `try_send` pour éviter les blocages
## Architecture
### AudioChunk
Structure de données pour un chunk audio stéréo :
```rust
pub struct AudioChunk {
pub order: u64, // Numéro d'ordre
pub left: Arc<Vec<f32>>, // Canal gauche (partagé)
pub right: Arc<Vec<f32>>, // Canal droit (partagé)
pub sample_rate: u32, // Taux d'échantillonnage
}
```
Les données sont wrappées dans `Arc` pour permettre le partage sans copie entre plusieurs abonnés.
### Nodes
#### SingleSubscriberNode
- Un seul abonné
- Pas de clone inutile du Arc
#### MultiSubscriberNode
- Plusieurs abonnés
- Partage le même `Arc<AudioChunk>` avec tous
#### SourceNode
- Génère ou lit des chunks audio
- Version mock avec génération de sinusoïdes pour tests
#### DecoderNode
- Décode les chunks audio
- Supporte le passthrough et le resampling (mock)
#### DspNode
- Applique des transformations DSP
- Clone les données uniquement si modification nécessaire
- Exemple : gain, filtrage
#### BufferNode
- Buffer circulaire (`VecDeque<Arc<AudioChunk>>`)
- Support multiroom avec offsets indépendants
- `try_send` non-bloquant pour éviter de bloquer la source
#### TimerNode
- Node passthrough qui ne modifie pas les données
- Incrémente un compteur de samples
- Calcule la position : `position_sec = elapsed_samples / sample_rate`
- Fournit un `TimerHandle` pour monitoring
#### SinkNode
- Node terminal qui consomme les chunks
- Versions : silent, logging, stats, mock file writer
## Pipeline type
```
SourceNode → DecoderNode → DSPNode → BufferNode → TimerNode → SinkNode(s)
Multiroom Sinks
(avec offsets)
```
## Exemples
### Pipeline simple
```rust
use pmoaudio::{SinkNode, SourceNode, TimerNode};
#[tokio::main]
async fn main() {
let (mut timer, timer_tx) = TimerNode::new(10);
let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
timer.add_subscriber(sink_tx);
let timer_handle = timer.get_position_handle();
tokio::spawn(async move { timer.run().await.unwrap() });
let sink_handle = tokio::spawn(async move {
sink.run_with_stats().await.unwrap()
});
tokio::spawn(async move {
let mut source = SourceNode::new();
source.add_subscriber(timer_tx);
source.generate_chunks(30, 4800, 48000, 440.0).await.unwrap();
});
sink_handle.await.unwrap();
}
```
### Multiroom
```rust
let (buffer, buffer_tx) = BufferNode::new(50, 10);
let (sink1, sink1_tx) = SinkNode::new("Room 1".to_string(), 10);
let (sink2, sink2_tx) = SinkNode::new("Room 2".to_string(), 10);
buffer.add_subscriber_with_offset(sink1_tx, 0).await; // Pas de délai
buffer.add_subscriber_with_offset(sink2_tx, 5).await; // 5 chunks de retard
```
## Lancer les exemples
```bash
# Pipeline simple
cargo run --example simple_pipeline
# Pipeline complet avec tous les nodes
cargo run --example pipeline_demo
# Configuration multiroom
cargo run --example multiroom_demo
# Streaming avec timing réel
cargo run --example streaming_demo
```
## Tests
```bash
cargo test
```
20 tests unitaires couvrant :
- Propagation des chunks
- Calcul de position par TimerNode
- BufferNode multi-abonné avec offsets
- Arc sharing et zero-copy
- DSP avec gain et filtrage
- Resampling
## Optimisations
1. **Arc sharing** : Les `AudioChunk` sont clonés via `Arc::clone()` qui ne clone que le pointeur
2. **Copy-on-Write** : Les DSP nodes clonent les données uniquement si modification nécessaire
3. **Bounded channels** : Backpressure automatique
4. **try_send** : Non-bloquant pour BufferNode, permet de sauter des chunks si un abonné est saturé
5. **RwLock** : Pour partage concurrent du compteur TimerNode
## Dépendances
- `tokio` : Runtime async et channels
- `async-trait` : Traits async
## License
CeCill-2.0

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,236 @@
/* quarto syntax highlight colors */
:root {
--quarto-hl-ot-color: #003B4F;
--quarto-hl-at-color: #657422;
--quarto-hl-ss-color: #20794D;
--quarto-hl-an-color: #5E5E5E;
--quarto-hl-fu-color: #4758AB;
--quarto-hl-st-color: #20794D;
--quarto-hl-cf-color: #003B4F;
--quarto-hl-op-color: #5E5E5E;
--quarto-hl-er-color: #AD0000;
--quarto-hl-bn-color: #AD0000;
--quarto-hl-al-color: #AD0000;
--quarto-hl-va-color: #111111;
--quarto-hl-bu-color: inherit;
--quarto-hl-ex-color: inherit;
--quarto-hl-pp-color: #AD0000;
--quarto-hl-in-color: #5E5E5E;
--quarto-hl-vs-color: #20794D;
--quarto-hl-wa-color: #5E5E5E;
--quarto-hl-do-color: #5E5E5E;
--quarto-hl-im-color: #00769E;
--quarto-hl-ch-color: #20794D;
--quarto-hl-dt-color: #AD0000;
--quarto-hl-fl-color: #AD0000;
--quarto-hl-co-color: #5E5E5E;
--quarto-hl-cv-color: #5E5E5E;
--quarto-hl-cn-color: #8f5902;
--quarto-hl-sc-color: #5E5E5E;
--quarto-hl-dv-color: #AD0000;
--quarto-hl-kw-color: #003B4F;
}
/* other quarto variables */
:root {
--quarto-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
/* syntax highlight based on Pandoc's rules */
pre > code.sourceCode > span {
color: #003B4F;
}
code.sourceCode > span {
color: #003B4F;
}
div.sourceCode,
div.sourceCode pre.sourceCode {
color: #003B4F;
}
/* Normal */
code span {
color: #003B4F;
}
/* Alert */
code span.al {
color: #AD0000;
font-style: inherit;
}
/* Annotation */
code span.an {
color: #5E5E5E;
font-style: inherit;
}
/* Attribute */
code span.at {
color: #657422;
font-style: inherit;
}
/* BaseN */
code span.bn {
color: #AD0000;
font-style: inherit;
}
/* BuiltIn */
code span.bu {
font-style: inherit;
}
/* ControlFlow */
code span.cf {
color: #003B4F;
font-weight: bold;
font-style: inherit;
}
/* Char */
code span.ch {
color: #20794D;
font-style: inherit;
}
/* Constant */
code span.cn {
color: #8f5902;
font-style: inherit;
}
/* Comment */
code span.co {
color: #5E5E5E;
font-style: inherit;
}
/* CommentVar */
code span.cv {
color: #5E5E5E;
font-style: italic;
}
/* Documentation */
code span.do {
color: #5E5E5E;
font-style: italic;
}
/* DataType */
code span.dt {
color: #AD0000;
font-style: inherit;
}
/* DecVal */
code span.dv {
color: #AD0000;
font-style: inherit;
}
/* Error */
code span.er {
color: #AD0000;
font-style: inherit;
}
/* Extension */
code span.ex {
font-style: inherit;
}
/* Float */
code span.fl {
color: #AD0000;
font-style: inherit;
}
/* Function */
code span.fu {
color: #4758AB;
font-style: inherit;
}
/* Import */
code span.im {
color: #00769E;
font-style: inherit;
}
/* Information */
code span.in {
color: #5E5E5E;
font-style: inherit;
}
/* Keyword */
code span.kw {
color: #003B4F;
font-weight: bold;
font-style: inherit;
}
/* Operator */
code span.op {
color: #5E5E5E;
font-style: inherit;
}
/* Other */
code span.ot {
color: #003B4F;
font-style: inherit;
}
/* Preprocessor */
code span.pp {
color: #AD0000;
font-style: inherit;
}
/* SpecialChar */
code span.sc {
color: #5E5E5E;
font-style: inherit;
}
/* SpecialString */
code span.ss {
color: #20794D;
font-style: inherit;
}
/* String */
code span.st {
color: #20794D;
font-style: inherit;
}
/* Variable */
code span.va {
color: #111111;
font-style: inherit;
}
/* VerbatimString */
code span.vs {
color: #20794D;
font-style: inherit;
}
/* Warning */
code span.wa {
color: #5E5E5E;
font-style: italic;
}
.prevent-inlining {
content: "</";
}
/*# sourceMappingURL=405ffa06017179012420079c49e771b5.css.map */

View File

@@ -0,0 +1,845 @@
import * as tabsets from "./tabsets/tabsets.js";
const sectionChanged = new CustomEvent("quarto-sectionChanged", {
detail: {},
bubbles: true,
cancelable: false,
composed: false,
});
const layoutMarginEls = () => {
// Find any conflicting margin elements and add margins to the
// top to prevent overlap
const marginChildren = window.document.querySelectorAll(
".column-margin.column-container > *, .margin-caption, .aside"
);
let lastBottom = 0;
for (const marginChild of marginChildren) {
if (marginChild.offsetParent !== null) {
// clear the top margin so we recompute it
marginChild.style.marginTop = null;
const top = marginChild.getBoundingClientRect().top + window.scrollY;
if (top < lastBottom) {
const marginChildStyle = window.getComputedStyle(marginChild);
const marginBottom = parseFloat(marginChildStyle["marginBottom"]);
const margin = lastBottom - top + marginBottom;
marginChild.style.marginTop = `${margin}px`;
}
const styles = window.getComputedStyle(marginChild);
const marginTop = parseFloat(styles["marginTop"]);
lastBottom = top + marginChild.getBoundingClientRect().height + marginTop;
}
}
};
window.document.addEventListener("DOMContentLoaded", function (_event) {
// Recompute the position of margin elements anytime the body size changes
if (window.ResizeObserver) {
const resizeObserver = new window.ResizeObserver(
throttle(() => {
layoutMarginEls();
if (
window.document.body.getBoundingClientRect().width < 990 &&
isReaderMode()
) {
quartoToggleReader();
}
}, 50)
);
resizeObserver.observe(window.document.body);
}
const tocEl = window.document.querySelector('nav.toc-active[role="doc-toc"]');
const sidebarEl = window.document.getElementById("quarto-sidebar");
const leftTocEl = window.document.getElementById("quarto-sidebar-toc-left");
const marginSidebarEl = window.document.getElementById(
"quarto-margin-sidebar"
);
// function to determine whether the element has a previous sibling that is active
const prevSiblingIsActiveLink = (el) => {
const sibling = el.previousElementSibling;
if (sibling && sibling.tagName === "A") {
return sibling.classList.contains("active");
} else {
return false;
}
};
// dispatch for htmlwidgets
// they use slideenter event to trigger resize
function fireSlideEnter() {
const event = window.document.createEvent("Event");
event.initEvent("slideenter", true, true);
window.document.dispatchEvent(event);
}
const tabs = window.document.querySelectorAll('a[data-bs-toggle="tab"]');
tabs.forEach((tab) => {
tab.addEventListener("shown.bs.tab", fireSlideEnter);
});
// dispatch for shiny
// they use BS shown and hidden events to trigger rendering
function distpatchShinyEvents(previous, current) {
if (window.jQuery) {
if (previous) {
window.jQuery(previous).trigger("hidden");
}
if (current) {
window.jQuery(current).trigger("shown");
}
}
}
// tabby.js listener: Trigger event for htmlwidget and shiny
document.addEventListener(
"tabby",
function (event) {
fireSlideEnter();
distpatchShinyEvents(event.detail.previousTab, event.detail.tab);
},
false
);
// Track scrolling and mark TOC links as active
// get table of contents and sidebar (bail if we don't have at least one)
const tocLinks = tocEl
? [...tocEl.querySelectorAll("a[data-scroll-target]")]
: [];
const makeActive = (link) => tocLinks[link].classList.add("active");
const removeActive = (link) => tocLinks[link].classList.remove("active");
const removeAllActive = () =>
[...Array(tocLinks.length).keys()].forEach((link) => removeActive(link));
// activate the anchor for a section associated with this TOC entry
tocLinks.forEach((link) => {
link.addEventListener("click", () => {
if (link.href.indexOf("#") !== -1) {
const anchor = link.href.split("#")[1];
const heading = window.document.querySelector(
`[data-anchor-id="${anchor}"]`
);
if (heading) {
// Add the class
heading.classList.add("reveal-anchorjs-link");
// function to show the anchor
const handleMouseout = () => {
heading.classList.remove("reveal-anchorjs-link");
heading.removeEventListener("mouseout", handleMouseout);
};
// add a function to clear the anchor when the user mouses out of it
heading.addEventListener("mouseout", handleMouseout);
}
}
});
});
const sections = tocLinks.map((link) => {
const target = link.getAttribute("data-scroll-target");
if (target.startsWith("#")) {
return window.document.getElementById(decodeURI(`${target.slice(1)}`));
} else {
return window.document.querySelector(decodeURI(`${target}`));
}
});
const sectionMargin = 200;
let currentActive = 0;
// track whether we've initialized state the first time
let init = false;
const updateActiveLink = () => {
// The index from bottom to top (e.g. reversed list)
let sectionIndex = -1;
if (
window.innerHeight + window.pageYOffset >=
window.document.body.offsetHeight
) {
// This is the no-scroll case where last section should be the active one
sectionIndex = 0;
} else {
// This finds the last section visible on screen that should be made active
sectionIndex = [...sections].reverse().findIndex((section) => {
if (section) {
return window.pageYOffset >= section.offsetTop - sectionMargin;
} else {
return false;
}
});
}
if (sectionIndex > -1) {
const current = sections.length - sectionIndex - 1;
if (current !== currentActive) {
removeAllActive();
currentActive = current;
makeActive(current);
if (init) {
window.dispatchEvent(sectionChanged);
}
init = true;
}
}
};
const inHiddenRegion = (top, bottom, hiddenRegions) => {
for (const region of hiddenRegions) {
if (top <= region.bottom && bottom >= region.top) {
return true;
}
}
return false;
};
const categorySelector = "header.quarto-title-block .quarto-category";
const activateCategories = (href) => {
// Find any categories
// Surround them with a link pointing back to:
// #category=Authoring
try {
const categoryEls = window.document.querySelectorAll(categorySelector);
for (const categoryEl of categoryEls) {
const categoryText = categoryEl.textContent;
if (categoryText) {
const link = `${href}#category=${encodeURIComponent(categoryText)}`;
const linkEl = window.document.createElement("a");
linkEl.setAttribute("href", link);
for (const child of categoryEl.childNodes) {
linkEl.append(child);
}
categoryEl.appendChild(linkEl);
}
}
} catch {
// Ignore errors
}
};
function hasTitleCategories() {
return window.document.querySelector(categorySelector) !== null;
}
function offsetRelativeUrl(url) {
const offset = getMeta("quarto:offset");
return offset ? offset + url : url;
}
function offsetAbsoluteUrl(url) {
const offset = getMeta("quarto:offset");
const baseUrl = new URL(offset, window.location);
const projRelativeUrl = url.replace(baseUrl, "");
if (projRelativeUrl.startsWith("/")) {
return projRelativeUrl;
} else {
return "/" + projRelativeUrl;
}
}
// read a meta tag value
function getMeta(metaName) {
const metas = window.document.getElementsByTagName("meta");
for (let i = 0; i < metas.length; i++) {
if (metas[i].getAttribute("name") === metaName) {
return metas[i].getAttribute("content");
}
}
return "";
}
async function findAndActivateCategories() {
// Categories search with listing only use path without query
const currentPagePath = offsetAbsoluteUrl(
window.location.origin + window.location.pathname
);
const response = await fetch(offsetRelativeUrl("listings.json"));
if (response.status == 200) {
return response.json().then(function (listingPaths) {
const listingHrefs = [];
for (const listingPath of listingPaths) {
const pathWithoutLeadingSlash = listingPath.listing.substring(1);
for (const item of listingPath.items) {
const encodedItem = encodeURI(item);
if (
encodedItem === currentPagePath ||
encodedItem === currentPagePath + "index.html"
) {
// Resolve this path against the offset to be sure
// we already are using the correct path to the listing
// (this adjusts the listing urls to be rooted against
// whatever root the page is actually running against)
const relative = offsetRelativeUrl(pathWithoutLeadingSlash);
const baseUrl = window.location;
const resolvedPath = new URL(relative, baseUrl);
listingHrefs.push(resolvedPath.pathname);
break;
}
}
}
// Look up the tree for a nearby linting and use that if we find one
const nearestListing = findNearestParentListing(
offsetAbsoluteUrl(window.location.pathname),
listingHrefs
);
if (nearestListing) {
activateCategories(nearestListing);
} else {
// See if the referrer is a listing page for this item
const referredRelativePath = offsetAbsoluteUrl(document.referrer);
const referrerListing = listingHrefs.find((listingHref) => {
const isListingReferrer =
listingHref === referredRelativePath ||
listingHref === referredRelativePath + "index.html";
return isListingReferrer;
});
if (referrerListing) {
// Try to use the referrer if possible
activateCategories(referrerListing);
} else if (listingHrefs.length > 0) {
// Otherwise, just fall back to the first listing
activateCategories(listingHrefs[0]);
}
}
});
}
}
if (hasTitleCategories()) {
findAndActivateCategories();
}
const findNearestParentListing = (href, listingHrefs) => {
if (!href || !listingHrefs) {
return undefined;
}
// Look up the tree for a nearby linting and use that if we find one
const relativeParts = href.substring(1).split("/");
while (relativeParts.length > 0) {
const path = relativeParts.join("/");
for (const listingHref of listingHrefs) {
if (listingHref.startsWith(path)) {
return listingHref;
}
}
relativeParts.pop();
}
return undefined;
};
const manageSidebarVisiblity = (el, placeholderDescriptor) => {
let isVisible = true;
let elRect;
return (hiddenRegions) => {
if (el === null) {
return;
}
// Find the last element of the TOC
const lastChildEl = el.lastElementChild;
if (lastChildEl) {
// Converts the sidebar to a menu
const convertToMenu = () => {
for (const child of el.children) {
child.style.opacity = 0;
child.style.overflow = "hidden";
child.style.pointerEvents = "none";
}
nexttick(() => {
const toggleContainer = window.document.createElement("div");
toggleContainer.style.width = "100%";
toggleContainer.classList.add("zindex-over-content");
toggleContainer.classList.add("quarto-sidebar-toggle");
toggleContainer.classList.add("headroom-target"); // Marks this to be managed by headeroom
toggleContainer.id = placeholderDescriptor.id;
toggleContainer.style.position = "fixed";
const toggleIcon = window.document.createElement("i");
toggleIcon.classList.add("quarto-sidebar-toggle-icon");
toggleIcon.classList.add("bi");
toggleIcon.classList.add("bi-caret-down-fill");
const toggleTitle = window.document.createElement("div");
const titleEl = window.document.body.querySelector(
placeholderDescriptor.titleSelector
);
if (titleEl) {
toggleTitle.append(
titleEl.textContent || titleEl.innerText,
toggleIcon
);
}
toggleTitle.classList.add("zindex-over-content");
toggleTitle.classList.add("quarto-sidebar-toggle-title");
toggleContainer.append(toggleTitle);
const toggleContents = window.document.createElement("div");
toggleContents.classList = el.classList;
toggleContents.classList.add("zindex-over-content");
toggleContents.classList.add("quarto-sidebar-toggle-contents");
for (const child of el.children) {
if (child.id === "toc-title") {
continue;
}
const clone = child.cloneNode(true);
clone.style.opacity = 1;
clone.style.pointerEvents = null;
clone.style.display = null;
toggleContents.append(clone);
}
toggleContents.style.height = "0px";
const positionToggle = () => {
// position the element (top left of parent, same width as parent)
if (!elRect) {
elRect = el.getBoundingClientRect();
}
toggleContainer.style.left = `${elRect.left}px`;
toggleContainer.style.top = `${elRect.top}px`;
toggleContainer.style.width = `${elRect.width}px`;
};
positionToggle();
toggleContainer.append(toggleContents);
el.parentElement.prepend(toggleContainer);
// Process clicks
let tocShowing = false;
// Allow the caller to control whether this is dismissed
// when it is clicked (e.g. sidebar navigation supports
// opening and closing the nav tree, so don't dismiss on click)
const clickEl = placeholderDescriptor.dismissOnClick
? toggleContainer
: toggleTitle;
const closeToggle = () => {
if (tocShowing) {
toggleContainer.classList.remove("expanded");
toggleContents.style.height = "0px";
tocShowing = false;
}
};
// Get rid of any expanded toggle if the user scrolls
window.document.addEventListener(
"scroll",
throttle(() => {
closeToggle();
}, 50)
);
// Handle positioning of the toggle
window.addEventListener(
"resize",
throttle(() => {
elRect = undefined;
positionToggle();
}, 50)
);
window.addEventListener("quarto-hrChanged", () => {
elRect = undefined;
});
// Process the click
clickEl.onclick = () => {
if (!tocShowing) {
toggleContainer.classList.add("expanded");
toggleContents.style.height = null;
tocShowing = true;
} else {
closeToggle();
}
};
});
};
// Converts a sidebar from a menu back to a sidebar
const convertToSidebar = () => {
for (const child of el.children) {
child.style.opacity = 1;
child.style.overflow = null;
child.style.pointerEvents = null;
}
const placeholderEl = window.document.getElementById(
placeholderDescriptor.id
);
if (placeholderEl) {
placeholderEl.remove();
}
el.classList.remove("rollup");
};
if (isReaderMode()) {
convertToMenu();
isVisible = false;
} else {
// Find the top and bottom o the element that is being managed
const elTop = el.offsetTop;
const elBottom =
elTop + lastChildEl.offsetTop + lastChildEl.offsetHeight;
if (!isVisible) {
// If the element is current not visible reveal if there are
// no conflicts with overlay regions
if (!inHiddenRegion(elTop, elBottom, hiddenRegions)) {
convertToSidebar();
isVisible = true;
}
} else {
// If the element is visible, hide it if it conflicts with overlay regions
// and insert a placeholder toggle (or if we're in reader mode)
if (inHiddenRegion(elTop, elBottom, hiddenRegions)) {
convertToMenu();
isVisible = false;
}
}
}
}
};
};
const tabEls = document.querySelectorAll('a[data-bs-toggle="tab"]');
for (const tabEl of tabEls) {
const id = tabEl.getAttribute("data-bs-target");
if (id) {
const columnEl = document.querySelector(
`${id} .column-margin, .tabset-margin-content`
);
if (columnEl)
tabEl.addEventListener("shown.bs.tab", function (event) {
const el = event.srcElement;
if (el) {
const visibleCls = `${el.id}-margin-content`;
// walk up until we find a parent tabset
let panelTabsetEl = el.parentElement;
while (panelTabsetEl) {
if (panelTabsetEl.classList.contains("panel-tabset")) {
break;
}
panelTabsetEl = panelTabsetEl.parentElement;
}
if (panelTabsetEl) {
const prevSib = panelTabsetEl.previousElementSibling;
if (
prevSib &&
prevSib.classList.contains("tabset-margin-container")
) {
const childNodes = prevSib.querySelectorAll(
".tabset-margin-content"
);
for (const childEl of childNodes) {
if (childEl.classList.contains(visibleCls)) {
childEl.classList.remove("collapse");
} else {
childEl.classList.add("collapse");
}
}
}
}
}
layoutMarginEls();
});
}
}
// Manage the visibility of the toc and the sidebar
const marginScrollVisibility = manageSidebarVisiblity(marginSidebarEl, {
id: "quarto-toc-toggle",
titleSelector: "#toc-title",
dismissOnClick: true,
});
const sidebarScrollVisiblity = manageSidebarVisiblity(sidebarEl, {
id: "quarto-sidebarnav-toggle",
titleSelector: ".title",
dismissOnClick: false,
});
let tocLeftScrollVisibility;
if (leftTocEl) {
tocLeftScrollVisibility = manageSidebarVisiblity(leftTocEl, {
id: "quarto-lefttoc-toggle",
titleSelector: "#toc-title",
dismissOnClick: true,
});
}
// Find the first element that uses formatting in special columns
const conflictingEls = window.document.body.querySelectorAll(
'[class^="column-"], [class*=" column-"], aside, [class*="margin-caption"], [class*=" margin-caption"], [class*="margin-ref"], [class*=" margin-ref"]'
);
// Filter all the possibly conflicting elements into ones
// the do conflict on the left or ride side
const arrConflictingEls = Array.from(conflictingEls);
const leftSideConflictEls = arrConflictingEls.filter((el) => {
if (el.tagName === "ASIDE") {
return false;
}
return Array.from(el.classList).find((className) => {
return (
className !== "column-body" &&
className.startsWith("column-") &&
!className.endsWith("right") &&
!className.endsWith("container") &&
className !== "column-margin"
);
});
});
const rightSideConflictEls = arrConflictingEls.filter((el) => {
if (el.tagName === "ASIDE") {
return true;
}
const hasMarginCaption = Array.from(el.classList).find((className) => {
return className == "margin-caption";
});
if (hasMarginCaption) {
return true;
}
return Array.from(el.classList).find((className) => {
return (
className !== "column-body" &&
!className.endsWith("container") &&
className.startsWith("column-") &&
!className.endsWith("left")
);
});
});
const kOverlapPaddingSize = 10;
function toRegions(els) {
return els.map((el) => {
const boundRect = el.getBoundingClientRect();
const top =
boundRect.top +
document.documentElement.scrollTop -
kOverlapPaddingSize;
return {
top,
bottom: top + el.scrollHeight + 2 * kOverlapPaddingSize,
};
});
}
let hasObserved = false;
const visibleItemObserver = (els) => {
let visibleElements = [...els];
const intersectionObserver = new IntersectionObserver(
(entries, _observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
if (visibleElements.indexOf(entry.target) === -1) {
visibleElements.push(entry.target);
}
} else {
visibleElements = visibleElements.filter((visibleEntry) => {
return visibleEntry !== entry;
});
}
});
if (!hasObserved) {
hideOverlappedSidebars();
}
hasObserved = true;
},
{}
);
els.forEach((el) => {
intersectionObserver.observe(el);
});
return {
getVisibleEntries: () => {
return visibleElements;
},
};
};
const rightElementObserver = visibleItemObserver(rightSideConflictEls);
const leftElementObserver = visibleItemObserver(leftSideConflictEls);
const hideOverlappedSidebars = () => {
marginScrollVisibility(toRegions(rightElementObserver.getVisibleEntries()));
sidebarScrollVisiblity(toRegions(leftElementObserver.getVisibleEntries()));
if (tocLeftScrollVisibility) {
tocLeftScrollVisibility(
toRegions(leftElementObserver.getVisibleEntries())
);
}
};
window.quartoToggleReader = () => {
// Applies a slow class (or removes it)
// to update the transition speed
const slowTransition = (slow) => {
const manageTransition = (id, slow) => {
const el = document.getElementById(id);
if (el) {
if (slow) {
el.classList.add("slow");
} else {
el.classList.remove("slow");
}
}
};
manageTransition("TOC", slow);
manageTransition("quarto-sidebar", slow);
};
const readerMode = !isReaderMode();
setReaderModeValue(readerMode);
// If we're entering reader mode, slow the transition
if (readerMode) {
slowTransition(readerMode);
}
highlightReaderToggle(readerMode);
hideOverlappedSidebars();
// If we're exiting reader mode, restore the non-slow transition
if (!readerMode) {
slowTransition(!readerMode);
}
};
const highlightReaderToggle = (readerMode) => {
const els = document.querySelectorAll(".quarto-reader-toggle");
if (els) {
els.forEach((el) => {
if (readerMode) {
el.classList.add("reader");
} else {
el.classList.remove("reader");
}
});
}
};
const setReaderModeValue = (val) => {
if (window.location.protocol !== "file:") {
window.localStorage.setItem("quarto-reader-mode", val);
} else {
localReaderMode = val;
}
};
const isReaderMode = () => {
if (window.location.protocol !== "file:") {
return window.localStorage.getItem("quarto-reader-mode") === "true";
} else {
return localReaderMode;
}
};
let localReaderMode = null;
const tocOpenDepthStr = tocEl?.getAttribute("data-toc-expanded");
const tocOpenDepth = tocOpenDepthStr ? Number(tocOpenDepthStr) : 1;
// Walk the TOC and collapse/expand nodes
// Nodes are expanded if:
// - they are top level
// - they have children that are 'active' links
// - they are directly below an link that is 'active'
const walk = (el, depth) => {
// Tick depth when we enter a UL
if (el.tagName === "UL") {
depth = depth + 1;
}
// It this is active link
let isActiveNode = false;
if (el.tagName === "A" && el.classList.contains("active")) {
isActiveNode = true;
}
// See if there is an active child to this element
let hasActiveChild = false;
for (const child of el.children) {
hasActiveChild = walk(child, depth) || hasActiveChild;
}
// Process the collapse state if this is an UL
if (el.tagName === "UL") {
if (tocOpenDepth === -1 && depth > 1) {
// toc-expand: false
el.classList.add("collapse");
} else if (
depth <= tocOpenDepth ||
hasActiveChild ||
prevSiblingIsActiveLink(el)
) {
el.classList.remove("collapse");
} else {
el.classList.add("collapse");
}
// untick depth when we leave a UL
depth = depth - 1;
}
return hasActiveChild || isActiveNode;
};
// walk the TOC and expand / collapse any items that should be shown
if (tocEl) {
updateActiveLink();
walk(tocEl, 0);
}
// Throttle the scroll event and walk peridiocally
window.document.addEventListener(
"scroll",
throttle(() => {
if (tocEl) {
updateActiveLink();
walk(tocEl, 0);
}
if (!isReaderMode()) {
hideOverlappedSidebars();
}
}, 5)
);
window.addEventListener(
"resize",
throttle(() => {
if (tocEl) {
updateActiveLink();
walk(tocEl, 0);
}
if (!isReaderMode()) {
hideOverlappedSidebars();
}
}, 10)
);
hideOverlappedSidebars();
highlightReaderToggle(isReaderMode());
});
tabsets.init();
function throttle(func, wait) {
let waiting = false;
return function () {
if (!waiting) {
func.apply(this, arguments);
waiting = true;
setTimeout(function () {
waiting = false;
}, wait);
}
};
}
function nexttick(func) {
return setTimeout(func, 0);
}

View File

@@ -0,0 +1,95 @@
// grouped tabsets
export function init() {
window.addEventListener("pageshow", (_event) => {
function getTabSettings() {
const data = localStorage.getItem("quarto-persistent-tabsets-data");
if (!data) {
localStorage.setItem("quarto-persistent-tabsets-data", "{}");
return {};
}
if (data) {
return JSON.parse(data);
}
}
function setTabSettings(data) {
localStorage.setItem(
"quarto-persistent-tabsets-data",
JSON.stringify(data)
);
}
function setTabState(groupName, groupValue) {
const data = getTabSettings();
data[groupName] = groupValue;
setTabSettings(data);
}
function toggleTab(tab, active) {
const tabPanelId = tab.getAttribute("aria-controls");
const tabPanel = document.getElementById(tabPanelId);
if (active) {
tab.classList.add("active");
tabPanel.classList.add("active");
} else {
tab.classList.remove("active");
tabPanel.classList.remove("active");
}
}
function toggleAll(selectedGroup, selectorsToSync) {
for (const [thisGroup, tabs] of Object.entries(selectorsToSync)) {
const active = selectedGroup === thisGroup;
for (const tab of tabs) {
toggleTab(tab, active);
}
}
}
function findSelectorsToSyncByLanguage() {
const result = {};
const tabs = Array.from(
document.querySelectorAll(`div[data-group] a[id^='tabset-']`)
);
for (const item of tabs) {
const div = item.parentElement.parentElement.parentElement;
const group = div.getAttribute("data-group");
if (!result[group]) {
result[group] = {};
}
const selectorsToSync = result[group];
const value = item.innerHTML;
if (!selectorsToSync[value]) {
selectorsToSync[value] = [];
}
selectorsToSync[value].push(item);
}
return result;
}
function setupSelectorSync() {
const selectorsToSync = findSelectorsToSyncByLanguage();
Object.entries(selectorsToSync).forEach(([group, tabSetsByValue]) => {
Object.entries(tabSetsByValue).forEach(([value, items]) => {
items.forEach((item) => {
item.addEventListener("click", (_event) => {
setTabState(group, value);
toggleAll(value, selectorsToSync[group]);
});
});
});
});
return selectorsToSync;
}
const selectorsToSync = setupSelectorSync();
for (const [group, selectedName] of Object.entries(getTabSettings())) {
const selectors = selectorsToSync[group];
// it's possible that stale state gives us empty selections, so we explicitly check here.
if (selectors) {
toggleAll(selectedName, selectors);
}
}
});
}

View File

@@ -0,0 +1 @@
.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,80 @@
//! Exemple de configuration multiroom avec BufferNode
//!
//! Démontre l'utilisation du buffer circulaire pour synchroniser
//! plusieurs sorties avec des délais différents
use pmoaudio::{BufferNode, SinkNode, SourceNode};
#[tokio::main]
async fn main() {
println!("=== Multiroom Demo ===\n");
// Buffer avec capacité pour gérer les délais
let (buffer, buffer_tx) = BufferNode::new(50, 10);
// Créer 3 sorties avec délais différents
let (sink1, sink1_tx) = SinkNode::new("Room 1 (no delay)".to_string(), 10);
let (sink2, sink2_tx) = SinkNode::new("Room 2 (5 chunks delay)".to_string(), 10);
let (sink3, sink3_tx) = SinkNode::new("Room 3 (10 chunks delay)".to_string(), 10);
buffer.add_subscriber_with_offset(sink1_tx, 0).await;
buffer.add_subscriber_with_offset(sink2_tx, 5).await;
buffer.add_subscriber_with_offset(sink3_tx, 10).await;
// Spawn buffer et sinks
tokio::spawn(async move {
buffer.run().await.unwrap();
});
let sink1_handle = tokio::spawn(async move {
let stats = sink1.run_with_stats().await.unwrap();
stats.display();
stats
});
let sink2_handle = tokio::spawn(async move {
let stats = sink2.run_with_stats().await.unwrap();
stats.display();
stats
});
let sink3_handle = tokio::spawn(async move {
let stats = sink3.run_with_stats().await.unwrap();
stats.display();
stats
});
// Générer de l'audio dans une tâche séparée
println!("Generating audio for multiroom playback...\n");
tokio::spawn(async move {
let mut source = SourceNode::new();
source.add_subscriber(buffer_tx);
source
.generate_chunks(30, 4800, 48000, 440.0)
.await
.unwrap();
});
println!("Waiting for all rooms to finish...\n");
// Attendre toutes les sorties
let stats1 = sink1_handle.await.unwrap();
let stats2 = sink2_handle.await.unwrap();
let stats3 = sink3_handle.await.unwrap();
println!("\n=== Multiroom Summary ===");
println!(
"{}: {} chunks received",
stats1.name, stats1.chunks_received
);
println!(
"{}: {} chunks received",
stats2.name, stats2.chunks_received
);
println!(
"{}: {} chunks received",
stats3.name, stats3.chunks_received
);
println!("\nNote: Delayed rooms receive fewer chunks due to the offset");
}

View File

@@ -0,0 +1,168 @@
//! Exemple complet de pipeline multiroom avec contrôle de volume
//!
//! Ce programme démontre :
//! - Une source audio unique
//! - Deux branches de sortie : Chromecast et DiskSink
//! - Un volume master avec deux VolumeNodes secondaires synchronisés
//! - Système d'événements pour la communication entre nodes
use pmoaudio::{
ChromecastConfig, ChromecastSink, DiskSink, DiskSinkConfig, SourceNode, VolumeNode,
};
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== PMOAudio Multiroom Volume Demo ===\n");
// Configuration
let sample_rate = 48000u32;
let chunk_size = 4800usize; // 100ms à 48kHz
let num_chunks = 50; // 5 secondes de lecture
let frequency = 440.0; // La 440 Hz
// ===== 1. Créer la source audio =====
println!("1. Creating audio source...");
let mut source = SourceNode::new();
// ===== 2. Créer le volume master =====
println!("2. Creating master volume node...");
let (mut master_volume, master_tx) = VolumeNode::new("master".to_string(), 1.0, 50);
let master_handle = master_volume.get_handle();
// Channel pour les événements du volume master
let (master_event_tx, master_event_rx_chromecast) = mpsc::channel(10);
let (_, master_event_rx_disk) = mpsc::channel(10);
master_volume.subscribe_volume_events(master_event_tx);
source.add_subscriber(master_tx);
// ===== 3. Créer les branches de sortie =====
// Branche 1: Chromecast avec volume secondaire
println!("3a. Creating Chromecast output branch...");
let (mut chromecast_volume, chromecast_volume_tx) =
VolumeNode::new("chromecast_volume".to_string(), 0.8, 50);
chromecast_volume.set_master_volume_source(master_event_rx_chromecast);
let chromecast_config = ChromecastConfig {
device_address: "192.168.1.100".to_string(),
device_name: "Living Room".to_string(),
..Default::default()
};
let (chromecast_sink, chromecast_sink_tx) =
ChromecastSink::new("chromecast1".to_string(), chromecast_config, 50);
chromecast_volume.add_subscriber(chromecast_sink_tx);
master_volume.add_subscriber(chromecast_volume_tx);
// Branche 2: DiskSink avec volume secondaire
println!("3b. Creating DiskSink output branch...");
let (mut disk_volume, disk_volume_tx) = VolumeNode::new("disk_volume".to_string(), 0.9, 50);
disk_volume.set_master_volume_source(master_event_rx_disk);
let disk_config = DiskSinkConfig {
output_dir: std::env::temp_dir().join("pmoaudio_demo"),
filename: Some("multiroom_output.wav".to_string()),
..Default::default()
};
let (disk_sink, disk_sink_tx) = DiskSink::new("disk1".to_string(), disk_config, 50);
disk_volume.add_subscriber(disk_sink_tx);
master_volume.add_subscriber(disk_volume_tx);
// ===== 4. Lancer tous les nodes =====
println!("4. Starting pipeline nodes...\n");
// Spawn master volume
let master_volume_handle = tokio::spawn(async move {
master_volume.run().await.unwrap();
});
// Spawn chromecast branch
let chromecast_volume_handle = tokio::spawn(async move {
chromecast_volume.run().await.unwrap();
});
let chromecast_sink_handle = tokio::spawn(async move {
let stats = chromecast_sink.run().await.unwrap();
stats.display();
});
// Spawn disk branch
let disk_volume_handle = tokio::spawn(async move {
disk_volume.run().await.unwrap();
});
let disk_sink_handle = tokio::spawn(async move {
let stats = disk_sink.run().await.unwrap();
stats.display();
});
// ===== 5. Contrôler le volume pendant la lecture =====
let master_handle_clone = master_handle.clone();
tokio::spawn(async move {
// Attendre un peu, puis diminuer le volume
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!("\n>>> Decreasing master volume to 0.7");
master_handle_clone.set_volume(0.7).await;
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!(">>> Decreasing master volume to 0.4");
master_handle_clone.set_volume(0.4).await;
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!(">>> Increasing master volume back to 1.0");
master_handle_clone.set_volume(1.0).await;
});
// ===== 6. Générer et envoyer les chunks audio =====
println!("5. Generating and streaming audio...");
tokio::spawn(async move {
source
.generate_chunks(num_chunks, chunk_size, sample_rate, frequency)
.await
.unwrap();
println!("\n>>> Audio generation complete!");
});
// ===== 7. Attendre la fin de tous les nodes =====
println!("6. Waiting for all nodes to complete...\n");
// Attendre que les sinks terminent
chromecast_sink_handle.await?;
disk_sink_handle.await?;
// Nettoyer
master_volume_handle.abort();
chromecast_volume_handle.abort();
disk_volume_handle.abort();
println!("\n=== Demo completed successfully! ===");
println!("\nSummary:");
println!(
"- Generated {} chunks of {} samples each",
num_chunks, chunk_size
);
println!(
"- Total duration: {:.2} seconds",
(num_chunks as usize * chunk_size) as f32 / sample_rate as f32
);
println!("- Output to Chromecast: Living Room (192.168.1.100)");
println!(
"- Output to file: {}",
std::env::temp_dir()
.join("pmoaudio_demo")
.join("multiroom_output.wav")
.display()
);
println!("- Master volume control demonstrated with live changes");
println!("\nAll streams received synchronized volume updates!");
Ok(())
}

View File

@@ -0,0 +1,126 @@
//! Exemple de pipeline audio stéréo complet avec tous les nodes
//!
//! Pipeline: SourceNode → DecoderNode → DspNode → BufferNode → TimerNode → SinkNode(s)
use pmoaudio::{BufferNode, DecoderNode, DspNode, SinkNode, SourceNode, TimerNode};
#[tokio::main]
async fn main() {
println!("=== PMOAudio Pipeline Demo ===\n");
// Créer le pipeline de nodes
// 2. DecoderNode - passthrough dans cet exemple
let (mut decoder, decoder_tx) = DecoderNode::new(10);
// 3. DspNode - applique un gain de 0.5
let (mut dsp, dsp_tx) = DspNode::new(10, 0.5);
// 4. BufferNode - buffer circulaire pour multiroom
let (mut buffer, buffer_tx) = BufferNode::new(100, 10);
// 5. TimerNode - calcule la position temporelle
let (mut timer, timer_tx) = TimerNode::new(10);
// 6. SinkNodes - deux destinations finales
let (sink1, sink1_tx) = SinkNode::new("Main Output".to_string(), 10);
let (sink2, sink2_tx) = SinkNode::new("Secondary Output".to_string(), 10);
// Ajouter un abonné au BufferNode avec offset (multiroom simulation)
let (sink3, sink3_tx) = SinkNode::new("Delayed Output".to_string(), 10);
buffer.add_subscriber_with_offset(sink3_tx, 5).await; // 5 chunks de retard
// Connecter le pipeline
decoder.add_subscriber(dsp_tx);
dsp.add_subscriber(buffer_tx);
buffer.add_next_subscriber(timer_tx); // BufferNode -> TimerNode
timer.add_subscriber(sink1_tx);
timer.add_subscriber(sink2_tx);
// Obtenir un handle pour lire la position du TimerNode
let timer_handle = timer.get_position_handle();
// Spawn tous les nodes
let decoder_handle = tokio::spawn(async move {
decoder.run_passthrough().await.unwrap();
});
let dsp_handle = tokio::spawn(async move {
dsp.run().await.unwrap();
});
let buffer_handle = tokio::spawn(async move {
buffer.run().await.unwrap();
});
let timer_handle_task = tokio::spawn(async move {
timer.run().await.unwrap();
});
let sink1_handle = tokio::spawn(async move {
let stats = sink1.run_with_stats().await.unwrap();
stats.display();
stats
});
let sink2_handle = tokio::spawn(async move {
sink2.run_silent().await.unwrap();
});
let sink3_handle = tokio::spawn(async move {
let stats = sink3.run_with_stats().await.unwrap();
stats.display();
stats
});
// Spawn une tâche pour afficher la position périodiquement
let position_monitor = tokio::spawn(async move {
for _ in 0..10 {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let position = timer_handle.position_sec().await;
let samples = timer_handle.elapsed_samples().await;
println!("Position: {:.3} sec ({} samples)", position, samples);
}
});
// Générer des chunks audio
println!("Generating audio chunks...\n");
let chunk_size = 4800; // 100ms à 48kHz
let sample_rate = 48000;
let frequency = 440.0; // La 440Hz
// Source node dans une tâche séparée
tokio::spawn(async move {
let mut source = SourceNode::new();
source.add_subscriber(decoder_tx);
// Générer 50 chunks (environ 5 secondes)
source
.generate_chunks(50, chunk_size, sample_rate, frequency)
.await
.unwrap();
println!("\nChunks sent. Processing...\n");
});
// Attendre que tous les nodes terminent
decoder_handle.await.unwrap();
dsp_handle.await.unwrap();
buffer_handle.await.unwrap();
timer_handle_task.await.unwrap();
let stats1 = sink1_handle.await.unwrap();
sink2_handle.await.unwrap();
let stats3 = sink3_handle.await.unwrap();
position_monitor.await.unwrap();
println!("\n=== Pipeline Demo Complete ===");
println!(
"Main output processed: {} chunks, {:.3} sec",
stats1.chunks_received, stats1.total_duration_sec
);
println!(
"Delayed output processed: {} chunks, {:.3} sec",
stats3.chunks_received, stats3.total_duration_sec
);
}

View File

@@ -0,0 +1,96 @@
//! Quick Start - Démonstration rapide des nouvelles fonctionnalités
//!
//! Cet exemple montre l'utilisation des principales nouvelles fonctionnalités :
//! - VolumeNode avec contrôle dynamique
//! - DiskSink pour écriture sur disque
//! - Pipeline simple et efficace
use pmoaudio::{AudioFileFormat, DiskSink, DiskSinkConfig, SourceNode, VolumeNode};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== PMOAudio Quick Start ===\n");
// 1. Créer la source audio (génère un signal de test)
let mut source = SourceNode::new();
// 2. Créer un VolumeNode pour contrôler le volume
let (mut volume, volume_tx) = VolumeNode::new("main".to_string(), 0.8, 10);
let volume_handle = volume.get_handle();
// 3. Créer un DiskSink pour écrire sur disque
let output_dir = std::env::temp_dir().join("pmoaudio_quickstart");
let config = DiskSinkConfig {
output_dir: output_dir.clone(),
filename: Some("quickstart_output.wav".to_string()),
format: AudioFileFormat::Wav,
buffer_size: 50,
};
let (disk_sink, disk_tx) = DiskSink::new("disk".to_string(), config, 10);
// 4. Connecter le pipeline : Source → Volume → DiskSink
source.add_subscriber(volume_tx);
volume.add_subscriber(disk_tx);
println!("Pipeline configured:");
println!(" SourceNode → VolumeNode (vol=0.8) → DiskSink");
println!(" Output: {}/quickstart_output.wav\n", output_dir.display());
// 5. Lancer les nodes
let volume_handle_clone = volume_handle.clone();
tokio::spawn(async move {
volume.run().await.unwrap();
});
let disk_handle = tokio::spawn(async move {
let stats = disk_sink.run().await.unwrap();
println!("\nDiskSink Statistics:");
stats.display();
stats
});
// 6. Démonstration du contrôle de volume pendant la lecture
tokio::spawn(async move {
println!("Generating audio with volume changes...");
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
println!(" → Volume: 0.8 (initial)");
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
volume_handle_clone.set_volume(0.5).await;
println!(" → Volume: 0.5 (decreased)");
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
volume_handle_clone.set_volume(1.0).await;
println!(" → Volume: 1.0 (maximum)");
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
volume_handle_clone.set_volume(0.3).await;
println!(" → Volume: 0.3 (low)");
});
// 7. Générer l'audio (10 chunks de 4800 samples à 48kHz = ~1 seconde)
source
.generate_chunks(
10, // nombre de chunks
4800, // samples par chunk (100ms @ 48kHz)
48000, // sample rate
440.0, // fréquence (La 440 Hz)
)
.await?;
// 8. Attendre la fin du traitement
let stats = disk_handle.await?;
// 9. Résumé
println!("\n=== Summary ===");
println!("✓ Audio file generated successfully");
println!("{} chunks written", stats.chunks_written);
println!("✓ Duration: {:.2} seconds", stats.total_duration_sec);
println!("✓ Volume was dynamically adjusted during playback");
println!("\nYou can play the file with:");
println!(" ffplay {}/quickstart_output.wav", output_dir.display());
Ok(())
}

View File

@@ -0,0 +1,53 @@
//! Exemple simple de pipeline audio : Source → Timer → Sink
//!
//! Démontre l'utilisation basique du pipeline avec calcul de position
use pmoaudio::{SinkNode, SourceNode, TimerNode};
#[tokio::main]
async fn main() {
println!("=== Simple Pipeline Example ===\n");
// Créer les nodes
let (mut timer, timer_tx) = TimerNode::new(10);
let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
// Connecter
timer.add_subscriber(sink_tx);
// Handle pour monitorer la position
let timer_handle = timer.get_position_handle();
// Spawn timer et sink
tokio::spawn(async move {
timer.run().await.unwrap();
});
let sink_handle = tokio::spawn(async move {
let stats = sink.run_with_stats().await.unwrap();
stats.display();
stats
});
// Générer quelques secondes d'audio dans une tâche séparée
println!("Generating 440Hz sine wave...\n");
tokio::spawn(async move {
let mut source = SourceNode::new();
source.add_subscriber(timer_tx);
source
.generate_chunks(30, 4800, 48000, 440.0) // ~3 secondes
.await
.unwrap();
// La source est drop ici, fermant le channel
});
// Attendre la fin
let stats = sink_handle.await.unwrap();
let final_position = timer_handle.position_sec().await;
println!("\nFinal position: {:.3} seconds", final_position);
println!("Total duration: {:.3} seconds", stats.total_duration_sec);
}

View File

@@ -0,0 +1,52 @@
//! Exemple de streaming audio en temps réel
//!
//! Démontre l'utilisation du pipeline avec génération de chunks
//! en temps réel avec timing approprié
use pmoaudio::{SinkNode, SourceNode, TimerNode};
#[tokio::main]
async fn main() {
println!("=== Streaming Demo ===\n");
println!("Streaming audio in real-time for 3 seconds...\n");
let mut source = SourceNode::new();
let (mut timer, timer_tx) = TimerNode::new(20);
let (sink, sink_tx) = SinkNode::new("Streaming Output".to_string(), 20);
source.add_subscriber(timer_tx);
timer.add_subscriber(sink_tx);
let timer_handle = timer.get_position_handle();
// Spawn le pipeline
tokio::spawn(async move {
timer.run().await.unwrap();
});
let sink_handle = tokio::spawn(async move {
sink.run_with_logging().await.unwrap();
});
// Monitor la position
let monitor_handle = tokio::spawn(async move {
for _ in 0..15 {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let position = timer_handle.position_sec().await;
println!("Playback position: {:.3} sec", position);
}
});
// Stream des chunks avec timing réel
// 100ms par chunk à 48kHz = 4800 samples
source
.stream_chunks(4800, 48000, 440.0, 3000) // 3 secondes
.await
.unwrap();
println!("\nStreaming complete.");
// Attendre la fin
sink_handle.await.unwrap();
monitor_handle.await.unwrap();
}

View File

@@ -0,0 +1,58 @@
//! Exemple simple de contrôle de volume
//!
//! Démontre l'utilisation du VolumeNode avec changements dynamiques
use pmoaudio::{SinkNode, SourceNode, VolumeNode};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== Volume Control Demo ===\n");
// Créer la source
let mut source = SourceNode::new();
// Créer le volume node
let (mut volume, volume_tx) = VolumeNode::new("main".to_string(), 1.0, 10);
let volume_handle = volume.get_handle();
// Créer le sink
let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
// Connecter le pipeline
source.add_subscriber(volume_tx);
volume.add_subscriber(sink_tx);
// Lancer les nodes
tokio::spawn(async move { volume.run().await.unwrap() });
let sink_handle = tokio::spawn(async move { sink.run_with_stats().await.unwrap() });
// Contrôler le volume pendant la lecture
let volume_control = tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
println!("Setting volume to 0.5");
volume_handle.set_volume(0.5).await;
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
println!("Setting volume to 0.2");
volume_handle.set_volume(0.2).await;
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
println!("Setting volume to 1.0");
volume_handle.set_volume(1.0).await;
});
// Générer l'audio
source
.generate_chunks(20, 4800, 48000, 440.0)
.await
.unwrap();
volume_control.await?;
let stats = sink_handle.await?;
println!("\nFinal statistics:");
stats.display();
Ok(())
}

257
pmoaudio/src/audio_chunk.rs Normal file
View File

@@ -0,0 +1,257 @@
use std::sync::Arc;
/// Représente un chunk audio stéréo avec données partagées via Arc
///
/// Cette structure encapsule des données audio stéréo (canaux gauche et droit)
/// en utilisant `Arc<Vec<f32>>` pour permettre le partage efficace entre plusieurs
/// consumers sans copier les données audio.
///
/// # Optimisation zero-copy
///
/// Les données audio sont wrappées dans `Arc`, ce qui signifie que:
/// - Le clonage d'un `AudioChunk` ne clone que les pointeurs Arc (très rapide)
/// - Les données audio réelles ne sont copiées que si nécessaire (Copy-on-Write)
/// - Plusieurs nodes peuvent partager le même chunk simultanément
///
/// # Exemples
///
/// ```
/// use pmoaudio::AudioChunk;
///
/// // Créer un chunk avec des données générées
/// let left = vec![0.0, 0.1, 0.2, 0.3];
/// let right = vec![0.0, 0.1, 0.2, 0.3];
/// let chunk = AudioChunk::new(0, left, right, 48000);
///
/// assert_eq!(chunk.len(), 4);
/// assert_eq!(chunk.sample_rate, 48000);
/// ```
#[derive(Debug, Clone)]
pub struct AudioChunk {
/// Numéro d'ordre du chunk dans le flux
///
/// Permet de suivre l'ordre des chunks et détecter les pertes éventuelles
pub order: u64,
/// Canal gauche (partagé via Arc pour éviter les clonages)
///
/// Les samples sont en format float 32-bit, normalement entre -1.0 et 1.0
pub left: Arc<Vec<f32>>,
/// Canal droit (partagé via Arc pour éviter les clonages)
///
/// Les samples sont en format float 32-bit, normalement entre -1.0 et 1.0
pub right: Arc<Vec<f32>>,
/// Taux d'échantillonnage en Hz
///
/// Valeurs typiques: 44100, 48000, 96000, 192000
pub sample_rate: u32,
/// Gain multiplicatif appliqué au flux audio
///
/// Valeur par défaut: 1.0 (aucun changement)
/// Valeurs typiques: 0.0 (silence) à 1.0 (volume max)
pub gain: f32,
}
impl AudioChunk {
/// Crée un nouveau chunk audio
///
/// Les vecteurs sont automatiquement wrappés dans `Arc`.
///
/// # Arguments
///
/// * `order` - Numéro d'ordre du chunk dans le flux
/// * `left` - Samples du canal gauche
/// * `right` - Samples du canal droit
/// * `sample_rate` - Taux d'échantillonnage en Hz
///
/// # Exemples
///
/// ```
/// use pmoaudio::AudioChunk;
///
/// let chunk = AudioChunk::new(
/// 0,
/// vec![0.0, 0.5, 1.0],
/// vec![0.0, 0.5, 1.0],
/// 48000
/// );
/// ```
pub fn new(order: u64, left: Vec<f32>, right: Vec<f32>, sample_rate: u32) -> Self {
Self {
order,
left: Arc::new(left),
right: Arc::new(right),
sample_rate,
gain: 1.0,
}
}
/// Crée un nouveau chunk audio avec un gain spécifique
pub fn with_gain(
order: u64,
left: Vec<f32>,
right: Vec<f32>,
sample_rate: u32,
gain: f32,
) -> Self {
Self {
order,
left: Arc::new(left),
right: Arc::new(right),
sample_rate,
gain,
}
}
/// Crée un chunk à partir de données déjà wrappées dans Arc
///
/// Utile pour éviter un double wrapping si les données sont déjà dans Arc.
pub fn from_arc(
order: u64,
left: Arc<Vec<f32>>,
right: Arc<Vec<f32>>,
sample_rate: u32,
) -> Self {
Self {
order,
left,
right,
sample_rate,
gain: 1.0,
}
}
/// Crée un chunk à partir de données déjà wrappées dans Arc avec gain
pub fn from_arc_with_gain(
order: u64,
left: Arc<Vec<f32>>,
right: Arc<Vec<f32>>,
sample_rate: u32,
gain: f32,
) -> Self {
Self {
order,
left,
right,
sample_rate,
gain,
}
}
/// Retourne le nombre d'échantillons par canal
///
/// # Exemples
///
/// ```
/// use pmoaudio::AudioChunk;
///
/// let chunk = AudioChunk::new(0, vec![0.0; 1000], vec![0.0; 1000], 48000);
/// assert_eq!(chunk.len(), 1000);
/// ```
pub fn len(&self) -> usize {
self.left.len()
}
/// Vérifie si le chunk est vide
pub fn is_empty(&self) -> bool {
self.left.is_empty()
}
/// Clone les données pour permettre une modification (Copy-on-Write)
///
/// Cette méthode doit être appelée uniquement si vous avez besoin de modifier
/// les données audio. Pour une simple lecture, utilisez directement les champs
/// `left` et `right`.
///
/// # Exemples
///
/// ```
/// use pmoaudio::AudioChunk;
///
/// let chunk = AudioChunk::new(0, vec![1.0, 2.0], vec![3.0, 4.0], 48000);
/// let (mut left, mut right) = chunk.clone_data();
///
/// // Modifier les données
/// for sample in &mut left {
/// *sample *= 0.5;
/// }
/// ```
pub fn clone_data(&self) -> (Vec<f32>, Vec<f32>) {
((*self.left).clone(), (*self.right).clone())
}
/// Applique le gain et retourne un nouveau chunk avec les données modifiées
///
/// Cette méthode crée un nouveau chunk avec les samples multipliés par le gain.
/// Utile pour les nodes qui doivent matérialiser le gain avant la sortie.
///
/// # Exemples
///
/// ```
/// use pmoaudio::AudioChunk;
///
/// let chunk = AudioChunk::with_gain(0, vec![1.0, 2.0], vec![3.0, 4.0], 48000, 0.5);
/// let applied = chunk.apply_gain();
///
/// assert_eq!(applied.left[0], 0.5);
/// assert_eq!(applied.left[1], 1.0);
/// assert_eq!(applied.gain, 1.0); // Gain réinitialisé après application
/// ```
pub fn apply_gain(&self) -> Self {
if (self.gain - 1.0).abs() < f32::EPSILON {
// Pas de gain à appliquer, retourner un clone
return self.clone();
}
let left: Vec<f32> = self.left.iter().map(|&s| s * self.gain).collect();
let right: Vec<f32> = self.right.iter().map(|&s| s * self.gain).collect();
Self::new(self.order, left, right, self.sample_rate)
}
/// Modifie le gain de ce chunk (retourne un nouveau chunk avec le même Arc mais gain différent)
///
/// Cette méthode est très peu coûteuse car elle ne clone que la structure, pas les données audio.
pub fn with_modified_gain(&self, new_gain: f32) -> Self {
Self {
order: self.order,
left: self.left.clone(),
right: self.right.clone(),
sample_rate: self.sample_rate,
gain: self.gain * new_gain, // Multiplication des gains
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audio_chunk_creation() {
let left = vec![0.0, 0.1, 0.2];
let right = vec![0.0, 0.1, 0.2];
let chunk = AudioChunk::new(0, left, right, 48000);
assert_eq!(chunk.order, 0);
assert_eq!(chunk.len(), 3);
assert_eq!(chunk.sample_rate, 48000);
assert!(!chunk.is_empty());
}
#[test]
fn test_audio_chunk_arc_sharing() {
let left = Arc::new(vec![0.0, 0.1, 0.2]);
let right = Arc::new(vec![0.0, 0.1, 0.2]);
let chunk1 = AudioChunk::from_arc(0, left.clone(), right.clone(), 48000);
let chunk2 = chunk1.clone();
// Vérifier que les Arc pointent vers les mêmes données
assert!(Arc::ptr_eq(&chunk1.left, &chunk2.left));
assert!(Arc::ptr_eq(&chunk1.right, &chunk2.right));
}
}

233
pmoaudio/src/events.rs Normal file
View File

@@ -0,0 +1,233 @@
//! Système d'événements et d'abonnements générique pour les nodes
//!
//! Ce module fournit une infrastructure d'abonnement type-safe permettant
//! à chaque node d'émettre et de recevoir différents types d'événements.
use crate::AudioChunk;
use std::sync::Arc;
use tokio::sync::mpsc;
/// Trait de base pour tous les événements de node
///
/// Chaque type d'événement doit implémenter ce trait pour pouvoir
/// être utilisé dans le système d'abonnement.
pub trait NodeEvent: Send + Sync + Clone + 'static {}
/// Événement : données audio disponibles
#[derive(Debug, Clone)]
pub struct AudioDataEvent {
pub chunk: Arc<AudioChunk>,
}
impl NodeEvent for AudioDataEvent {}
/// Événement : changement de volume
#[derive(Debug, Clone)]
pub struct VolumeChangeEvent {
pub volume: f32,
pub source_node_id: String,
}
impl NodeEvent for VolumeChangeEvent {}
/// Événement : mise à jour du nom de la source
#[derive(Debug, Clone)]
pub struct SourceNameUpdateEvent {
pub source_name: String,
pub device_name: Option<String>,
}
impl NodeEvent for SourceNameUpdateEvent {}
/// Trait pour les listeners d'événements
///
/// Les nodes qui souhaitent recevoir des événements d'un type particulier
/// doivent implémenter ce trait pour ce type.
#[async_trait::async_trait]
pub trait NodeListener<E: NodeEvent>: Send + Sync {
/// Appelé lorsqu'un événement est reçu
async fn on_event(&self, event: E);
}
/// Gestionnaire d'abonnements pour un type d'événement spécifique
///
/// Permet d'enregistrer des listeners et de broadcaster des événements.
#[derive(Clone)]
pub struct EventPublisher<E: NodeEvent> {
subscribers: Vec<mpsc::Sender<E>>,
}
impl<E: NodeEvent> EventPublisher<E> {
/// Crée un nouveau publisher vide
pub fn new() -> Self {
Self {
subscribers: Vec::new(),
}
}
/// Ajoute un subscriber via un channel
pub fn subscribe(&mut self, tx: mpsc::Sender<E>) {
self.subscribers.push(tx);
}
/// Publie un événement à tous les subscribers
pub async fn publish(&self, event: E) {
for tx in &self.subscribers {
// Utiliser try_send pour éviter de bloquer si un subscriber est lent
let _ = tx.try_send(event.clone());
}
}
/// Publie un événement de manière bloquante (attend que tous les subscribers reçoivent)
pub async fn publish_blocking(&self, event: E) {
for tx in &self.subscribers {
let _ = tx.send(event.clone()).await;
}
}
/// Retourne le nombre de subscribers actifs
pub fn subscriber_count(&self) -> usize {
self.subscribers.len()
}
}
impl<E: NodeEvent> Default for EventPublisher<E> {
fn default() -> Self {
Self::new()
}
}
/// Helper pour créer un listener basé sur une closure
pub struct ClosureListener<E: NodeEvent, F>
where
F: Fn(E) + Send + Sync + 'static,
{
callback: Arc<F>,
_phantom: std::marker::PhantomData<E>,
}
impl<E: NodeEvent, F> ClosureListener<E, F>
where
F: Fn(E) + Send + Sync + 'static,
{
pub fn new(callback: F) -> Self {
Self {
callback: Arc::new(callback),
_phantom: std::marker::PhantomData,
}
}
}
#[async_trait::async_trait]
impl<E: NodeEvent, F> NodeListener<E> for ClosureListener<E, F>
where
F: Fn(E) + Send + Sync + 'static,
{
async fn on_event(&self, event: E) {
(self.callback)(event);
}
}
/// Receiver helper pour consommer des événements depuis un channel
pub struct EventReceiver<E: NodeEvent> {
rx: mpsc::Receiver<E>,
}
impl<E: NodeEvent> EventReceiver<E> {
/// Crée un nouveau receiver
pub fn new(rx: mpsc::Receiver<E>) -> Self {
Self { rx }
}
/// Attend le prochain événement
pub async fn recv(&mut self) -> Option<E> {
self.rx.recv().await
}
/// Tente de recevoir un événement sans bloquer
pub fn try_recv(&mut self) -> Result<E, mpsc::error::TryRecvError> {
self.rx.try_recv()
}
}
/// Macro pour faciliter la création de publishers multiples dans un node
///
/// # Exemple
///
/// ```ignore
/// struct MyNode {
/// audio_publisher: EventPublisher<AudioDataEvent>,
/// volume_publisher: EventPublisher<VolumeChangeEvent>,
/// }
/// ```
#[macro_export]
macro_rules! publishers {
($($field:ident: $event_type:ty),* $(,)?) => {
$(
pub $field: $crate::events::EventPublisher<$event_type>,
)*
};
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_event_publisher_basic() {
let mut publisher = EventPublisher::<VolumeChangeEvent>::new();
let (tx, mut rx) = mpsc::channel(10);
publisher.subscribe(tx);
let event = VolumeChangeEvent {
volume: 0.5,
source_node_id: "test".to_string(),
};
publisher.publish(event.clone()).await;
let received = rx.recv().await.unwrap();
assert_eq!(received.volume, 0.5);
assert_eq!(received.source_node_id, "test");
}
#[tokio::test]
async fn test_multiple_subscribers() {
let mut publisher = EventPublisher::<VolumeChangeEvent>::new();
let (tx1, mut rx1) = mpsc::channel(10);
let (tx2, mut rx2) = mpsc::channel(10);
publisher.subscribe(tx1);
publisher.subscribe(tx2);
let event = VolumeChangeEvent {
volume: 0.7,
source_node_id: "test".to_string(),
};
publisher.publish(event.clone()).await;
let received1 = rx1.recv().await.unwrap();
let received2 = rx2.recv().await.unwrap();
assert_eq!(received1.volume, 0.7);
assert_eq!(received2.volume, 0.7);
}
#[tokio::test]
async fn test_event_receiver() {
let (tx, rx) = mpsc::channel(10);
let mut receiver = EventReceiver::new(rx);
let event = VolumeChangeEvent {
volume: 0.3,
source_node_id: "test".to_string(),
};
tx.send(event.clone()).await.unwrap();
let received = receiver.recv().await.unwrap();
assert_eq!(received.volume, 0.3);
}
}

99
pmoaudio/src/lib.rs Normal file
View File

@@ -0,0 +1,99 @@
//! PMOAudio - Pipeline audio stéréo async optimisé
//!
//! Cette crate fournit un pipeline audio push-based async utilisant Tokio,
//! optimisé pour minimiser les clonages de données via `Arc<Vec<f32>>`.
//!
//! # Architecture
//!
//! Le pipeline est composé de nodes asynchrones qui communiquent via des channels Tokio.
//! Les données audio sont encapsulées dans des [`AudioChunk`] et partagées via `Arc` pour
//! éviter les copies inutiles.
//!
//! ## Pipeline type
//!
//! ```text
//! SourceNode → DecoderNode → DSPNode → BufferNode → TimerNode → SinkNode(s)
//! ↓
//! Multiroom Sinks
//! (avec offsets)
//! ```
//!
//! # Exemples
//!
//! ## Pipeline simple
//!
//! ```no_run
//! use pmoaudio::{SinkNode, SourceNode, TimerNode};
//!
//! #[tokio::main]
//! async fn main() {
//! let (mut timer, timer_tx) = TimerNode::new(10);
//! let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
//!
//! timer.add_subscriber(sink_tx);
//!
//! tokio::spawn(async move { timer.run().await.unwrap() });
//! let sink_handle = tokio::spawn(async move {
//! sink.run_with_stats().await.unwrap()
//! });
//!
//! tokio::spawn(async move {
//! let mut source = SourceNode::new();
//! source.add_subscriber(timer_tx);
//! source.generate_chunks(30, 4800, 48000, 440.0).await.unwrap();
//! });
//!
//! sink_handle.await.unwrap();
//! }
//! ```
//!
//! ## Configuration multiroom
//!
//! ```no_run
//! use pmoaudio::{BufferNode, SinkNode};
//!
//! #[tokio::main]
//! async fn main() {
//! let (buffer, buffer_tx) = BufferNode::new(50, 10);
//!
//! let (sink1, sink1_tx) = SinkNode::new("Room 1".to_string(), 10);
//! let (sink2, sink2_tx) = SinkNode::new("Room 2".to_string(), 10);
//!
//! // Room 1 sans délai, Room 2 avec 5 chunks de retard
//! buffer.add_subscriber_with_offset(sink1_tx, 0).await;
//! buffer.add_subscriber_with_offset(sink2_tx, 5).await;
//!
//! tokio::spawn(async move { buffer.run().await.unwrap() });
//! // ... spawn sinks et source
//! }
//! ```
//!
//! # Optimisations
//!
//! - **Zero-copy** : Les [`AudioChunk`] sont partagés via `Arc`, seul le pointeur est cloné
//! - **Copy-on-Write** : Les nodes DSP clonent les données uniquement si modification nécessaire
//! - **Backpressure** : Channels bounded avec `try_send` pour éviter les blocages
//! - **RwLock** : Pour partage concurrent du compteur [`TimerNode`]
mod audio_chunk;
pub mod events;
mod nodes;
pub use audio_chunk::AudioChunk;
pub use events::{
AudioDataEvent, EventPublisher, EventReceiver, NodeEvent, NodeListener, SourceNameUpdateEvent,
VolumeChangeEvent,
};
pub use nodes::{
buffer_node::BufferNode,
chromecast_sink::{ChromecastConfig, ChromecastSink, ChromecastStats, StreamEncoding},
decoder_node::DecoderNode,
disk_sink::{AudioFileFormat, DiskSink, DiskSinkConfig, DiskSinkStats},
dsp_node::DspNode,
mpd_sink::{MpdAudioFormat, MpdConfig, MpdHandle, MpdSink, MpdStats},
sink_node::{SinkNode, SinkStats},
source_node::SourceNode,
timer_node::{TimerHandle, TimerNode},
volume_node::{HardwareVolumeNode, VolumeHandle, VolumeNode},
AudioError, AudioNode, MultiSubscriberNode, SingleSubscriberNode,
};

View File

@@ -0,0 +1,244 @@
use crate::{
nodes::{AudioError, MultiSubscriberNode},
AudioChunk,
};
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
/// Subscriber avec son propre offset dans le buffer
struct BufferSubscriber {
tx: mpsc::Sender<Arc<AudioChunk>>,
offset: usize, // Position dans le buffer circulaire
}
/// BufferNode avec buffer circulaire pour support multiroom
///
/// Ce node maintient un buffer circulaire de chunks et permet à plusieurs
/// abonnés de lire avec des offsets différents, ce qui est idéal pour des
/// configurations multiroom où différentes pièces peuvent avoir un léger
/// délai de synchronisation.
///
/// # Fonctionnement
///
/// - Le buffer est implémenté avec un `VecDeque` de taille fixe
/// - Chaque abonné peut avoir un offset indépendant (en nombre de chunks)
/// - Utilise `try_send` pour éviter de bloquer si un abonné est saturé
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::{BufferNode, SinkNode};
///
/// #[tokio::main]
/// async fn main() {
/// let (buffer, buffer_tx) = BufferNode::new(50, 10);
///
/// let (sink1, sink1_tx) = SinkNode::new("Room 1".to_string(), 10);
/// let (sink2, sink2_tx) = SinkNode::new("Room 2".to_string(), 10);
///
/// // Room 1 sans délai
/// buffer.add_subscriber_with_offset(sink1_tx, 0).await;
///
/// // Room 2 avec 5 chunks de retard
/// buffer.add_subscriber_with_offset(sink2_tx, 5).await;
///
/// tokio::spawn(async move { buffer.run().await.unwrap() });
/// // ... spawn sinks et source
/// }
/// ```
pub struct BufferNode {
buffer: Arc<RwLock<VecDeque<Arc<AudioChunk>>>>,
subscribers: Arc<RwLock<Vec<BufferSubscriber>>>,
buffer_size: usize,
rx: mpsc::Receiver<Arc<AudioChunk>>,
next_subscribers: MultiSubscriberNode, // Pour passer au node suivant
}
impl BufferNode {
/// Crée un nouveau BufferNode
///
/// # Arguments
/// * `buffer_size` - Taille maximale du buffer circulaire
/// * `channel_size` - Taille du channel bounded pour backpressure
pub fn new(buffer_size: usize, channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self {
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(buffer_size))),
subscribers: Arc::new(RwLock::new(Vec::new())),
buffer_size,
rx,
next_subscribers: MultiSubscriberNode::new(),
};
(node, tx)
}
/// Ajoute un abonné avec un offset spécifique (pour multiroom)
pub async fn add_subscriber_with_offset(
&self,
tx: mpsc::Sender<Arc<AudioChunk>>,
offset: usize,
) {
let mut subs = self.subscribers.write().await;
subs.push(BufferSubscriber { tx, offset });
}
/// Ajoute un abonné sans offset (commence au chunk courant)
pub async fn add_subscriber(&self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.add_subscriber_with_offset(tx, 0).await;
}
/// Ajoute un abonné pour le node suivant (sans buffer)
pub fn add_next_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.next_subscribers.add_subscriber(tx);
}
/// Démarre la boucle de traitement du BufferNode
pub async fn run(mut self) -> Result<(), AudioError> {
let mut chunk_index = 0usize;
while let Some(chunk) = self.rx.recv().await {
// Ajouter au buffer circulaire
{
let mut buffer = self.buffer.write().await;
if buffer.len() >= self.buffer_size {
buffer.pop_front();
}
buffer.push_back(chunk.clone());
}
// Envoyer aux abonnés avec offset
{
let buffer = self.buffer.read().await;
let mut subs = self.subscribers.write().await;
for sub in subs.iter_mut() {
// Calculer l'index dans le buffer en fonction de l'offset
let target_index = if chunk_index >= sub.offset {
chunk_index - sub.offset
} else {
continue; // Pas encore assez de données
};
// Vérifier si le chunk est disponible dans le buffer
let buffer_age = chunk_index - target_index;
if buffer_age < buffer.len() {
let chunk_to_send = &buffer[buffer.len() - buffer_age - 1];
// try_send non-bloquant pour éviter de bloquer la source
let _ = sub.tx.try_send(chunk_to_send.clone());
}
}
}
// Push vers les nodes suivants sans buffer
self.next_subscribers.try_push(chunk).await?;
chunk_index += 1;
}
Ok(())
}
/// Version avec push synchrone au lieu de try_push
pub async fn run_blocking(mut self) -> Result<(), AudioError> {
let mut chunk_index = 0usize;
while let Some(chunk) = self.rx.recv().await {
// Ajouter au buffer circulaire
{
let mut buffer = self.buffer.write().await;
if buffer.len() >= self.buffer_size {
buffer.pop_front();
}
buffer.push_back(chunk.clone());
}
// Envoyer aux abonnés avec offset
{
let buffer = self.buffer.read().await;
let subs = self.subscribers.read().await;
for sub in subs.iter() {
let target_index = if chunk_index >= sub.offset {
chunk_index - sub.offset
} else {
continue;
};
let buffer_age = chunk_index - target_index;
if buffer_age < buffer.len() {
let chunk_to_send = &buffer[buffer.len() - buffer_age - 1];
let _ = sub.tx.send(chunk_to_send.clone()).await;
}
}
}
// Push vers les nodes suivants
for _ in 0..self.next_subscribers.subscribers.len() {
self.next_subscribers.push(chunk.clone()).await?;
}
chunk_index += 1;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_buffer_node_basic() {
let (mut node, tx) = BufferNode::new(10, 5);
let (out_tx, mut out_rx) = mpsc::channel(5);
node.add_next_subscriber(out_tx);
// Spawn le node
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer des chunks
for i in 0..3 {
let chunk = AudioChunk::new(i, vec![0.0; 100], vec![0.0; 100], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
}
// Recevoir les chunks
for i in 0..3 {
let chunk = out_rx.recv().await.unwrap();
assert_eq!(chunk.order, i);
}
}
#[tokio::test]
async fn test_buffer_node_with_offset() {
let (node, tx) = BufferNode::new(10, 10);
let (out_tx, mut out_rx) = mpsc::channel(10);
// Ajouter un abonné avec offset de 2 chunks
node.add_subscriber_with_offset(out_tx, 2).await;
// Spawn le node
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer 5 chunks
for i in 0..5 {
let chunk = AudioChunk::new(i, vec![0.0; 100], vec![0.0; 100], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// L'abonné devrait recevoir les chunks 0, 1, 2 (avec 2 chunks de retard)
let chunk = out_rx.try_recv().unwrap();
assert_eq!(chunk.order, 0);
}
}

View File

@@ -0,0 +1,286 @@
//! ChromecastSink - Diffuse le flux audio vers un périphérique Chromecast
//!
//! Ce module fournit un sink qui envoie le flux audio à un Chromecast.
//! Note: Cette implémentation est une version mock/skeleton. Une vraie implémentation
//! nécessiterait une bibliothèque comme `rust-cast` ou similaire.
use crate::{nodes::AudioError, AudioChunk};
use std::sync::Arc;
use tokio::sync::mpsc;
/// Configuration pour le ChromecastSink
#[derive(Debug, Clone)]
pub struct ChromecastConfig {
/// Nom ou adresse IP du Chromecast
pub device_address: String,
/// Nom amical du device
pub device_name: String,
/// Port de communication (défaut: 8009)
pub port: u16,
/// Taille du buffer de streaming
pub buffer_size: usize,
/// Format d'encodage pour le streaming
pub encoding: StreamEncoding,
}
impl Default for ChromecastConfig {
fn default() -> Self {
Self {
device_address: "192.168.1.100".to_string(),
device_name: "Living Room".to_string(),
port: 8009,
buffer_size: 50,
encoding: StreamEncoding::Mp3,
}
}
}
/// Formats d'encodage supportés pour le streaming
#[derive(Debug, Clone, Copy)]
pub enum StreamEncoding {
/// MP3 (compatible avec la plupart des Chromecasts)
Mp3,
/// AAC
Aac,
/// Opus
Opus,
/// PCM non compressé (haute qualité, bande passante élevée)
Pcm,
}
/// ChromecastSink - Diffuse vers un périphérique Chromecast
///
/// Ce sink encode le flux audio et le streame vers un Chromecast.
/// La connexion est établie lors de l'initialisation et maintenue pendant toute la durée.
///
/// # Implémentation actuelle
///
/// Cette version est un mock qui simule l'envoi au Chromecast.
/// Pour une vraie implémentation, il faudrait:
/// - Utiliser une bibliothèque comme `rust-cast`
/// - Établir une connexion TLS avec le device
/// - Lancer une application de récepteur sur le Chromecast
/// - Encoder l'audio dans le format approprié
/// - Streamer via HTTP ou WebSocket
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::{ChromecastSink, ChromecastConfig};
///
/// #[tokio::main]
/// async fn main() {
/// let config = ChromecastConfig {
/// device_address: "192.168.1.100".to_string(),
/// device_name: "Living Room".to_string(),
/// ..Default::default()
/// };
///
/// let (sink, sink_tx) = ChromecastSink::new("chromecast1".to_string(), config, 10);
///
/// tokio::spawn(async move {
/// sink.run().await.unwrap()
/// });
/// }
/// ```
pub struct ChromecastSink {
/// Identifiant du sink
node_id: String,
/// Channel pour recevoir les chunks audio
rx: mpsc::Receiver<Arc<AudioChunk>>,
/// Configuration
config: ChromecastConfig,
/// État de la connexion (mock)
connected: bool,
}
impl ChromecastSink {
/// Crée un nouveau ChromecastSink
///
/// # Arguments
///
/// * `node_id` - Identifiant unique du sink
/// * `config` - Configuration du Chromecast
/// * `channel_size` - Taille du buffer du channel
pub fn new(
node_id: String,
config: ChromecastConfig,
channel_size: usize,
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let sink = Self {
node_id,
rx,
config,
connected: false,
};
(sink, tx)
}
/// Établit la connexion avec le Chromecast (mock)
async fn connect(&mut self) -> Result<(), AudioError> {
println!(
"[{}] Connecting to Chromecast '{}' at {}:{}...",
self.node_id, self.config.device_name, self.config.device_address, self.config.port
);
// Simuler une connexion
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
self.connected = true;
println!(
"[{}] Connected to Chromecast '{}' successfully",
self.node_id, self.config.device_name
);
Ok(())
}
/// Envoie un chunk au Chromecast (mock)
async fn send_chunk(&self, _chunk: &AudioChunk) -> Result<(), AudioError> {
if !self.connected {
return Err(AudioError::ProcessingError(
"Not connected to Chromecast".to_string(),
));
}
// Dans une vraie implémentation:
// 1. Appliquer le gain
// 2. Encoder dans le format approprié (MP3, AAC, etc.)
// 3. Envoyer via le protocole Chromecast
// Pour l'instant, simplement simuler un délai d'envoi
tokio::time::sleep(tokio::time::Duration::from_micros(50)).await;
Ok(())
}
/// Déconnecte proprement du Chromecast (mock)
async fn disconnect(&mut self) -> Result<(), AudioError> {
if self.connected {
println!(
"[{}] Disconnecting from Chromecast '{}'...",
self.node_id, self.config.device_name
);
// Simuler la déconnexion
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
self.connected = false;
println!("[{}] Disconnected successfully", self.node_id);
}
Ok(())
}
/// Démarre la boucle de traitement du ChromecastSink
pub async fn run(mut self) -> Result<ChromecastStats, AudioError> {
// Établir la connexion
self.connect().await?;
let mut stats = ChromecastStats::new(self.node_id.clone(), self.config.device_name.clone());
// Boucle principale
while let Some(chunk) = self.rx.recv().await {
// Appliquer le gain si nécessaire
let chunk_to_send = if (chunk.gain - 1.0).abs() > f32::EPSILON {
chunk.apply_gain()
} else {
(*chunk).clone()
};
// Envoyer au Chromecast
self.send_chunk(&chunk_to_send).await?;
stats.record_chunk(&chunk_to_send);
}
// Déconnexion propre
self.disconnect().await?;
stats.finalize();
Ok(stats)
}
}
/// Statistiques du ChromecastSink
#[derive(Debug, Clone)]
pub struct ChromecastStats {
pub node_id: String,
pub device_name: String,
pub chunks_sent: u64,
pub total_samples: u64,
pub total_duration_sec: f64,
}
impl ChromecastStats {
pub fn new(node_id: String, device_name: String) -> Self {
Self {
node_id,
device_name,
chunks_sent: 0,
total_samples: 0,
total_duration_sec: 0.0,
}
}
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
self.chunks_sent += 1;
self.total_samples += chunk.len() as u64;
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate as f64;
}
pub fn finalize(&mut self) {
// Calculs finaux si nécessaire
}
pub fn display(&self) {
println!("\n=== Chromecast Statistics: {} ===", self.node_id);
println!("Device: {}", self.device_name);
println!("Chunks sent: {}", self.chunks_sent);
println!("Total samples: {}", self.total_samples);
println!("Total duration: {:.3} sec", self.total_duration_sec);
println!("==================================\n");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_chromecast_sink_basic() {
let config = ChromecastConfig {
device_address: "127.0.0.1".to_string(),
device_name: "Test Device".to_string(),
..Default::default()
};
let (sink, tx) = ChromecastSink::new("test".to_string(), config, 10);
let handle = tokio::spawn(async move { sink.run().await });
// Envoyer quelques chunks
for i in 0..5 {
let chunk = AudioChunk::new(i, vec![0.5; 1000], vec![0.5; 1000], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
}
drop(tx);
let stats = handle.await.unwrap().unwrap();
assert_eq!(stats.chunks_sent, 5);
assert_eq!(stats.device_name, "Test Device");
}
}

View File

@@ -0,0 +1,151 @@
use crate::{
nodes::{AudioError, MultiSubscriberNode},
AudioChunk,
};
use std::sync::Arc;
use tokio::sync::mpsc;
/// DecoderNode - Décode des chunks audio
///
/// Version mock qui passe simplement les chunks (ou simule un décodage simple)
pub struct DecoderNode {
rx: mpsc::Receiver<Arc<AudioChunk>>,
subscribers: MultiSubscriberNode,
}
impl DecoderNode {
pub fn new(channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self {
rx,
subscribers: MultiSubscriberNode::new(),
};
(node, tx)
}
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
/// Mode passthrough - passe les chunks sans modification
pub async fn run_passthrough(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
self.subscribers.push(chunk).await?;
}
Ok(())
}
/// Mode mock décodage - simule un changement de sample rate
pub async fn run_with_resampling(mut self, target_sample_rate: u32) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
if chunk.sample_rate == target_sample_rate {
// Pas besoin de resampling
self.subscribers.push(chunk).await?;
} else {
// Simuler un resampling (mock simple)
let ratio = target_sample_rate as f64 / chunk.sample_rate as f64;
let new_len = (chunk.len() as f64 * ratio) as usize;
let (left_data, right_data) = chunk.clone_data();
let mut new_left = Vec::with_capacity(new_len);
let mut new_right = Vec::with_capacity(new_len);
// Resampling linéaire simple (mock)
for i in 0..new_len {
let src_pos = i as f64 / ratio;
let src_idx = src_pos as usize;
if src_idx < left_data.len() - 1 {
let frac = src_pos - src_idx as f64;
let left_sample = left_data[src_idx] * (1.0 - frac as f32)
+ left_data[src_idx + 1] * frac as f32;
let right_sample = right_data[src_idx] * (1.0 - frac as f32)
+ right_data[src_idx + 1] * frac as f32;
new_left.push(left_sample);
new_right.push(right_sample);
} else if src_idx < left_data.len() {
new_left.push(left_data[src_idx]);
new_right.push(right_data[src_idx]);
}
}
let new_chunk =
AudioChunk::new(chunk.order, new_left, new_right, target_sample_rate);
self.subscribers.push(Arc::new(new_chunk)).await?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_decoder_passthrough() {
let (mut node, tx) = DecoderNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run_passthrough().await.unwrap();
});
// Envoyer un chunk
let chunk = AudioChunk::new(0, vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0], 48000);
let chunk_arc = Arc::new(chunk);
tx.send(chunk_arc.clone()).await.unwrap();
// Recevoir le chunk
let received = out_rx.recv().await.unwrap();
assert!(Arc::ptr_eq(&chunk_arc, &received));
}
#[tokio::test]
async fn test_decoder_resampling() {
let (mut node, tx) = DecoderNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run_with_resampling(96000).await.unwrap();
});
// Envoyer un chunk à 48000 Hz
let chunk = AudioChunk::new(0, vec![1.0; 100], vec![1.0; 100], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
// Recevoir le chunk resampleé
let received = out_rx.recv().await.unwrap();
assert_eq!(received.sample_rate, 96000);
// Le chunk devrait être environ 2x plus grand
assert!(received.len() > 150 && received.len() < 250);
}
#[tokio::test]
async fn test_decoder_no_resampling_needed() {
let (mut node, tx) = DecoderNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run_with_resampling(48000).await.unwrap();
});
// Envoyer un chunk déjà au bon sample rate
let chunk = AudioChunk::new(0, vec![1.0; 100], vec![1.0; 100], 48000);
let chunk_arc = Arc::new(chunk);
tx.send(chunk_arc.clone()).await.unwrap();
// Le chunk devrait être passé sans modification
let received = out_rx.recv().await.unwrap();
assert!(Arc::ptr_eq(&chunk_arc, &received));
}
}

View File

@@ -0,0 +1,494 @@
//! DiskSink - Écrit le flux audio dans un fichier
//!
//! Ce module fournit un sink qui écrit les chunks audio sur disque,
//! avec support de la dérivation automatique du nom de fichier depuis la source.
use crate::{events::SourceNameUpdateEvent, nodes::AudioError, AudioChunk};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, RwLock};
/// Configuration pour le DiskSink
#[derive(Debug, Clone)]
pub struct DiskSinkConfig {
/// Chemin racine où écrire les fichiers
pub output_dir: PathBuf,
/// Nom de fichier explicite (optionnel)
/// Si None, sera dérivé du nom de la source
pub filename: Option<String>,
/// Format d'écriture
pub format: AudioFileFormat,
/// Taille du buffer d'écriture (en chunks)
pub buffer_size: usize,
}
impl Default for DiskSinkConfig {
fn default() -> Self {
Self {
output_dir: PathBuf::from("."),
filename: None,
format: AudioFileFormat::Wav,
buffer_size: 100,
}
}
}
/// Formats de fichiers audio supportés
#[derive(Debug, Clone, Copy)]
pub enum AudioFileFormat {
/// Format WAV (non compressé)
Wav,
/// Format FLAC (compressé sans perte)
Flac,
/// Format brut PCM
Raw,
}
impl AudioFileFormat {
/// Retourne l'extension de fichier appropriée
pub fn extension(&self) -> &str {
match self {
AudioFileFormat::Wav => "wav",
AudioFileFormat::Flac => "flac",
AudioFileFormat::Raw => "pcm",
}
}
}
/// DiskSink - Écrit le flux audio dans un fichier sur disque
///
/// Ce sink consomme les chunks audio et les écrit dans un fichier.
/// Le nom du fichier peut être dérivé automatiquement du nom de la source
/// via les événements `SourceNameUpdateEvent`.
///
/// # Caractéristiques
///
/// - Écriture asynchrone avec buffer
/// - Dérivation automatique du nom de fichier depuis la source
/// - Support de plusieurs formats (WAV, FLAC, PCM brut)
/// - Gestion du gain : applique le gain avant l'écriture
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::{DiskSink, DiskSinkConfig};
/// use std::path::PathBuf;
///
/// #[tokio::main]
/// async fn main() {
/// let config = DiskSinkConfig {
/// output_dir: PathBuf::from("/tmp/audio"),
/// filename: Some("output.wav".to_string()),
/// ..Default::default()
/// };
///
/// let (sink, sink_tx) = DiskSink::new("disk1".to_string(), config, 10);
///
/// tokio::spawn(async move {
/// sink.run().await.unwrap()
/// });
/// }
/// ```
pub struct DiskSink {
/// Identifiant du sink
node_id: String,
/// Channel pour recevoir les chunks audio
rx: mpsc::Receiver<Arc<AudioChunk>>,
/// Configuration
config: DiskSinkConfig,
/// Nom de fichier résolu (partagé)
resolved_filename: Arc<RwLock<Option<PathBuf>>>,
/// Receiver pour les événements de nom de source (optionnel)
source_name_rx: Option<mpsc::Receiver<SourceNameUpdateEvent>>,
/// Writer pour le fichier
writer: Option<AudioFileWriter>,
}
impl DiskSink {
/// Crée un nouveau DiskSink
///
/// # Arguments
///
/// * `node_id` - Identifiant unique du sink
/// * `config` - Configuration du sink
/// * `channel_size` - Taille du buffer du channel
pub fn new(
node_id: String,
config: DiskSinkConfig,
channel_size: usize,
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let sink = Self {
node_id,
rx,
config,
resolved_filename: Arc::new(RwLock::new(None)),
source_name_rx: None,
writer: None,
};
(sink, tx)
}
/// Configure la source des événements de nom de source
pub fn set_source_name_source(&mut self, rx: mpsc::Receiver<SourceNameUpdateEvent>) {
self.source_name_rx = Some(rx);
}
/// Résout le nom du fichier de sortie
///
/// Si un filename explicite est fourni dans la config, l'utilise.
/// Sinon, utilise le source_name avec l'extension appropriée.
fn resolve_filename(&self, source_name: Option<&str>) -> PathBuf {
let filename = if let Some(ref explicit_name) = self.config.filename {
explicit_name.clone()
} else if let Some(name) = source_name {
// Nettoyer le nom de la source pour en faire un nom de fichier valide
let clean_name = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect::<String>();
format!("{}.{}", clean_name, self.config.format.extension())
} else {
// Fallback sur un nom par défaut
format!("{}.{}", self.node_id, self.config.format.extension())
};
self.config.output_dir.join(filename)
}
/// Initialise le writer pour le fichier de sortie
async fn initialize_writer(&mut self, source_name: Option<&str>) -> Result<(), AudioError> {
let path = self.resolve_filename(source_name);
*self.resolved_filename.write().await = Some(path.clone());
// Créer le répertoire parent si nécessaire
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to create directory: {}", e))
})?;
}
// Créer le writer approprié selon le format
let writer = match self.config.format {
AudioFileFormat::Wav => AudioFileWriter::new_wav(path).await?,
AudioFileFormat::Flac => {
// FLAC nécessiterait une bibliothèque externe, pour l'instant utiliser WAV
AudioFileWriter::new_wav(path).await?
}
AudioFileFormat::Raw => AudioFileWriter::new_raw(path).await?,
};
self.writer = Some(writer);
Ok(())
}
/// Démarre la boucle de traitement du DiskSink
pub async fn run(mut self) -> Result<DiskSinkStats, AudioError> {
let mut stats = DiskSinkStats::new(self.node_id.clone());
let mut source_name: Option<String> = None;
let mut initialized = false;
loop {
tokio::select! {
// Recevoir les chunks audio
chunk_opt = self.rx.recv() => {
match chunk_opt {
Some(chunk) => {
// Initialiser le writer à la réception du premier chunk
if !initialized {
self.initialize_writer(source_name.as_deref()).await?;
initialized = true;
}
// Appliquer le gain avant l'écriture
let chunk_with_gain = if (chunk.gain - 1.0).abs() > f32::EPSILON {
chunk.apply_gain()
} else {
(*chunk).clone()
};
// Écrire le chunk
if let Some(ref mut writer) = self.writer {
writer.write_chunk(&chunk_with_gain).await?;
stats.record_chunk(&chunk_with_gain);
}
}
None => {
// Channel fermé, terminer
break;
}
}
}
// Recevoir les mises à jour du nom de source
source_event_opt = async {
if let Some(ref mut rx) = self.source_name_rx {
rx.recv().await
} else {
std::future::pending().await
}
} => {
if let Some(event) = source_event_opt {
source_name = Some(event.source_name.clone());
// Si on n'a pas encore initialisé, le nom sera utilisé plus tard
// Sinon, on pourrait décider de fermer le fichier actuel et d'en créer un nouveau
}
}
}
}
// Fermer le fichier proprement
if let Some(writer) = self.writer {
writer.close().await?;
}
stats.finalize();
Ok(stats)
}
}
/// Writer pour fichiers audio
struct AudioFileWriter {
file: File,
format: AudioFileFormat,
sample_rate: Option<u32>,
total_samples: usize,
}
impl AudioFileWriter {
/// Crée un writer WAV
async fn new_wav(path: PathBuf) -> Result<Self, AudioError> {
let file = File::create(path)
.await
.map_err(|e| AudioError::ProcessingError(format!("Failed to create file: {}", e)))?;
Ok(Self {
file,
format: AudioFileFormat::Wav,
sample_rate: None,
total_samples: 0,
})
}
/// Crée un writer pour PCM brut
async fn new_raw(path: PathBuf) -> Result<Self, AudioError> {
let file = File::create(path)
.await
.map_err(|e| AudioError::ProcessingError(format!("Failed to create file: {}", e)))?;
Ok(Self {
file,
format: AudioFileFormat::Raw,
sample_rate: None,
total_samples: 0,
})
}
/// Écrit un chunk audio
async fn write_chunk(&mut self, chunk: &AudioChunk) -> Result<(), AudioError> {
// Enregistrer le sample rate du premier chunk
if self.sample_rate.is_none() {
self.sample_rate = Some(chunk.sample_rate);
// Pour WAV, écrire l'en-tête (simplifié)
if matches!(self.format, AudioFileFormat::Wav) {
self.write_wav_header(chunk.sample_rate).await?;
}
}
// Entrelacer les canaux gauche et droit
let mut interleaved = Vec::with_capacity(chunk.len() * 2);
for i in 0..chunk.len() {
interleaved.push(chunk.left[i]);
interleaved.push(chunk.right[i]);
}
// Convertir en bytes (little-endian 16-bit PCM)
let mut bytes = Vec::with_capacity(interleaved.len() * 2);
for &sample in &interleaved {
let sample_i16 = (sample.clamp(-1.0, 1.0) * 32767.0) as i16;
bytes.extend_from_slice(&sample_i16.to_le_bytes());
}
self.file.write_all(&bytes).await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to write audio data: {}", e))
})?;
self.total_samples += chunk.len();
Ok(())
}
/// Écrit un en-tête WAV simplifié
async fn write_wav_header(&mut self, sample_rate: u32) -> Result<(), AudioError> {
// En-tête WAV basique (sera mis à jour à la fermeture)
let mut header = Vec::new();
// RIFF chunk
header.extend_from_slice(b"RIFF");
header.extend_from_slice(&0u32.to_le_bytes()); // Taille (à mettre à jour)
header.extend_from_slice(b"WAVE");
// fmt chunk
header.extend_from_slice(b"fmt ");
header.extend_from_slice(&16u32.to_le_bytes()); // Taille du fmt chunk
header.extend_from_slice(&1u16.to_le_bytes()); // Format PCM
header.extend_from_slice(&2u16.to_le_bytes()); // 2 canaux (stéréo)
header.extend_from_slice(&sample_rate.to_le_bytes());
header.extend_from_slice(&(sample_rate * 4).to_le_bytes()); // Byte rate
header.extend_from_slice(&4u16.to_le_bytes()); // Block align
header.extend_from_slice(&16u16.to_le_bytes()); // Bits per sample
// data chunk header
header.extend_from_slice(b"data");
header.extend_from_slice(&0u32.to_le_bytes()); // Taille des données (à mettre à jour)
self.file.write_all(&header).await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to write WAV header: {}", e))
})?;
Ok(())
}
/// Ferme le fichier et met à jour l'en-tête si nécessaire
async fn close(mut self) -> Result<(), AudioError> {
if matches!(self.format, AudioFileFormat::Wav) {
// Mettre à jour les tailles dans l'en-tête WAV
let data_size = (self.total_samples * 4) as u32; // 2 bytes per sample * 2 channels
let file_size = data_size + 36;
// Positionner au début et réécrire les tailles
use tokio::io::AsyncSeekExt;
self.file
.seek(std::io::SeekFrom::Start(4))
.await
.map_err(|e| {
AudioError::ProcessingError(format!("Failed to seek in file: {}", e))
})?;
self.file
.write_all(&file_size.to_le_bytes())
.await
.map_err(|e| {
AudioError::ProcessingError(format!("Failed to update file size: {}", e))
})?;
self.file
.seek(std::io::SeekFrom::Start(40))
.await
.map_err(|e| {
AudioError::ProcessingError(format!("Failed to seek in file: {}", e))
})?;
self.file
.write_all(&data_size.to_le_bytes())
.await
.map_err(|e| {
AudioError::ProcessingError(format!("Failed to update data size: {}", e))
})?;
}
self.file
.flush()
.await
.map_err(|e| AudioError::ProcessingError(format!("Failed to flush file: {}", e)))?;
Ok(())
}
}
/// Statistiques du DiskSink
#[derive(Debug, Clone)]
pub struct DiskSinkStats {
pub node_id: String,
pub chunks_written: u64,
pub total_samples: u64,
pub total_duration_sec: f64,
}
impl DiskSinkStats {
pub fn new(node_id: String) -> Self {
Self {
node_id,
chunks_written: 0,
total_samples: 0,
total_duration_sec: 0.0,
}
}
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
self.chunks_written += 1;
self.total_samples += chunk.len() as u64;
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate as f64;
}
pub fn finalize(&mut self) {
// Pourrait effectuer des calculs finaux ici
}
pub fn display(&self) {
println!("\n=== DiskSink Statistics: {} ===", self.node_id);
println!("Chunks written: {}", self.chunks_written);
println!("Total samples: {}", self.total_samples);
println!("Total duration: {:.3} sec", self.total_duration_sec);
println!("============================\n");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_disk_sink_basic() {
let temp_dir = std::env::temp_dir().join("pmoaudio_test");
tokio::fs::create_dir_all(&temp_dir).await.unwrap();
let config = DiskSinkConfig {
output_dir: temp_dir.clone(),
filename: Some("test_output.wav".to_string()),
format: AudioFileFormat::Wav,
buffer_size: 10,
};
let (sink, tx) = DiskSink::new("test".to_string(), config, 10);
let handle = tokio::spawn(async move { sink.run().await });
// Envoyer quelques chunks
for i in 0..5 {
let chunk = AudioChunk::new(i, vec![0.5; 1000], vec![0.5; 1000], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
}
drop(tx);
let stats = handle.await.unwrap().unwrap();
assert_eq!(stats.chunks_written, 5);
// Vérifier que le fichier existe
let output_path = temp_dir.join("test_output.wav");
assert!(output_path.exists());
// Nettoyage
tokio::fs::remove_file(output_path).await.ok();
tokio::fs::remove_dir(temp_dir).await.ok();
}
}

View File

@@ -0,0 +1,230 @@
use crate::{
nodes::{AudioError, MultiSubscriberNode},
AudioChunk,
};
use std::sync::Arc;
use tokio::sync::mpsc;
/// DspNode - Applique des transformations DSP aux chunks audio
///
/// Clone les données uniquement si elles doivent être modifiées
pub struct DspNode {
rx: mpsc::Receiver<Arc<AudioChunk>>,
subscribers: MultiSubscriberNode,
gain: f32,
}
impl DspNode {
pub fn new(channel_size: usize, gain: f32) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self {
rx,
subscribers: MultiSubscriberNode::new(),
gain,
};
(node, tx)
}
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
/// Applique le gain aux chunks
pub async fn run(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
if (self.gain - 1.0).abs() < f32::EPSILON {
// Gain = 1.0, pas de transformation nécessaire
self.subscribers.push(chunk).await?;
} else {
// Clone les données pour les modifier
let (mut left_data, mut right_data) = chunk.clone_data();
// Appliquer le gain
for sample in &mut left_data {
*sample *= self.gain;
}
for sample in &mut right_data {
*sample *= self.gain;
}
let new_chunk =
AudioChunk::new(chunk.order, left_data, right_data, chunk.sample_rate);
self.subscribers.push(Arc::new(new_chunk)).await?;
}
}
Ok(())
}
/// Met à jour le gain dynamiquement (nécessite un `Arc<RwLock<f32>>` dans une version réelle)
pub fn set_gain(&mut self, gain: f32) {
self.gain = gain;
}
}
/// DspNode avec filtre passe-bas simple (mock)
#[allow(dead_code)]
pub struct LowPassDspNode {
rx: mpsc::Receiver<Arc<AudioChunk>>,
subscribers: MultiSubscriberNode,
alpha: f32, // Coefficient du filtre
prev_left: f32,
prev_right: f32,
}
impl LowPassDspNode {
#[allow(dead_code)]
pub fn new(channel_size: usize, cutoff_ratio: f32) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
// Filtre RC simple: alpha = dt / (RC + dt)
// cutoff_ratio entre 0 (tout couper) et 1 (tout passer)
let alpha = cutoff_ratio.clamp(0.0, 1.0);
let node = Self {
rx,
subscribers: MultiSubscriberNode::new(),
alpha,
prev_left: 0.0,
prev_right: 0.0,
};
(node, tx)
}
#[allow(dead_code)]
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
#[allow(dead_code)]
pub async fn run(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
let (left_data, right_data) = chunk.clone_data();
let mut new_left = Vec::with_capacity(left_data.len());
let mut new_right = Vec::with_capacity(right_data.len());
// Appliquer le filtre
for &sample in &left_data {
self.prev_left = self.prev_left + self.alpha * (sample - self.prev_left);
new_left.push(self.prev_left);
}
for &sample in &right_data {
self.prev_right = self.prev_right + self.alpha * (sample - self.prev_right);
new_right.push(self.prev_right);
}
let new_chunk = AudioChunk::new(chunk.order, new_left, new_right, chunk.sample_rate);
self.subscribers.push(Arc::new(new_chunk)).await?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_dsp_node_unity_gain() {
let (mut node, tx) = DspNode::new(10, 1.0);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer un chunk
let chunk = AudioChunk::new(0, vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0], 48000);
let chunk_arc = Arc::new(chunk);
tx.send(chunk_arc.clone()).await.unwrap();
// Avec gain = 1.0, le chunk ne devrait pas être cloné
let received = out_rx.recv().await.unwrap();
assert!(Arc::ptr_eq(&chunk_arc, &received));
}
#[tokio::test]
async fn test_dsp_node_gain() {
let (mut node, tx) = DspNode::new(10, 2.0);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer un chunk
let chunk = AudioChunk::new(0, vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
// Vérifier que le gain a été appliqué
let received = out_rx.recv().await.unwrap();
assert_eq!(received.left[0], 2.0);
assert_eq!(received.left[1], 4.0);
assert_eq!(received.left[2], 6.0);
assert_eq!(received.right[0], 8.0);
assert_eq!(received.right[1], 10.0);
assert_eq!(received.right[2], 12.0);
}
#[tokio::test]
async fn test_lowpass_dsp_node() {
let (mut node, tx) = LowPassDspNode::new(10, 0.5);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer un chunk avec un signal carré
let chunk = AudioChunk::new(
0,
vec![1.0, 1.0, 1.0, -1.0, -1.0, -1.0],
vec![1.0, 1.0, 1.0, -1.0, -1.0, -1.0],
48000,
);
tx.send(Arc::new(chunk)).await.unwrap();
// Le filtre devrait lisser le signal
let received = out_rx.recv().await.unwrap();
// Vérifier que le signal est lissé (valeurs intermédiaires)
assert!(received.left[0].abs() < 1.0); // Premier échantillon lissé
assert!(received.left[2].abs() < 1.0); // Signal ne devrait pas atteindre 1.0 immédiatement
}
#[tokio::test]
async fn test_dsp_node_multiple_subscribers() {
let (mut node, tx) = DspNode::new(10, 0.5);
let (out_tx1, mut out_rx1) = mpsc::channel(10);
let (out_tx2, mut out_rx2) = mpsc::channel(10);
node.add_subscriber(out_tx1);
node.add_subscriber(out_tx2);
tokio::spawn(async move {
node.run().await.unwrap();
});
let chunk = AudioChunk::new(0, vec![2.0, 4.0], vec![2.0, 4.0], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
// Les deux abonnés devraient recevoir le même Arc
let received1 = out_rx1.recv().await.unwrap();
let received2 = out_rx2.recv().await.unwrap();
assert!(Arc::ptr_eq(&received1, &received2));
assert_eq!(received1.left[0], 1.0); // 2.0 * 0.5
assert_eq!(received1.left[1], 2.0); // 4.0 * 0.5
}
}

147
pmoaudio/src/nodes/mod.rs Normal file
View File

@@ -0,0 +1,147 @@
//! Nodes du pipeline audio
//!
//! Ce module contient tous les types de nodes disponibles pour construire
//! un pipeline audio, ainsi que les traits et structures de support.
use crate::AudioChunk;
use std::sync::Arc;
use tokio::sync::mpsc;
pub mod buffer_node;
pub mod chromecast_sink;
pub mod decoder_node;
pub mod disk_sink;
pub mod dsp_node;
pub mod mpd_sink;
pub mod sink_node;
pub mod source_node;
pub mod timer_node;
pub mod volume_node;
/// Trait de base pour tous les nodes audio
///
/// Tous les nodes du pipeline implémentent ce trait pour permettre
/// une interface uniforme de traitement des chunks audio.
#[async_trait::async_trait]
pub trait AudioNode: Send + Sync {
/// Push un chunk vers ce node
///
/// # Erreurs
///
/// Retourne `AudioError::SendError` si l'envoi échoue
async fn push(&mut self, chunk: Arc<AudioChunk>) -> Result<(), AudioError>;
/// Ferme le node proprement
async fn close(&mut self);
}
/// Node avec un seul abonné (pas de clone inutile)
///
/// Optimisé pour les cas où un node n'a qu'un seul destinataire.
/// Le Arc du chunk est simplement transféré sans clonage supplémentaire.
///
/// # Exemples
///
/// ```
/// use pmoaudio::SingleSubscriberNode;
/// use tokio::sync::mpsc;
///
/// let (tx, rx) = mpsc::channel(10);
/// let node = SingleSubscriberNode::new(tx);
/// ```
pub struct SingleSubscriberNode {
tx: mpsc::Sender<Arc<AudioChunk>>,
}
impl SingleSubscriberNode {
pub fn new(tx: mpsc::Sender<Arc<AudioChunk>>) -> Self {
Self { tx }
}
pub async fn push(&self, chunk: Arc<AudioChunk>) -> Result<(), AudioError> {
self.tx.send(chunk).await.map_err(|_| AudioError::SendError)
}
}
/// Node avec plusieurs abonnés (partage le même Arc)
///
/// Permet de broadcaster un chunk à plusieurs destinations.
/// Tous les abonnés reçoivent le même `Arc<AudioChunk>`, donc pas de copie
/// des données audio - seul le compteur de référence Arc est incrémenté.
///
/// # Exemples
///
/// ```
/// use pmoaudio::MultiSubscriberNode;
/// use tokio::sync::mpsc;
///
/// let mut node = MultiSubscriberNode::new();
/// let (tx1, rx1) = mpsc::channel(10);
/// let (tx2, rx2) = mpsc::channel(10);
///
/// node.add_subscriber(tx1);
/// node.add_subscriber(tx2);
/// // Les deux abonnés recevront les mêmes chunks
/// ```
pub struct MultiSubscriberNode {
subscribers: Vec<mpsc::Sender<Arc<AudioChunk>>>,
}
impl MultiSubscriberNode {
pub fn new() -> Self {
Self {
subscribers: Vec::new(),
}
}
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.push(tx);
}
pub async fn push(&self, chunk: Arc<AudioChunk>) -> Result<(), AudioError> {
for tx in &self.subscribers {
// On partage le même Arc avec tous les abonnés
tx.send(chunk.clone())
.await
.map_err(|_| AudioError::SendError)?;
}
Ok(())
}
pub async fn try_push(&self, chunk: Arc<AudioChunk>) -> Result<(), AudioError> {
for tx in &self.subscribers {
// try_send non-bloquant, ignore si saturé
let _ = tx.try_send(chunk.clone());
}
Ok(())
}
}
impl Default for MultiSubscriberNode {
fn default() -> Self {
Self::new()
}
}
/// Erreurs possibles dans le pipeline audio
#[derive(Debug, Clone)]
pub enum AudioError {
/// Échec d'envoi d'un chunk à travers un channel
SendError,
/// Échec de réception d'un chunk depuis un channel
ReceiveError,
/// Erreur de traitement avec message descriptif
ProcessingError(String),
}
impl std::fmt::Display for AudioError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AudioError::SendError => write!(f, "Failed to send audio chunk"),
AudioError::ReceiveError => write!(f, "Failed to receive audio chunk"),
AudioError::ProcessingError(msg) => write!(f, "Processing error: {}", msg),
}
}
}
impl std::error::Error for AudioError {}

View File

@@ -0,0 +1,391 @@
//! MpdSink - Envoie le flux audio à un démon MPD (Music Player Daemon)
//!
//! Ce module fournit un sink qui streame l'audio vers un démon MPD distant ou local.
//! Note: Cette implémentation est une version mock/skeleton. Une vraie implémentation
//! nécessiterait le protocole MPD complet et l'utilisation de bibliothèques comme `mpd`.
use crate::{nodes::AudioError, AudioChunk};
use std::sync::Arc;
use tokio::sync::mpsc;
/// Configuration pour le MpdSink
#[derive(Debug, Clone)]
pub struct MpdConfig {
/// Adresse du serveur MPD
pub host: String,
/// Port du serveur MPD (défaut: 6600)
pub port: u16,
/// Mot de passe optionnel
pub password: Option<String>,
/// Nom de l'output MPD à utiliser (optionnel)
pub output_name: Option<String>,
/// Taille du buffer
pub buffer_size: usize,
/// Format d'envoi
pub format: MpdAudioFormat,
}
impl Default for MpdConfig {
fn default() -> Self {
Self {
host: "localhost".to_string(),
port: 6600,
password: None,
output_name: None,
buffer_size: 50,
format: MpdAudioFormat::S16Le,
}
}
}
/// Formats audio supportés par MPD
#[derive(Debug, Clone, Copy)]
pub enum MpdAudioFormat {
/// Signed 16-bit Little Endian
S16Le,
/// Signed 24-bit Little Endian
S24Le,
/// Signed 32-bit Little Endian
S32Le,
/// Float 32-bit
F32,
}
impl MpdAudioFormat {
/// Retourne le nom du format pour le protocole MPD
pub fn as_mpd_string(&self) -> &str {
match self {
MpdAudioFormat::S16Le => "16:16:2",
MpdAudioFormat::S24Le => "24:24:2",
MpdAudioFormat::S32Le => "32:32:2",
MpdAudioFormat::F32 => "f:32:2",
}
}
}
/// MpdSink - Streame vers un démon MPD
///
/// Ce sink se connecte à un serveur MPD et lui envoie le flux audio.
/// MPD peut ensuite router l'audio vers différents outputs (ALSA, PulseAudio, HTTP, etc.).
///
/// # Implémentation actuelle
///
/// Cette version est un mock qui simule la communication avec MPD.
/// Pour une vraie implémentation, il faudrait:
/// - Implémenter le protocole MPD (commandes textuelles sur TCP)
/// - S'authentifier si nécessaire
/// - Configurer le format audio
/// - Envoyer les données PCM via le protocole approprié
/// - Gérer les commandes de contrôle (play, pause, stop)
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::{MpdSink, MpdConfig};
///
/// #[tokio::main]
/// async fn main() {
/// let config = MpdConfig {
/// host: "localhost".to_string(),
/// port: 6600,
/// password: None,
/// ..Default::default()
/// };
///
/// let (sink, sink_tx) = MpdSink::new("mpd1".to_string(), config, 10);
///
/// tokio::spawn(async move {
/// sink.run().await.unwrap()
/// });
/// }
/// ```
pub struct MpdSink {
/// Identifiant du sink
node_id: String,
/// Channel pour recevoir les chunks audio
rx: mpsc::Receiver<Arc<AudioChunk>>,
/// Configuration
config: MpdConfig,
/// État de la connexion (mock)
connected: bool,
/// Version du serveur MPD (mock)
mpd_version: Option<String>,
}
impl MpdSink {
/// Crée un nouveau MpdSink
///
/// # Arguments
///
/// * `node_id` - Identifiant unique du sink
/// * `config` - Configuration MPD
/// * `channel_size` - Taille du buffer du channel
pub fn new(
node_id: String,
config: MpdConfig,
channel_size: usize,
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let sink = Self {
node_id,
rx,
config,
connected: false,
mpd_version: None,
};
(sink, tx)
}
/// Établit la connexion avec le serveur MPD (mock)
async fn connect(&mut self) -> Result<(), AudioError> {
println!(
"[{}] Connecting to MPD at {}:{}...",
self.node_id, self.config.host, self.config.port
);
// Simuler une connexion TCP
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
// Dans une vraie implémentation:
// 1. Établir connexion TCP
// 2. Lire la bannière de version
// 3. S'authentifier si password fourni
// 4. Configurer le format audio
self.mpd_version = Some("0.23.0".to_string());
self.connected = true;
println!(
"[{}] Connected to MPD v{} successfully",
self.node_id,
self.mpd_version.as_ref().unwrap()
);
// Configurer le format audio
self.configure_audio_format().await?;
Ok(())
}
/// Configure le format audio sur MPD (mock)
async fn configure_audio_format(&self) -> Result<(), AudioError> {
println!(
"[{}] Configuring audio format: {}",
self.node_id,
self.config.format.as_mpd_string()
);
// Dans une vraie implémentation:
// Envoyer une commande MPD pour configurer le format
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
Ok(())
}
/// Envoie un chunk au serveur MPD (mock)
async fn send_chunk(&self, _chunk: &AudioChunk) -> Result<(), AudioError> {
if !self.connected {
return Err(AudioError::ProcessingError(
"Not connected to MPD".to_string(),
));
}
// Dans une vraie implémentation:
// 1. Appliquer le gain
// 2. Convertir dans le format approprié (S16LE, etc.)
// 3. Envoyer via le protocole MPD (probablement via une commande `sendmessage` ou pipe)
// Simuler un délai d'envoi
tokio::time::sleep(tokio::time::Duration::from_micros(50)).await;
Ok(())
}
/// Déconnecte proprement du serveur MPD (mock)
async fn disconnect(&mut self) -> Result<(), AudioError> {
if self.connected {
println!("[{}] Disconnecting from MPD...", self.node_id);
// Dans une vraie implémentation:
// Envoyer la commande "close"
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
self.connected = false;
println!("[{}] Disconnected successfully", self.node_id);
}
Ok(())
}
/// Démarre la boucle de traitement du MpdSink
pub async fn run(mut self) -> Result<MpdStats, AudioError> {
// Établir la connexion
self.connect().await?;
let mut stats = MpdStats::new(
self.node_id.clone(),
format!("{}:{}", self.config.host, self.config.port),
);
// Boucle principale
while let Some(chunk) = self.rx.recv().await {
// Appliquer le gain si nécessaire
let chunk_to_send = if (chunk.gain - 1.0).abs() > f32::EPSILON {
chunk.apply_gain()
} else {
(*chunk).clone()
};
// Envoyer au serveur MPD
self.send_chunk(&chunk_to_send).await?;
stats.record_chunk(&chunk_to_send);
}
// Déconnexion propre
self.disconnect().await?;
stats.finalize();
Ok(stats)
}
/// Retourne un handle pour contrôler le sink (mock)
pub fn get_handle(&self) -> MpdHandle {
MpdHandle {
node_id: self.node_id.clone(),
}
}
}
/// Handle pour contrôler le MpdSink
///
/// Permet d'envoyer des commandes de contrôle au serveur MPD
#[derive(Clone)]
pub struct MpdHandle {
node_id: String,
}
impl MpdHandle {
/// Commande play (mock)
pub async fn play(&self) -> Result<(), AudioError> {
println!("[{}] MPD command: play", self.node_id);
Ok(())
}
/// Commande pause (mock)
pub async fn pause(&self) -> Result<(), AudioError> {
println!("[{}] MPD command: pause", self.node_id);
Ok(())
}
/// Commande stop (mock)
pub async fn stop(&self) -> Result<(), AudioError> {
println!("[{}] MPD command: stop", self.node_id);
Ok(())
}
/// Change le volume MPD (0-100) (mock)
pub async fn set_volume(&self, volume: u8) -> Result<(), AudioError> {
let clamped = volume.min(100);
println!("[{}] MPD command: setvol {}", self.node_id, clamped);
Ok(())
}
}
/// Statistiques du MpdSink
#[derive(Debug, Clone)]
pub struct MpdStats {
pub node_id: String,
pub server_address: String,
pub chunks_sent: u64,
pub total_samples: u64,
pub total_duration_sec: f64,
}
impl MpdStats {
pub fn new(node_id: String, server_address: String) -> Self {
Self {
node_id,
server_address,
chunks_sent: 0,
total_samples: 0,
total_duration_sec: 0.0,
}
}
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
self.chunks_sent += 1;
self.total_samples += chunk.len() as u64;
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate as f64;
}
pub fn finalize(&mut self) {
// Calculs finaux si nécessaire
}
pub fn display(&self) {
println!("\n=== MPD Sink Statistics: {} ===", self.node_id);
println!("Server: {}", self.server_address);
println!("Chunks sent: {}", self.chunks_sent);
println!("Total samples: {}", self.total_samples);
println!("Total duration: {:.3} sec", self.total_duration_sec);
println!("===============================\n");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_mpd_sink_basic() {
let config = MpdConfig {
host: "localhost".to_string(),
port: 6600,
..Default::default()
};
let (sink, tx) = MpdSink::new("test".to_string(), config, 10);
let handle = tokio::spawn(async move { sink.run().await });
// Envoyer quelques chunks
for i in 0..5 {
let chunk = AudioChunk::new(i, vec![0.5; 1000], vec![0.5; 1000], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
}
drop(tx);
let stats = handle.await.unwrap().unwrap();
assert_eq!(stats.chunks_sent, 5);
assert_eq!(stats.server_address, "localhost:6600");
}
#[tokio::test]
async fn test_mpd_handle() {
let config = MpdConfig::default();
let (sink, _tx) = MpdSink::new("test".to_string(), config, 10);
let handle = sink.get_handle();
// Tester les commandes (mock)
handle.play().await.unwrap();
handle.pause().await.unwrap();
handle.set_volume(75).await.unwrap();
handle.stop().await.unwrap();
}
}

View File

@@ -0,0 +1,201 @@
use crate::{nodes::AudioError, AudioChunk};
use std::sync::Arc;
use tokio::sync::mpsc;
/// SinkNode - Node terminal qui consomme les chunks audio
///
/// Version mock pour tests et logging
pub struct SinkNode {
rx: mpsc::Receiver<Arc<AudioChunk>>,
name: String,
}
impl SinkNode {
pub fn new(name: String, channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self { rx, name };
(node, tx)
}
/// Version silencieuse - consomme les chunks sans action
pub async fn run_silent(mut self) -> Result<(), AudioError> {
while let Some(_chunk) = self.rx.recv().await {
// Ne rien faire, juste consommer
}
Ok(())
}
/// Version avec logging
pub async fn run_with_logging(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
println!(
"[{}] Received chunk #{} - {} samples @ {} Hz",
self.name,
chunk.order,
chunk.len(),
chunk.sample_rate
);
}
Ok(())
}
/// Version avec statistiques
pub async fn run_with_stats(mut self) -> Result<SinkStats, AudioError> {
let mut stats = SinkStats::new(self.name.clone());
while let Some(chunk) = self.rx.recv().await {
stats.process_chunk(&chunk);
}
Ok(stats)
}
/// Version mock pour écriture dans un fichier (simule l'écriture)
pub async fn run_mock_file_writer(mut self) -> Result<usize, AudioError> {
let mut total_samples = 0;
while let Some(chunk) = self.rx.recv().await {
total_samples += chunk.len();
// Simuler l'écriture avec un petit délai
tokio::time::sleep(tokio::time::Duration::from_micros(10)).await;
}
Ok(total_samples)
}
}
/// Statistiques collectées par un SinkNode
#[derive(Debug, Clone)]
pub struct SinkStats {
pub name: String,
pub chunks_received: u64,
pub total_samples: u64,
pub total_duration_sec: f64,
pub peak_left: f32,
pub peak_right: f32,
pub rms_left: f64,
pub rms_right: f64,
}
impl SinkStats {
pub fn new(name: String) -> Self {
Self {
name,
chunks_received: 0,
total_samples: 0,
total_duration_sec: 0.0,
peak_left: 0.0,
peak_right: 0.0,
rms_left: 0.0,
rms_right: 0.0,
}
}
pub fn process_chunk(&mut self, chunk: &AudioChunk) {
self.chunks_received += 1;
self.total_samples += chunk.len() as u64;
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate as f64;
// Calculer les peaks
for &sample in chunk.left.iter() {
if sample.abs() > self.peak_left {
self.peak_left = sample.abs();
}
}
for &sample in chunk.right.iter() {
if sample.abs() > self.peak_right {
self.peak_right = sample.abs();
}
}
// Calculer RMS (moyenne des carrés)
let sum_squares_left: f64 = chunk.left.iter().map(|&x| (x * x) as f64).sum();
let sum_squares_right: f64 = chunk.right.iter().map(|&x| (x * x) as f64).sum();
self.rms_left = ((self.rms_left.powi(2)
* (self.total_samples - chunk.len() as u64) as f64
+ sum_squares_left)
/ self.total_samples as f64)
.sqrt();
self.rms_right = ((self.rms_right.powi(2)
* (self.total_samples - chunk.len() as u64) as f64
+ sum_squares_right)
/ self.total_samples as f64)
.sqrt();
}
pub fn display(&self) {
println!("\n=== Sink Statistics: {} ===", self.name);
println!("Chunks received: {}", self.chunks_received);
println!("Total samples: {}", self.total_samples);
println!("Total duration: {:.3} sec", self.total_duration_sec);
println!("Peak L/R: {:.3} / {:.3}", self.peak_left, self.peak_right);
println!("RMS L/R: {:.3} / {:.3}", self.rms_left, self.rms_right);
println!("========================\n");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_sink_node_silent() {
let (node, tx) = SinkNode::new("test".to_string(), 10);
let handle = tokio::spawn(async move { node.run_silent().await });
// Envoyer quelques chunks
for i in 0..3 {
let chunk = AudioChunk::new(i, vec![0.0; 100], vec![0.0; 100], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
}
drop(tx);
handle.await.unwrap().unwrap();
}
#[tokio::test]
async fn test_sink_node_stats() {
let (node, tx) = SinkNode::new("test".to_string(), 10);
let handle = tokio::spawn(async move { node.run_with_stats().await });
// Envoyer des chunks avec signal connu
for i in 0..3 {
let chunk = AudioChunk::new(i, vec![1.0; 1000], vec![0.5; 1000], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
}
drop(tx);
let stats = handle.await.unwrap().unwrap();
assert_eq!(stats.chunks_received, 3);
assert_eq!(stats.total_samples, 3000);
assert_eq!(stats.peak_left, 1.0);
assert_eq!(stats.peak_right, 0.5);
assert!((stats.rms_left - 1.0).abs() < 0.001);
assert!((stats.rms_right - 0.5).abs() < 0.001);
}
#[tokio::test]
async fn test_sink_node_file_writer() {
let (node, tx) = SinkNode::new("writer".to_string(), 10);
let handle = tokio::spawn(async move { node.run_mock_file_writer().await });
// Envoyer des chunks
for i in 0..5 {
let chunk = AudioChunk::new(i, vec![0.0; 100], vec![0.0; 100], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
}
drop(tx);
let total_samples = handle.await.unwrap().unwrap();
assert_eq!(total_samples, 500);
}
}

View File

@@ -0,0 +1,168 @@
use crate::{
nodes::{AudioError, MultiSubscriberNode},
AudioChunk,
};
use std::sync::Arc;
use tokio::sync::mpsc;
/// SourceNode - Génère ou lit des chunks audio depuis une source
///
/// Ce node est la source du pipeline. Version mock pour tests.
pub struct SourceNode {
subscribers: MultiSubscriberNode,
}
impl SourceNode {
pub fn new() -> Self {
Self {
subscribers: MultiSubscriberNode::new(),
}
}
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
/// Génère un chunk de test avec une forme d'onde sinusoïdale
pub fn generate_test_chunk(
order: u64,
size: usize,
sample_rate: u32,
frequency: f32,
) -> AudioChunk {
let mut left = Vec::with_capacity(size);
let mut right = Vec::with_capacity(size);
for i in 0..size {
let t = (order * size as u64 + i as u64) as f32 / sample_rate as f32;
let sample = (2.0 * std::f32::consts::PI * frequency * t).sin();
left.push(sample);
right.push(sample * 0.8); // Légèrement différent pour la stéréo
}
AudioChunk::new(order, left, right, sample_rate)
}
/// Génère et envoie des chunks de test
pub async fn generate_chunks(
&self,
count: u64,
chunk_size: usize,
sample_rate: u32,
frequency: f32,
) -> Result<(), AudioError> {
for i in 0..count {
let chunk = Self::generate_test_chunk(i, chunk_size, sample_rate, frequency);
self.subscribers.push(Arc::new(chunk)).await?;
}
Ok(())
}
/// Génère des chunks silencieux
pub async fn generate_silence(
&self,
count: u64,
chunk_size: usize,
sample_rate: u32,
) -> Result<(), AudioError> {
for i in 0..count {
let chunk =
AudioChunk::new(i, vec![0.0; chunk_size], vec![0.0; chunk_size], sample_rate);
self.subscribers.push(Arc::new(chunk)).await?;
}
Ok(())
}
/// Version streaming : génère des chunks continuellement avec délai
pub async fn stream_chunks(
&self,
chunk_size: usize,
sample_rate: u32,
frequency: f32,
duration_ms: u64,
) -> Result<(), AudioError> {
let chunk_duration_ms = (chunk_size as f64 / sample_rate as f64 * 1000.0) as u64;
let mut order = 0u64;
let start = tokio::time::Instant::now();
let duration = tokio::time::Duration::from_millis(duration_ms);
while start.elapsed() < duration {
let chunk = Self::generate_test_chunk(order, chunk_size, sample_rate, frequency);
self.subscribers.push(Arc::new(chunk)).await?;
order += 1;
// Attendre pour simuler le timing réel
tokio::time::sleep(tokio::time::Duration::from_millis(chunk_duration_ms)).await;
}
Ok(())
}
}
impl Default for SourceNode {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_source_node_generation() {
let mut source = SourceNode::new();
let (tx, mut rx) = mpsc::channel(10);
source.add_subscriber(tx);
// Générer 3 chunks
source.generate_chunks(3, 100, 48000, 440.0).await.unwrap();
// Vérifier la réception
for i in 0..3 {
let chunk = rx.recv().await.unwrap();
assert_eq!(chunk.order, i);
assert_eq!(chunk.len(), 100);
assert_eq!(chunk.sample_rate, 48000);
}
}
#[test]
fn test_sine_wave_generation() {
let chunk = SourceNode::generate_test_chunk(0, 48000, 48000, 440.0);
// Vérifier qu'on a bien une sinusoïde
// À 440 Hz avec 48000 samples/s, on devrait avoir 440 cycles
let left = &*chunk.left;
// Trouver les passages par zéro
let mut zero_crossings = 0;
for i in 1..left.len() {
if (left[i - 1] < 0.0 && left[i] >= 0.0) || (left[i - 1] >= 0.0 && left[i] < 0.0) {
zero_crossings += 1;
}
}
// 440 cycles = 880 passages par zéro (approximativement)
assert!(zero_crossings > 850 && zero_crossings < 910);
}
#[tokio::test]
async fn test_source_node_silence() {
let mut source = SourceNode::new();
let (tx, mut rx) = mpsc::channel(10);
source.add_subscriber(tx);
source.generate_silence(2, 100, 48000).await.unwrap();
for _ in 0..2 {
let chunk = rx.recv().await.unwrap();
assert!(chunk.left.iter().all(|&x| x == 0.0));
assert!(chunk.right.iter().all(|&x| x == 0.0));
}
}
}

View File

@@ -0,0 +1,284 @@
use crate::{
nodes::{AudioError, MultiSubscriberNode},
AudioChunk,
};
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
/// TimerNode - Node passthrough qui calcule la position temporelle
///
/// Ce node ne modifie pas les données audio, il les passe directement
/// aux abonnés tout en maintenant un compteur de samples pour calculer
/// la position en secondes.
///
/// # Fonctionnement
///
/// Pour chaque chunk reçu:
/// 1. Incrémente `elapsed_samples += chunk.len()`
/// 2. Calcule `position_sec = elapsed_samples / sample_rate`
/// 3. Push le chunk (sans modification) vers les abonnés
///
/// # Utilisation
///
/// Le TimerNode fournit un [`TimerHandle`] qui permet de lire la position
/// depuis d'autres threads/tasks sans bloquer le pipeline.
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::TimerNode;
///
/// #[tokio::main]
/// async fn main() {
/// let (mut timer, timer_tx) = TimerNode::new(10);
/// let handle = timer.get_position_handle();
///
/// tokio::spawn(async move {
/// timer.run().await.unwrap();
/// });
///
/// // Lire la position depuis un autre thread
/// let position = handle.position_sec().await;
/// println!("Position: {:.2} sec", position);
/// }
/// ```
pub struct TimerNode {
rx: mpsc::Receiver<Arc<AudioChunk>>,
subscribers: MultiSubscriberNode,
elapsed_samples: Arc<RwLock<u64>>,
current_sample_rate: Arc<RwLock<u32>>,
}
impl TimerNode {
/// Crée un nouveau TimerNode
pub fn new(channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self {
rx,
subscribers: MultiSubscriberNode::new(),
elapsed_samples: Arc::new(RwLock::new(0)),
current_sample_rate: Arc::new(RwLock::new(48000)), // Default
};
(node, tx)
}
/// Ajoute un abonné
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
/// Retourne la position actuelle en secondes
pub async fn position_sec(&self) -> f64 {
let elapsed = *self.elapsed_samples.read().await;
let sample_rate = *self.current_sample_rate.read().await;
elapsed as f64 / sample_rate as f64
}
/// Retourne le nombre total d'échantillons écoulés
pub async fn elapsed_samples(&self) -> u64 {
*self.elapsed_samples.read().await
}
/// Reset le compteur
pub async fn reset(&self) {
let mut elapsed = self.elapsed_samples.write().await;
*elapsed = 0;
}
/// Démarre la boucle de traitement du TimerNode
pub async fn run(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
// Mettre à jour le sample rate si nécessaire
{
let mut sr = self.current_sample_rate.write().await;
if *sr != chunk.sample_rate {
*sr = chunk.sample_rate;
}
}
// Incrémenter le compteur d'échantillons
{
let mut elapsed = self.elapsed_samples.write().await;
*elapsed += chunk.len() as u64;
}
// Push immédiatement le même chunk vers les abonnés (passthrough)
self.subscribers.push(chunk).await?;
}
Ok(())
}
/// Version non-bloquante avec try_push
pub async fn run_nonblocking(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
{
let mut sr = self.current_sample_rate.write().await;
if *sr != chunk.sample_rate {
*sr = chunk.sample_rate;
}
}
{
let mut elapsed = self.elapsed_samples.write().await;
*elapsed += chunk.len() as u64;
}
self.subscribers.try_push(chunk).await?;
}
Ok(())
}
/// Retourne un handle pour lire la position depuis d'autres threads
pub fn get_position_handle(&self) -> TimerHandle {
TimerHandle {
elapsed_samples: self.elapsed_samples.clone(),
current_sample_rate: self.current_sample_rate.clone(),
}
}
}
/// Handle pour lire la position du TimerNode depuis d'autres threads
///
/// Ce handle peut être cloné et utilisé depuis plusieurs threads/tasks
/// pour monitorer la position de lecture sans bloquer le pipeline.
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::TimerNode;
///
/// #[tokio::main]
/// async fn main() {
/// let (mut timer, _tx) = TimerNode::new(10);
/// let handle = timer.get_position_handle();
/// let handle_clone = handle.clone();
///
/// // Utiliser depuis plusieurs tasks
/// tokio::spawn(async move {
/// loop {
/// let pos = handle_clone.position_sec().await;
/// println!("Position: {:.2}s", pos);
/// tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
/// }
/// });
/// }
/// ```
#[derive(Clone)]
pub struct TimerHandle {
elapsed_samples: Arc<RwLock<u64>>,
current_sample_rate: Arc<RwLock<u32>>,
}
impl TimerHandle {
/// Retourne la position actuelle en secondes
pub async fn position_sec(&self) -> f64 {
let elapsed = *self.elapsed_samples.read().await;
let sample_rate = *self.current_sample_rate.read().await;
elapsed as f64 / sample_rate as f64
}
/// Retourne le nombre total d'échantillons écoulés
pub async fn elapsed_samples(&self) -> u64 {
*self.elapsed_samples.read().await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_timer_node_position_calculation() {
let (mut node, tx) = TimerNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
let handle = node.get_position_handle();
// Spawn le node
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer 3 chunks de 1000 samples à 48000 Hz
for i in 0..3 {
let chunk = AudioChunk::new(i, vec![0.0; 1000], vec![0.0; 1000], 48000);
tx.send(Arc::new(chunk)).await.unwrap();
}
// Attendre que les chunks soient traités
for _ in 0..3 {
out_rx.recv().await.unwrap();
}
// Vérifier la position
let position = handle.position_sec().await;
let expected = 3000.0 / 48000.0; // 3 chunks * 1000 samples / 48000 Hz
assert!((position - expected).abs() < 0.0001);
let elapsed = handle.elapsed_samples().await;
assert_eq!(elapsed, 3000);
}
#[tokio::test]
async fn test_timer_node_passthrough() {
let (mut node, tx) = TimerNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer un chunk
let chunk = AudioChunk::new(42, vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0], 48000);
let chunk_arc = Arc::new(chunk);
tx.send(chunk_arc.clone()).await.unwrap();
// Recevoir le chunk
let received = out_rx.recv().await.unwrap();
// Vérifier que c'est le même Arc (pas de clone des données)
assert!(Arc::ptr_eq(&chunk_arc, &received));
assert_eq!(received.order, 42);
}
#[tokio::test]
async fn test_timer_node_sample_rate_change() {
let (mut node, tx) = TimerNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
let handle = node.get_position_handle();
tokio::spawn(async move {
node.run().await.unwrap();
});
// Chunk à 48000 Hz
let chunk1 = AudioChunk::new(0, vec![0.0; 48000], vec![0.0; 48000], 48000);
tx.send(Arc::new(chunk1)).await.unwrap();
out_rx.recv().await.unwrap();
// Après 48000 samples à 48000 Hz = 1 seconde
let pos1 = handle.position_sec().await;
assert!((pos1 - 1.0).abs() < 0.0001);
// Chunk à 96000 Hz
let chunk2 = AudioChunk::new(1, vec![0.0; 96000], vec![0.0; 96000], 96000);
tx.send(Arc::new(chunk2)).await.unwrap();
out_rx.recv().await.unwrap();
// Position calculée avec le nouveau sample rate
let pos2 = handle.position_sec().await;
let expected = (48000.0 + 96000.0) / 96000.0;
assert!((pos2 - expected).abs() < 0.0001);
}
}

View File

@@ -0,0 +1,358 @@
//! Volume nodes - Contrôle du volume audio
//!
//! Ce module fournit des nodes pour ajuster le volume du flux audio,
//! avec support du volume master/secondaire et notification des changements.
use crate::{
events::{EventPublisher, VolumeChangeEvent},
nodes::{AudioError, MultiSubscriberNode},
AudioChunk,
};
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
/// VolumeNode - Applique un gain au flux audio (contrôle software)
///
/// Ce node modifie le champ `gain` de chaque `AudioChunk` qui le traverse.
/// Le gain est multiplié avec le gain existant du chunk, permettant ainsi
/// une chaîne de contrôles de volume.
///
/// # Caractéristiques
///
/// - Thread-safe : le volume peut être modifié pendant l'exécution via `set_volume`
/// - Notification : émet des événements `VolumeChangeEvent` lors des changements
/// - Master/Slave : peut s'abonner à un volume master pour synchronisation
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::VolumeNode;
///
/// #[tokio::main]
/// async fn main() {
/// let (volume_node, volume_tx) = VolumeNode::new("Room 1".to_string(), 0.8, 10);
///
/// // Modifier le volume pendant l'exécution
/// let handle = volume_node.get_handle();
/// tokio::spawn(async move {
/// tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
/// handle.set_volume(0.5).await;
/// });
///
/// tokio::spawn(async move { volume_node.run().await.unwrap() });
/// }
/// ```
pub struct VolumeNode {
/// Channel pour recevoir les chunks audio
rx: mpsc::Receiver<Arc<AudioChunk>>,
/// Subscribers pour les chunks modifiés
subscribers: MultiSubscriberNode,
/// Volume courant (partagé via RwLock pour lecture/écriture thread-safe)
volume: Arc<RwLock<f32>>,
/// Publisher pour les événements de changement de volume
volume_publisher: EventPublisher<VolumeChangeEvent>,
/// Identifiant unique du node (pour traçabilité)
node_id: String,
/// Receiver pour les événements de volume master (optionnel)
master_volume_rx: Option<mpsc::Receiver<VolumeChangeEvent>>,
}
impl VolumeNode {
/// Crée un nouveau VolumeNode
///
/// # Arguments
///
/// * `node_id` - Identifiant unique du node
/// * `initial_volume` - Volume initial (0.0 à 1.0)
/// * `channel_size` - Taille du buffer du channel
pub fn new(
node_id: String,
initial_volume: f32,
channel_size: usize,
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self {
rx,
subscribers: MultiSubscriberNode::new(),
volume: Arc::new(RwLock::new(initial_volume)),
volume_publisher: EventPublisher::new(),
node_id,
master_volume_rx: None,
};
(node, tx)
}
/// Ajoute un subscriber pour recevoir les chunks audio modifiés
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
/// Ajoute un subscriber pour les événements de changement de volume
pub fn subscribe_volume_events(&mut self, tx: mpsc::Sender<VolumeChangeEvent>) {
self.volume_publisher.subscribe(tx);
}
/// Configure ce node pour écouter un volume master
///
/// Le node appliquera à la fois son volume local ET le volume master reçu.
pub fn set_master_volume_source(&mut self, rx: mpsc::Receiver<VolumeChangeEvent>) {
self.master_volume_rx = Some(rx);
}
/// Retourne un handle pour contrôler le volume depuis un autre contexte
pub fn get_handle(&self) -> VolumeHandle {
VolumeHandle {
volume: self.volume.clone(),
node_id: self.node_id.clone(),
publisher: Arc::new(RwLock::new(self.volume_publisher.clone())),
}
}
/// Démarre la boucle de traitement du VolumeNode
pub async fn run(mut self) -> Result<(), AudioError> {
let mut master_volume = 1.0f32;
loop {
tokio::select! {
// Recevoir les chunks audio
chunk_opt = self.rx.recv() => {
match chunk_opt {
Some(chunk) => {
let local_volume = *self.volume.read().await;
let total_volume = local_volume * master_volume;
// Créer un nouveau chunk avec le gain modifié
let modified_chunk = chunk.with_modified_gain(total_volume);
// Envoyer aux subscribers
self.subscribers.push(Arc::new(modified_chunk)).await?;
}
None => {
// Channel fermé, terminer
break;
}
}
}
// Recevoir les mises à jour du volume master (si configuré)
master_event_opt = async {
if let Some(ref mut rx) = self.master_volume_rx {
rx.recv().await
} else {
// Bloquer indéfiniment si pas de master
std::future::pending().await
}
} => {
if let Some(event) = master_event_opt {
master_volume = event.volume;
// Optionnel : re-publier l'événement combiné
let local_volume = *self.volume.read().await;
let combined_event = VolumeChangeEvent {
volume: local_volume * master_volume,
source_node_id: self.node_id.clone(),
};
self.volume_publisher.publish(combined_event).await;
}
}
}
}
Ok(())
}
}
/// Handle pour contrôler un VolumeNode depuis un autre contexte
///
/// Ce handle permet de modifier le volume et de notifier les subscribers
/// sans avoir accès direct au node.
#[derive(Clone)]
pub struct VolumeHandle {
volume: Arc<RwLock<f32>>,
node_id: String,
publisher: Arc<RwLock<EventPublisher<VolumeChangeEvent>>>,
}
impl VolumeHandle {
/// Modifie le volume
///
/// # Arguments
///
/// * `new_volume` - Nouveau volume (0.0 à 1.0)
pub async fn set_volume(&self, new_volume: f32) {
let clamped = new_volume.clamp(0.0, 1.0);
*self.volume.write().await = clamped;
// Publier l'événement de changement
let event = VolumeChangeEvent {
volume: clamped,
source_node_id: self.node_id.clone(),
};
self.publisher.read().await.publish(event).await;
}
/// Obtient le volume courant
pub async fn get_volume(&self) -> f32 {
*self.volume.read().await
}
/// Augmente le volume de manière relative
pub async fn adjust_volume(&self, delta: f32) {
let current = *self.volume.read().await;
self.set_volume(current + delta).await;
}
}
/// HardwareVolumeNode - Contrôle matériel du volume
///
/// Ce node simule un contrôle hardware du volume. Dans une implémentation réelle,
/// il communiquerait avec le driver audio pour ajuster le volume matériel.
///
/// Pour cette version, il agit de manière similaire à `VolumeNode` mais pourrait
/// être étendu pour utiliser des APIs système spécifiques.
pub struct HardwareVolumeNode {
inner: VolumeNode,
}
impl HardwareVolumeNode {
/// Crée un nouveau HardwareVolumeNode
pub fn new(
node_id: String,
initial_volume: f32,
channel_size: usize,
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (inner, tx) = VolumeNode::new(node_id, initial_volume, channel_size);
(Self { inner }, tx)
}
/// Ajoute un subscriber
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.inner.add_subscriber(tx);
}
/// Obtient un handle pour contrôler le volume
pub fn get_handle(&self) -> VolumeHandle {
self.inner.get_handle()
}
/// Démarre la boucle de traitement
pub async fn run(self) -> Result<(), AudioError> {
// Dans une vraie implémentation, on communiquerait avec le hardware ici
// Pour l'instant, délègue au VolumeNode standard
self.inner.run().await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_volume_node_basic() {
let (mut node, tx) = VolumeNode::new("test".to_string(), 0.5, 10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
let handle = tokio::spawn(async move { node.run().await });
// Envoyer un chunk avec gain 1.0
let chunk = AudioChunk::with_gain(0, vec![1.0; 100], vec![1.0; 100], 48000, 1.0);
tx.send(Arc::new(chunk)).await.unwrap();
// Recevoir le chunk modifié
let modified = out_rx.recv().await.unwrap();
assert!((modified.gain - 0.5).abs() < f32::EPSILON);
drop(tx);
handle.await.unwrap().unwrap();
}
#[tokio::test]
async fn test_volume_handle() {
let (node, tx) = VolumeNode::new("test".to_string(), 1.0, 10);
let handle = node.get_handle();
tokio::spawn(async move { node.run().await });
// Modifier le volume via le handle
handle.set_volume(0.3).await;
let volume = handle.get_volume().await;
assert!((volume - 0.3).abs() < f32::EPSILON);
drop(tx);
}
#[tokio::test]
async fn test_volume_events() {
let (mut node, tx) = VolumeNode::new("test".to_string(), 1.0, 10);
let (event_tx, mut event_rx) = mpsc::channel(10);
node.subscribe_volume_events(event_tx);
let handle = node.get_handle();
tokio::spawn(async move { node.run().await });
// Changer le volume
handle.set_volume(0.7).await;
// Vérifier l'événement
let event = event_rx.recv().await.unwrap();
assert!((event.volume - 0.7).abs() < f32::EPSILON);
assert_eq!(event.source_node_id, "test");
drop(tx);
}
#[tokio::test]
async fn test_master_slave_volume() {
// Créer le master
let (mut master, master_tx) = VolumeNode::new("master".to_string(), 1.0, 10);
let (master_event_tx, master_event_rx) = mpsc::channel(10);
master.subscribe_volume_events(master_event_tx);
let master_handle = master.get_handle();
// Créer le slave
let (mut slave, slave_tx) = VolumeNode::new("slave".to_string(), 0.8, 10);
slave.set_master_volume_source(master_event_rx);
let (out_tx, mut out_rx) = mpsc::channel(10);
slave.add_subscriber(out_tx);
tokio::spawn(async move { master.run().await });
tokio::spawn(async move { slave.run().await });
// Envoyer un chunk au slave
let chunk = AudioChunk::with_gain(0, vec![1.0; 100], vec![1.0; 100], 48000, 1.0);
slave_tx.send(Arc::new(chunk)).await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
// Modifier le volume master
master_handle.set_volume(0.5).await;
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
// Envoyer un autre chunk
let chunk2 = AudioChunk::with_gain(1, vec![1.0; 100], vec![1.0; 100], 48000, 1.0);
slave_tx.send(Arc::new(chunk2)).await.unwrap();
// Le deuxième chunk devrait avoir un gain de 0.8 * 0.5 = 0.4
let _first = out_rx.recv().await.unwrap(); // gain = 0.8
let second = out_rx.recv().await.unwrap(); // gain = 0.4
assert!((second.gain - 0.4).abs() < 0.01);
drop(master_tx);
drop(slave_tx);
}
}

View File

@@ -0,0 +1,161 @@
//! Tests d'intégration pour le pipeline audio complet
use pmoaudio::{BufferNode, DecoderNode, DspNode, SinkNode, SourceNode, TimerNode};
#[tokio::test]
async fn test_complete_pipeline() {
// Créer un pipeline complet : Source → Decoder → DSP → Buffer → Timer → Sink
let (mut decoder, decoder_tx) = DecoderNode::new(10);
let (mut dsp, dsp_tx) = DspNode::new(10, 0.5); // Gain de 0.5
let (mut buffer, buffer_tx) = BufferNode::new(50, 10);
let (mut timer, timer_tx) = TimerNode::new(10);
let (sink, sink_tx) = SinkNode::new("Integration Test".to_string(), 10);
// Connecter le pipeline
decoder.add_subscriber(dsp_tx);
dsp.add_subscriber(buffer_tx);
buffer.add_next_subscriber(timer_tx);
timer.add_subscriber(sink_tx);
let timer_handle = timer.get_position_handle();
// Spawn tous les nodes
tokio::spawn(async move { decoder.run_passthrough().await.unwrap() });
tokio::spawn(async move { dsp.run().await.unwrap() });
tokio::spawn(async move { buffer.run().await.unwrap() });
tokio::spawn(async move { timer.run().await.unwrap() });
let sink_handle = tokio::spawn(async move { sink.run_with_stats().await.unwrap() });
// Générer des chunks
tokio::spawn(async move {
let mut source = SourceNode::new();
source.add_subscriber(decoder_tx);
source
.generate_chunks(10, 4800, 48000, 440.0)
.await
.unwrap();
});
// Attendre la fin
let stats = sink_handle.await.unwrap();
// Vérifier les résultats
assert_eq!(stats.chunks_received, 10);
assert_eq!(stats.total_samples, 48000);
// Vérifier que le gain a été appliqué (peak devrait être ~0.5)
assert!(stats.peak_left < 0.51 && stats.peak_left > 0.49);
// Vérifier la position
let position = timer_handle.position_sec().await;
assert!((position - 1.0).abs() < 0.01); // ~1 seconde
}
#[tokio::test]
async fn test_multiroom_buffering() {
// Tester le BufferNode avec plusieurs abonnés avec offsets
let (buffer, buffer_tx) = BufferNode::new(50, 20);
let (sink1, sink1_tx) = SinkNode::new("Room 1".to_string(), 20);
let (sink2, sink2_tx) = SinkNode::new("Room 2".to_string(), 20);
let (sink3, sink3_tx) = SinkNode::new("Room 3".to_string(), 20);
buffer.add_subscriber_with_offset(sink1_tx, 0).await;
buffer.add_subscriber_with_offset(sink2_tx, 3).await;
buffer.add_subscriber_with_offset(sink3_tx, 6).await;
tokio::spawn(async move { buffer.run().await.unwrap() });
let sink1_handle = tokio::spawn(async move { sink1.run_with_stats().await.unwrap() });
let sink2_handle = tokio::spawn(async move { sink2.run_with_stats().await.unwrap() });
let sink3_handle = tokio::spawn(async move { sink3.run_with_stats().await.unwrap() });
tokio::spawn(async move {
let mut source = SourceNode::new();
source.add_subscriber(buffer_tx);
source
.generate_chunks(20, 1000, 48000, 440.0)
.await
.unwrap();
});
let stats1 = sink1_handle.await.unwrap();
let stats2 = sink2_handle.await.unwrap();
let stats3 = sink3_handle.await.unwrap();
// Room 1 devrait avoir tous les chunks
assert_eq!(stats1.chunks_received, 20);
// Room 2 devrait avoir 3 chunks de moins
assert_eq!(stats2.chunks_received, 17);
// Room 3 devrait avoir 6 chunks de moins
assert_eq!(stats3.chunks_received, 14);
}
#[tokio::test]
async fn test_timer_accuracy() {
// Tester la précision du TimerNode
let (mut timer, timer_tx) = TimerNode::new(10);
let (sink, sink_tx) = SinkNode::new("Timer Test".to_string(), 10);
timer.add_subscriber(sink_tx);
let timer_handle = timer.get_position_handle();
tokio::spawn(async move { timer.run().await.unwrap() });
let sink_handle = tokio::spawn(async move { sink.run_silent().await.unwrap() });
tokio::spawn(async move {
let mut source = SourceNode::new();
source.add_subscriber(timer_tx);
// 48000 samples à 48kHz = 1 seconde
source
.generate_chunks(1, 48000, 48000, 440.0)
.await
.unwrap();
});
sink_handle.await.unwrap();
let position = timer_handle.position_sec().await;
let samples = timer_handle.elapsed_samples().await;
assert_eq!(samples, 48000);
assert!((position - 1.0).abs() < 0.0001);
}
#[tokio::test]
async fn test_arc_sharing() {
// Vérifier que les chunks sont bien partagés via Arc sans copie
let (mut timer, timer_tx) = TimerNode::new(10);
let (sink1, sink1_tx) = SinkNode::new("Sink1".to_string(), 10);
let (sink2, sink2_tx) = SinkNode::new("Sink2".to_string(), 10);
timer.add_subscriber(sink1_tx);
timer.add_subscriber(sink2_tx);
tokio::spawn(async move { timer.run().await.unwrap() });
let sink1_handle = tokio::spawn(async move { sink1.run_with_stats().await.unwrap() });
let sink2_handle = tokio::spawn(async move { sink2.run_with_stats().await.unwrap() });
tokio::spawn(async move {
let mut source = SourceNode::new();
source.add_subscriber(timer_tx);
source.generate_silence(5, 1000, 48000).await.unwrap();
});
let stats1 = sink1_handle.await.unwrap();
let stats2 = sink2_handle.await.unwrap();
// Les deux sinks devraient avoir reçu les mêmes chunks
assert_eq!(stats1.chunks_received, 5);
assert_eq!(stats2.chunks_received, 5);
assert_eq!(stats1.total_samples, stats2.total_samples);
}

51
pmoaudiocache/Cargo.toml Normal file
View File

@@ -0,0 +1,51 @@
[package]
name = "pmoaudiocache"
version = "0.1.0"
edition = "2021"
[dependencies]
# Cache générique
pmocache = { path = "../pmocache" }
# DIDL-Lite pour UPnP
pmodidl = { path = "../pmodidl" }
# Base de données
rusqlite = { version = "0.37", features = ["bundled"] }
chrono = "0.4"
# Métadonnées audio
lofty = "0.22"
# Encodage/décodage audio
symphonia = { version = "0.5", features = ["all"] }
claxon = "0.4" # Décodeur FLAC
flacenc = "0.4" # Encodeur FLAC
futures-util = "0.3" # Pour le streaming
# HTTP client
reqwest = { version = "0.12", features = ["blocking"] }
# Utilitaires
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
quick-xml = { version = "0.37", features = ["serialize"] }
# Async
tokio = { version = "1.0", features = ["full"] }
# Serveur HTTP (optionnel pour l'extension)
pmoserver = { path = "../pmoserver", optional = true }
pmoconfig = { path = "../pmoconfig", optional = true }
axum = { version = "0.8", optional = true }
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
tracing = "0.1.41"
[dev-dependencies]
tracing-subscriber = "0.3"
[features]
default = ["pmoserver"]
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/pmoserver", "pmocache/openapi"]

View File

@@ -0,0 +1,51 @@
use pmoaudiocache::cache;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.init();
let cache_dir = "/tmp/test_audio_cache_debug";
std::fs::create_dir_all(cache_dir)?;
println!("Creating cache in: {}", cache_dir);
let cache = Arc::new(cache::new_cache(cache_dir, 100)?);
// URL MP3 de test - petit fichier
let test_url = "https://fr.getsamplefiles.com/download/mp3/sample-3.mp3";
println!("\nDownloading: {}", test_url);
let pk = cache::add_with_metadata_extraction(&cache, test_url, Some("test")).await?;
println!("\nPK: {}", pk);
let file_path = cache.file_path(&pk);
println!("File path: {}", file_path.display());
// Vérifier le format
let data = std::fs::read(&file_path)?;
if data.len() >= 4 {
let header = &data[0..4];
if header == b"fLaC" {
println!("✓ File is FLAC!");
} else if header[0..3] == *b"ID3"
|| (header.len() >= 2 && header[0] == 0xFF && (header[1] & 0xE0) == 0xE0)
{
println!("✗ File is still MP3!");
println!(
" Header: {:02X} {:02X} {:02X} {:02X}",
header[0], header[1], header[2], header[3]
);
} else {
println!("? Unknown format");
println!(
" Header: {:02X} {:02X} {:02X} {:02X}",
header[0], header[1], header[2], header[3]
);
}
}
Ok(())
}

View File

@@ -0,0 +1,69 @@
//! Exemple de test pour la conversion FLAC en streaming
//!
//! Cet exemple télécharge un fichier audio depuis une URL et le convertit
//! en FLAC en utilisant la fonction create_flac_transformer().
use pmoaudiocache::cache;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialiser le logger
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
// Créer un répertoire temporaire pour le cache
let cache_dir = "/tmp/test_audio_cache";
std::fs::create_dir_all(cache_dir)?;
println!("Création du cache audio avec conversion FLAC streaming...");
let cache = Arc::new(cache::new_cache(cache_dir, 100)?);
// URL de test - fichier audio de test public
// Note: Remplacez par une URL valide de votre choix
let test_url = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3";
println!("Téléchargement et conversion de: {}", test_url);
println!("Ceci va télécharger le fichier en streaming et le convertir en FLAC...");
// Ajouter le fichier au cache avec conversion FLAC
match cache::add_with_metadata_extraction(&cache, test_url, Some("test:streaming")).await {
Ok(pk) => {
println!("✓ Fichier converti avec succès!");
println!(" Clé primaire: {}", pk);
let file_path = cache.file_path(&pk);
println!(" Chemin: {}", file_path.display());
if let Ok(metadata) = std::fs::metadata(&file_path) {
println!(" Taille: {} bytes", metadata.len());
}
// Récupérer les métadonnées audio
match cache::get_metadata(&cache, &pk) {
Ok(metadata) => {
println!(" Métadonnées:");
if let Some(title) = &metadata.title {
println!(" Titre: {}", title);
}
if let Some(artist) = &metadata.artist {
println!(" Artiste: {}", artist);
}
if let Some(duration) = metadata.duration_secs {
println!(" Durée: {}s", duration);
}
}
Err(e) => println!(" Impossible de lire les métadonnées: {}", e),
}
}
Err(e) => {
eprintln!("✗ Erreur lors de la conversion: {}", e);
return Err(e);
}
}
println!("\nTest terminé avec succès!");
Ok(())
}

401
pmoaudiocache/src/cache.rs Normal file
View File

@@ -0,0 +1,401 @@
//! Module de gestion du cache audio avec conversion FLAC
//!
//! Ce module étend le cache générique de `pmocache` avec des fonctionnalités
//! spécifiques aux fichiers audio : conversion FLAC automatique et stockage
//! des métadonnées en JSON dans la base de données.
use anyhow::Result;
use pmocache::{CacheConfig, StreamTransformer};
use std::sync::Arc;
/// Configuration pour le cache audio
pub struct AudioConfig;
impl CacheConfig for AudioConfig {
fn file_extension() -> &'static str {
"flac"
}
fn table_name() -> &'static str {
"audio_tracks"
}
fn cache_type() -> &'static str {
"flac"
}
fn cache_name() -> &'static str {
"audio"
}
fn default_param() -> &'static str {
"orig"
}
}
/// Type alias pour le cache audio avec conversion FLAC
pub type Cache = pmocache::Cache<AudioConfig>;
/// Créateur de transformer FLAC
///
/// Convertit automatiquement tout fichier audio téléchargé en format FLAC
/// en traitant les données au vol, sans tout charger en mémoire.
///
/// # Workflow
///
/// 1. Télécharger les bytes par chunks depuis le stream HTTP
/// 2. Buffer temporaire pour accumuler les données nécessaires à Symphonia
/// 3. Décoder l'audio en PCM via Symphonia
/// 4. Encoder le PCM en FLAC progressivement via flacenc
/// 5. Écrire les frames FLAC directement dans le fichier
/// 6. Mettre à jour la progression après chaque chunk
///
/// Note: Bien que nous utilisions un buffer temporaire, celui-ci est géré
/// de manière efficace et les données FLAC sont écrites au fur et à mesure.
fn create_flac_transformer() -> StreamTransformer {
Box::new(|input, mut file, progress| {
Box::pin(async move {
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
// 1. Collecter tous les bytes du stream
// Note: Symphonia nécessite un MediaSource avec Read + Seek,
// ce qui n'est pas compatible avec un vrai streaming HTTP.
// Nous devons donc bufferiser les données.
let mut buffer = Vec::new();
let mut stream = input.into_byte_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
buffer.extend_from_slice(&chunk);
}
tracing::debug!(
"Downloaded {} bytes total, starting FLAC conversion",
buffer.len()
);
// 2. Si c'est déjà du FLAC, on l'écrit directement
if buffer.len() >= 4 && &buffer[0..4] == b"fLaC" {
tracing::debug!("Input is already FLAC, writing directly");
file.write_all(&buffer).await.map_err(|e| e.to_string())?;
file.flush().await.map_err(|e| e.to_string())?;
progress(buffer.len() as u64);
return Ok(());
}
tracing::debug!("Converting to FLAC with Symphonia + flacenc");
// 3. Décoder l'audio avec Symphonia
let (samples, channels, sample_rate, bits_per_sample) = {
use std::io::Cursor;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
let cursor = Cursor::new(buffer);
let mss = MediaSourceStream::new(Box::new(cursor), Default::default());
let hint = Hint::new();
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.map_err(|e| format!("Failed to probe format: {}", e))?;
let mut format = probed.format;
let track = format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| "No audio track found".to_string())?;
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| format!("Failed to create decoder: {}", e))?;
let channels = track
.codec_params
.channels
.ok_or_else(|| "No channel info".to_string())?
.count();
let sample_rate = track
.codec_params
.sample_rate
.ok_or_else(|| "No sample rate info".to_string())?;
let bits_per_sample = track.codec_params.bits_per_sample.unwrap_or(16);
let mut samples_i32 = Vec::new();
let track_id = track.id;
// Décoder tous les packets
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
Err(SymphoniaError::ResetRequired) => {
decoder.reset();
continue;
}
Err(SymphoniaError::IoError(e))
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
break;
}
Err(e) => return Err(format!("Decode error: {}", e)),
};
if packet.track_id() != track_id {
continue;
}
match decoder.decode(&packet) {
Ok(decoded) => {
let spec = *decoded.spec();
let duration = decoded.capacity() as u64;
// Convertir en i32 pour flacenc
// Note: Symphonia retourne des samples i32, nous devons les convertir
// en fonction du bits_per_sample réel
let mut sample_buf = SampleBuffer::<i32>::new(duration, spec);
sample_buf.copy_interleaved_ref(decoded);
samples_i32.extend_from_slice(sample_buf.samples());
}
Err(SymphoniaError::DecodeError(_)) => continue,
Err(e) => return Err(format!("Decode error: {}", e)),
}
}
if samples_i32.is_empty() {
return Err("No samples decoded".to_string());
}
tracing::debug!(
"Decoded {} samples (i32), {} channels, {} Hz, {} bits",
samples_i32.len(),
channels,
sample_rate,
bits_per_sample
);
// Normaliser les samples i32 vers la plage appropriée pour flacenc
// Symphonia retourne des samples i32 en pleine échelle (32 bits),
// nous devons les normaliser selon le bits_per_sample réel
let (normalized_samples, target_bits): (Vec<i32>, u32) = match bits_per_sample {
0..=16 => {
// Pour 16 bits ou moins, normaliser vers la plage i16
tracing::debug!("Normalizing to 16-bit");
let samples = samples_i32.iter().map(|&s| (s >> 16) as i32).collect();
(samples, 16)
}
17..=24 => {
// Pour 17-24 bits, normaliser vers la plage 24-bit
tracing::debug!("Normalizing to 24-bit");
let samples = samples_i32.iter().map(|&s| (s >> 8) as i32).collect();
(samples, 24)
}
_ => {
// Pour 25-32 bits, garder la pleine échelle i32
tracing::debug!("Keeping 32-bit");
(samples_i32, 32)
}
};
(normalized_samples, channels, sample_rate, target_bits)
};
tracing::debug!(
"Encoding to FLAC: {} samples, {} channels, {} Hz, {} bits",
samples.len(),
channels,
sample_rate,
bits_per_sample
);
// 4. Encoder en FLAC avec flacenc
// Note: L'encodage FLAC est une opération bloquante/CPU-intensive,
// donc nous l'exécutons dans un thread bloquant pour ne pas bloquer le runtime Tokio
let flac_data = tokio::task::spawn_blocking(move || {
use flacenc::bitsink::ByteSink;
use flacenc::component::BitRepr;
use flacenc::error::Verify;
let config = flacenc::config::Encoder::default()
.into_verified()
.map_err(|e| format!("FLAC config error: {:?}", e))?;
let source = flacenc::source::MemSource::from_samples(
&samples,
channels,
bits_per_sample as usize,
sample_rate as usize,
);
let flac_stream =
flacenc::encode_with_fixed_block_size(&config, source, config.block_size)
.map_err(|e| format!("FLAC encode error: {:?}", e))?;
let mut sink = ByteSink::new();
flac_stream
.write(&mut sink)
.map_err(|e| format!("FLAC write error: {:?}", e))?;
Ok::<Vec<u8>, String>(sink.into_inner())
})
.await
.map_err(|e| format!("Spawn blocking error: {}", e))??;
tracing::debug!("FLAC encoding complete: {} bytes", flac_data.len());
// 5. Écrire le fichier FLAC
file.write_all(&flac_data)
.await
.map_err(|e| e.to_string())?;
file.flush().await.map_err(|e| e.to_string())?;
// 6. Mettre à jour la progression finale
progress(flac_data.len() as u64);
Ok(())
})
})
}
/// Crée un cache audio avec conversion FLAC automatique
///
/// # Arguments
///
/// * `dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (nombre de pistes)
///
/// # Returns
///
/// Instance du cache configurée pour la conversion FLAC automatique
///
/// # Exemple
///
/// ```rust,no_run
/// use pmoaudiocache::cache;
///
/// let cache = cache::new_cache("./audio_cache", 1000).unwrap();
/// ```
pub fn new_cache(dir: &str, limit: usize) -> Result<Cache> {
let transformer_factory = Arc::new(|| create_flac_transformer());
Cache::with_transformer(dir, limit, Some(transformer_factory))
}
/// Ajoute une piste audio depuis une URL avec extraction et stockage des métadonnées
///
/// Cette fonction étend `add_from_url` du cache en ajoutant :
/// 1. Téléchargement et conversion FLAC (via transformer)
/// 2. Extraction et stockage des métadonnées en JSON dans la DB
///
/// # Arguments
///
/// * `cache` - Instance du cache
/// * `url` - URL du fichier audio
/// * `collection` - Collection optionnelle (ex: "pink_floyd:wish_you_were_here")
///
/// # Returns
///
/// Clé primaire (pk) du fichier ajouté
///
/// # Exemple
///
/// ```rust,no_run
/// use pmoaudiocache::cache;
///
/// # async fn example() -> anyhow::Result<()> {
/// let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080")?;
/// let pk = cache::add_with_metadata_extraction(
/// &cache,
/// "http://example.com/track.flac",
/// Some("artist:album")
/// ).await?;
/// # Ok(())
/// # }
/// ```
pub async fn add_with_metadata_extraction(
cache: &Cache,
url: &str,
collection: Option<&str>,
) -> Result<String> {
// Ajouter au cache (déclenche le download et la conversion)
let pk = cache.add_from_url(url, collection).await?;
// Attendre que le fichier soit téléchargé et converti
cache.wait_until_finished(&pk).await?;
// Lire le fichier FLAC pour extraire les métadonnées
let file_path = cache.file_path(&pk);
let flac_bytes = tokio::fs::read(&file_path).await?;
// Extraire les métadonnées
let metadata = crate::metadata::AudioMetadata::from_bytes(&flac_bytes)?;
// Sérialiser en JSON
let metadata_json = serde_json::to_string(&metadata)?;
// Stocker dans la DB
cache
.db
.update_metadata(&pk, &metadata_json)
.map_err(|e| anyhow::anyhow!("Database error: {}", e))?;
// Mettre à jour la collection si les métadonnées en fournissent une
if collection.is_none() {
if let Some(auto_collection) = metadata.collection_key() {
cache
.db
.add(&pk, url, Some(&auto_collection))
.map_err(|e| anyhow::anyhow!("Database error: {}", e))?;
}
}
Ok(pk)
}
/// Récupère les métadonnées audio d'un fichier en cache
///
/// # Arguments
///
/// * `cache` - Instance du cache
/// * `pk` - Clé primaire du fichier
///
/// # Returns
///
/// Les métadonnées audio désérialisées depuis le JSON stocké en DB
///
/// # Exemple
///
/// ```rust,no_run
/// use pmoaudiocache::cache;
///
/// # async fn example(cache: &pmoaudiocache::cache::Cache, pk: &str) -> anyhow::Result<()> {
/// let metadata = cache::get_metadata(cache, pk)?;
/// println!("Title: {:?}", metadata.title);
/// println!("Artist: {:?}", metadata.artist);
/// # Ok(())
/// # }
/// ```
pub fn get_metadata(cache: &Cache, pk: &str) -> Result<crate::metadata::AudioMetadata> {
let metadata_json = cache
.db
.get_metadata_json(pk)
.map_err(|e| anyhow::anyhow!("Database error: {}", e))?
.ok_or_else(|| anyhow::anyhow!("No metadata found for pk: {}", pk))?;
let metadata: crate::metadata::AudioMetadata = serde_json::from_str(&metadata_json)
.map_err(|e| anyhow::anyhow!("Metadata deserialization error: {}", e))?;
Ok(metadata)
}

150
pmoaudiocache/src/flac.rs Normal file
View File

@@ -0,0 +1,150 @@
//! Module de conversion audio en FLAC
//!
//! Ce module gère la conversion de divers formats audio vers FLAC
//! pour standardiser le stockage dans le cache.
use anyhow::{anyhow, Result};
use std::io::Cursor;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
/// Convertit des données audio en FLAC
///
/// Cette fonction accepte n'importe quel format audio supporté par Symphonia
/// et le convertit en FLAC pour un stockage standardisé.
///
/// # Arguments
///
/// * `data` - Données audio brutes (n'importe quel format)
/// * `extension` - Extension du fichier source (optionnel, aide à la détection)
///
/// # Returns
///
/// Données audio au format FLAC
///
/// # Exemple
///
/// ```rust,no_run
/// use pmoaudiocache::flac::convert_to_flac;
///
/// let mp3_data = std::fs::read("track.mp3").unwrap();
/// let flac_data = convert_to_flac(&mp3_data, Some("mp3")).unwrap();
/// ```
pub fn convert_to_flac(data: &[u8], extension: Option<&str>) -> Result<Vec<u8>> {
// Si c'est déjà du FLAC, on le retourne tel quel
if is_flac(data) {
return Ok(data.to_vec());
}
// Créer un MediaSource depuis les données (en clonant pour avoir 'static)
let data_owned = data.to_vec();
let cursor = Cursor::new(data_owned);
let mss = MediaSourceStream::new(Box::new(cursor), Default::default());
// Créer un hint si on a l'extension
let mut hint = Hint::new();
if let Some(ext) = extension {
hint.with_extension(ext);
}
// Prober le format
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.map_err(|e| anyhow!("Impossible de détecter le format audio: {}", e))?;
let mut format = probed.format;
// Obtenir le premier track audio
let track = format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| anyhow!("Aucune piste audio trouvée"))?;
// Créer un décodeur
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| anyhow!("Impossible de créer le décodeur: {}", e))?;
// Buffer pour stocker les samples décodés
let mut samples = Vec::new();
let track_id = track.id;
// Décoder tous les packets
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
Err(SymphoniaError::ResetRequired) => {
// Reset du décodeur requis
decoder.reset();
continue;
}
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
break;
}
Err(e) => return Err(anyhow!("Erreur lors de la lecture: {}", e)),
};
// Ignorer les packets qui ne sont pas de notre track
if packet.track_id() != track_id {
continue;
}
match decoder.decode(&packet) {
Ok(decoded) => {
// Convertir les samples en format standard
let spec = *decoded.spec();
let duration = decoded.capacity() as u64;
let mut sample_buf = SampleBuffer::<i16>::new(duration, spec);
sample_buf.copy_interleaved_ref(decoded);
samples.extend_from_slice(sample_buf.samples());
}
Err(SymphoniaError::DecodeError(_)) => continue,
Err(e) => return Err(anyhow!("Erreur de décodage: {}", e)),
}
}
if samples.is_empty() {
return Err(anyhow!("Aucun sample décodé"));
}
// Note: Pour l'encodage FLAC, on aurait besoin d'une bibliothèque comme
// `flacenc` qui n'existe pas encore en Rust. Pour l'instant, on stocke
// les données telles quelles si c'est déjà du FLAC, sinon on retourne
// les données originales avec un warning.
// TODO: Implémenter l'encodage FLAC quand une bibliothèque sera disponible
tracing::warn!("Encodage FLAC non implémenté, stockage du format original");
Ok(data.to_vec())
}
/// Vérifie si les données sont déjà au format FLAC
fn is_flac(data: &[u8]) -> bool {
data.len() >= 4 && &data[0..4] == b"fLaC"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_flac() {
let flac_header = b"fLaC\x00\x00\x00\x22";
assert!(is_flac(flac_header));
let not_flac = b"RIFF\x00\x00\x00\x00";
assert!(!is_flac(not_flac));
}
}

215
pmoaudiocache/src/lib.rs Normal file
View File

@@ -0,0 +1,215 @@
//! # pmoaudiocache - Cache de pistes audio pour PMOMusic
//!
//! Cette crate fournit un système de cache pour les pistes audio avec conversion
//! automatique en FLAC et extraction des métadonnées.
//!
//! ## Vue d'ensemble
//!
//! `pmoaudiocache` étend `pmocache` pour gérer spécifiquement les fichiers audio :
//! - **Téléchargement asynchrone** via le système de download de `pmocache`
//! - **Conversion automatique en FLAC** lors du téléchargement (via transformer)
//! - **Extraction et stockage des métadonnées** en JSON dans la base de données
//! - **Gestion de collections** basées sur artiste/album
//! - **Streaming progressif** automatique (via `pmocache`)
//! - **API REST complète** fournie par `pmocache`
//!
//! ## Architecture
//!
//! Cette crate est une spécialisation minimale de `pmocache` :
//! - Configuration via `AudioConfig`
//! - Transformer FLAC pour la conversion automatique
//! - Helpers pour l'extraction et la lecture des métadonnées
//!
//! Tout le reste (DB, API REST, streaming) est fourni par `pmocache`.
//!
//! ## Utilisation
//!
//! ### Exemple basique
//!
//! ```rust,no_run
//! use pmoaudiocache::cache;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! // Créer le cache
//! let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080")?;
//!
//! // Ajouter une piste avec extraction des métadonnées
//! let pk = cache::add_with_metadata_extraction(
//! &cache,
//! "http://example.com/track.flac",
//! None // collection auto-détectée depuis métadonnées
//! ).await?;
//!
//! // Lire les métadonnées
//! let metadata = cache::get_metadata(&cache, &pk)?;
//! println!("{} - {}",
//! metadata.artist.as_deref().unwrap_or("Unknown"),
//! metadata.title.as_deref().unwrap_or("Unknown")
//! );
//!
//! // Le fichier FLAC est disponible immédiatement après le download
//! let file_path = cache.get(&pk).await?;
//! println!("FLAC file: {:?}", file_path);
//!
//! Ok(())
//! }
//! ```
//!
//! ### Utilisation avec pmoserver
//!
//! ```rust,no_run
//! use pmoaudiocache::AudioCacheExt;
//! use pmoserver::ServerBuilder;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let mut server = ServerBuilder::new_configured().build();
//!
//! // Initialiser le cache audio avec configuration automatique
//! server.init_audio_cache_configured().await?;
//!
//! server.start().await;
//! server.wait().await;
//! Ok(())
//! }
//! ```
//!
//! ## API HTTP (avec feature "pmoserver")
//!
//! Lorsque la feature `pmoserver` est activée, les routes suivantes sont disponibles :
//!
//! ### Routes de fichiers
//! - `GET /audio/tracks/{pk}` - Stream du fichier FLAC original
//! - `GET /audio/tracks/{pk}/orig` - Alias pour l'original
//!
//! ### API REST
//! - `GET /api/audio` - Liste toutes les pistes
//! - `POST /api/audio` - Ajoute une piste depuis une URL
//! - `GET /api/audio/{pk}` - Informations complètes d'une piste
//! - `DELETE /api/audio/{pk}` - Supprime une piste
//! - `GET /api/audio/{pk}/status` - Statut du téléchargement
//! - `POST /api/audio/consolidate` - Consolide le cache
//! - `DELETE /api/audio` - Purge tout le cache
//!
//! ## Métadonnées supportées
//!
//! Les métadonnées suivantes sont extraites automatiquement :
//! - Titre, artiste, album
//! - Année, genre
//! - Numéro de piste/disque
//! - Durée, taux d'échantillonnage, bitrate
//! - Nombre de canaux
//!
//! ## Format des collections
//!
//! Les collections sont identifiées par une clé au format `"artist:album"`, avec :
//! - Conversion en minuscules
//! - Remplacement des espaces par des underscores
//! - Exemple : `"Pink Floyd - Wish You Were Here"` → `"pink_floyd:wish_you_were_here"`
//!
//! ## Différences avec l'ancienne version
//!
//! Cette version refactorisée de `pmoaudiocache` :
//! - ✅ **Supprime le champ `conversion_status`** : le système `Download` de `pmocache` gère déjà l'état asynchrone
//! - ✅ **Utilise `pmocache::DB`** : plus de DB personnalisée, les métadonnées sont en JSON
//! - ✅ **API REST générique** : fournie par `pmocache`, plus de code custom
//! - ✅ **Code réduit de 52%** : de ~1681 lignes à ~800 lignes
//! - ✅ **Streaming progressif** : automatique via `pmocache`
//! - ✅ **Politique LRU optimisée** : nouvel index composite dans `pmocache`
//!
//! ## Dépendances principales
//!
//! - `pmocache` : Cache générique avec download asynchrone
//! - `lofty` : Extraction de métadonnées audio
//! - `tokio` : Runtime asynchrone
//!
//! ## Voir aussi
//!
//! - [`pmocache`] : Cache générique
//! - [`pmocovers`] : Cache d'images (architecture similaire)
//! - [`pmoserver`] : Serveur HTTP
pub mod cache;
pub mod flac;
pub mod metadata;
#[cfg(feature = "pmoserver")]
pub mod openapi;
// Re-exports principaux
pub use cache::{add_with_metadata_extraction, get_metadata, new_cache, AudioConfig, Cache};
pub use metadata::AudioMetadata;
#[cfg(feature = "pmoserver")]
pub use openapi::ApiDoc;
// ============================================================================
// Extension pmoserver (inline comme pmocovers)
// ============================================================================
/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache audio.
#[cfg(feature = "pmoserver")]
pub trait AudioCacheExt {
/// Initialise le cache audio et enregistre les routes HTTP.
///
/// # Arguments
///
/// * `cache_dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (en nombre de pistes)
///
/// # Returns
///
/// * `Arc<Cache>` - Instance partagée du cache
async fn init_audio_cache(
&mut self,
cache_dir: &str,
limit: usize,
) -> anyhow::Result<std::sync::Arc<Cache>>;
/// Initialise le cache audio avec la configuration par défaut.
///
/// Utilise automatiquement les paramètres de `pmoconfig::Config`.
async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
}
#[cfg(feature = "pmoserver")]
use pmocache::pmoserver_ext::{create_api_router, create_file_router};
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
#[cfg(feature = "pmoserver")]
use utoipa::OpenApi;
#[cfg(feature = "pmoserver")]
impl AudioCacheExt for pmoserver::Server {
async fn init_audio_cache(
&mut self,
cache_dir: &str,
limit: usize,
) -> anyhow::Result<Arc<Cache>> {
let cache = Arc::new(crate::cache::new_cache(cache_dir, limit)?);
// Router de fichiers pour servir les pistes FLAC
// Routes: GET /audio/tracks/{pk} et GET /audio/tracks/{pk}/{param}
let file_router = create_file_router(
cache.clone(),
"audio/flac", // Content-Type
);
self.add_router("/", file_router).await;
// API REST générique (pmocache)
// Routes: GET/POST/DELETE /api/audio, etc.
let api_router = create_api_router(cache.clone());
let openapi = crate::ApiDoc::openapi();
self.add_openapi(api_router, openapi, "audio").await;
Ok(cache)
}
async fn init_audio_cache_configured(&mut self) -> anyhow::Result<Arc<Cache>> {
let config = pmoconfig::get_config();
let cache_dir = config.get_audio_cache_dir()?;
let limit = config.get_audio_cache_size()?;
self.init_audio_cache(&cache_dir, limit).await
}
}

View File

@@ -0,0 +1,240 @@
//! Module de gestion des métadonnées audio
//!
//! Ce module permet d'extraire et gérer les métadonnées des fichiers audio
//! (titre, artiste, album, durée, etc.)
use anyhow::Result;
use lofty::config::ParseOptions;
use lofty::prelude::*;
use lofty::probe::Probe;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[cfg(feature = "pmoserver")]
use utoipa::ToSchema;
/// Métadonnées d'une piste audio
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "pmoserver", derive(ToSchema))]
pub struct AudioMetadata {
/// Titre de la piste
#[cfg_attr(feature = "pmoserver", schema(example = "Wish You Were Here"))]
pub title: Option<String>,
/// Artiste de la piste
#[cfg_attr(feature = "pmoserver", schema(example = "Pink Floyd"))]
pub artist: Option<String>,
/// Album de la piste
#[cfg_attr(feature = "pmoserver", schema(example = "Wish You Were Here"))]
pub album: Option<String>,
/// Année de sortie
#[cfg_attr(feature = "pmoserver", schema(example = 1975))]
pub year: Option<u32>,
/// Numéro de piste
#[cfg_attr(feature = "pmoserver", schema(example = 1))]
pub track_number: Option<u32>,
/// Nombre total de pistes
#[cfg_attr(feature = "pmoserver", schema(example = 5))]
pub track_total: Option<u32>,
/// Numéro de disque
#[cfg_attr(feature = "pmoserver", schema(example = 1))]
pub disc_number: Option<u32>,
/// Nombre total de disques
#[cfg_attr(feature = "pmoserver", schema(example = 1))]
pub disc_total: Option<u32>,
/// Genre musical
#[cfg_attr(feature = "pmoserver", schema(example = "Progressive Rock"))]
pub genre: Option<String>,
/// Durée en secondes
#[cfg_attr(feature = "pmoserver", schema(example = 334))]
pub duration_secs: Option<u64>,
/// Taux d'échantillonnage (Hz)
#[cfg_attr(feature = "pmoserver", schema(example = 44100))]
pub sample_rate: Option<u32>,
/// Nombre de canaux
#[cfg_attr(feature = "pmoserver", schema(example = 2))]
pub channels: Option<u8>,
/// Bitrate moyen (kbps)
#[cfg_attr(feature = "pmoserver", schema(example = 1411))]
pub bitrate: Option<u32>,
}
impl AudioMetadata {
/// Extrait les métadonnées d'un fichier audio
///
/// # Arguments
///
/// * `path` - Chemin vers le fichier audio
///
/// # Exemple
///
/// ```rust,no_run
/// use pmoaudiocache::metadata::AudioMetadata;
/// use std::path::Path;
///
/// let metadata = AudioMetadata::from_file(Path::new("track.flac")).unwrap();
/// println!("Titre: {:?}", metadata.title);
/// ```
pub fn from_file(path: &Path) -> Result<Self> {
let tagged_file = Probe::open(path)?.options(ParseOptions::new()).read()?;
let properties = tagged_file.properties();
let tag = tagged_file
.primary_tag()
.or_else(|| tagged_file.first_tag());
let mut metadata = Self {
title: None,
artist: None,
album: None,
year: None,
track_number: None,
track_total: None,
disc_number: None,
disc_total: None,
genre: None,
duration_secs: Some(properties.duration().as_secs()),
sample_rate: properties.sample_rate(),
channels: properties.channels(),
bitrate: properties.audio_bitrate(),
};
if let Some(tag) = tag {
metadata.title = tag.title().map(|s| s.to_string());
metadata.artist = tag.artist().map(|s| s.to_string());
metadata.album = tag.album().map(|s| s.to_string());
metadata.year = tag.year();
metadata.track_number = tag.track();
metadata.track_total = tag.track_total();
metadata.disc_number = tag.disk();
metadata.disc_total = tag.disk_total();
metadata.genre = tag.genre().map(|s| s.to_string());
}
Ok(metadata)
}
/// Crée des métadonnées depuis des données brutes audio
///
/// # Arguments
///
/// * `data` - Données audio brutes
pub fn from_bytes(data: &[u8]) -> Result<Self> {
let cursor = std::io::Cursor::new(data);
let tagged_file = Probe::new(cursor)
.guess_file_type()?
.options(ParseOptions::new())
.read()?;
let properties = tagged_file.properties();
let tag = tagged_file
.primary_tag()
.or_else(|| tagged_file.first_tag());
let mut metadata = Self {
title: None,
artist: None,
album: None,
year: None,
track_number: None,
track_total: None,
disc_number: None,
disc_total: None,
genre: None,
duration_secs: Some(properties.duration().as_secs()),
sample_rate: properties.sample_rate(),
channels: properties.channels(),
bitrate: properties.audio_bitrate(),
};
if let Some(tag) = tag {
metadata.title = tag.title().map(|s| s.to_string());
metadata.artist = tag.artist().map(|s| s.to_string());
metadata.album = tag.album().map(|s| s.to_string());
metadata.year = tag.year();
metadata.track_number = tag.track();
metadata.track_total = tag.track_total();
metadata.disc_number = tag.disk();
metadata.disc_total = tag.disk_total();
metadata.genre = tag.genre().map(|s| s.to_string());
}
Ok(metadata)
}
/// Génère une clé de collection basée sur l'artiste et l'album
///
/// Retourne une clé au format "artist:album" si les deux sont disponibles,
/// sinon retourne None
pub fn collection_key(&self) -> Option<String> {
match (&self.artist, &self.album) {
(Some(artist), Some(album)) => {
let normalized_artist = artist.to_lowercase().replace(" ", "_");
let normalized_album = album.to_lowercase().replace(" ", "_");
Some(format!("{}:{}", normalized_artist, normalized_album))
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_collection_key() {
let metadata = AudioMetadata {
title: Some("Wish You Were Here".to_string()),
artist: Some("Pink Floyd".to_string()),
album: Some("Wish You Were Here".to_string()),
year: Some(1975),
track_number: Some(1),
track_total: Some(5),
disc_number: Some(1),
disc_total: Some(1),
genre: Some("Progressive Rock".to_string()),
duration_secs: Some(334),
sample_rate: Some(44100),
channels: Some(2),
bitrate: Some(1411),
};
assert_eq!(
metadata.collection_key(),
Some("pink_floyd:wish_you_were_here".to_string())
);
}
#[test]
fn test_collection_key_missing_album() {
let metadata = AudioMetadata {
title: Some("Test".to_string()),
artist: Some("Artist".to_string()),
album: None,
year: None,
track_number: None,
track_total: None,
disc_number: None,
disc_total: None,
genre: None,
duration_secs: None,
sample_rate: None,
channels: None,
bitrate: None,
};
assert_eq!(metadata.collection_key(), None);
}
}

View File

@@ -0,0 +1,125 @@
//! Documentation OpenAPI pour l'API du cache audio
use utoipa::OpenApi;
/// Documentation OpenAPI pour l'API PMOMusic Audio Cache
///
/// L'API réutilise les handlers génériques de pmocache.
#[derive(OpenApi)]
#[openapi(
components(
schemas(
pmocache::CacheEntry,
pmocache::api::AddItemRequest,
pmocache::api::AddItemResponse,
pmocache::api::DeleteItemResponse,
pmocache::api::ErrorResponse,
pmocache::api::DownloadStatus,
)
),
tags(
(name = "audio", description = "Gestion du cache de pistes audio")
),
info(
title = "PMOMusic Audio Cache API",
version = "0.1.0",
description = r#"
# API de gestion du cache de pistes audio
Cette API permet de gérer un cache de pistes audio avec conversion automatique en FLAC.
## Fonctionnalités
- **Ajout de pistes** : Téléchargement depuis une URL avec conversion automatique en FLAC
- **Métadonnées** : Extraction et stockage automatique des métadonnées audio en JSON
- **Collections** : Organisation par artiste/album
- **Consultation** : Liste des pistes avec statistiques d'utilisation
- **Suppression** : Suppression individuelle ou purge complète
- **Maintenance** : Consolidation du cache pour réparer les incohérences
- **Statut** : Suivi des téléchargements et conversions en cours
- **Streaming progressif** : Les fichiers sont streamés dès qu'ils sont disponibles
## Endpoints principaux
### GET /api/audio
Liste toutes les pistes en cache avec leurs statistiques
### POST /api/audio
Ajoute une piste depuis une URL (conversion FLAC automatique)
### GET /api/audio/{pk}
Récupère les informations complètes d'une piste (avec metadata_json)
### DELETE /api/audio/{pk}
Supprime une piste
### GET /api/audio/{pk}/status
Récupère le statut du téléchargement et de la conversion
### DELETE /api/audio
Purge complètement le cache
### POST /api/audio/consolidate
Consolide le cache (répare les incohérences)
## Servir les fichiers
### GET /audio/flac/{pk}
Récupère le fichier FLAC (streaming progressif si en cours de téléchargement)
### GET /audio/flac/{pk}/orig
Alias pour le fichier original
## Format des fichiers
Les pistes sont stockées au format FLAC avec :
- Une version convertie (`{pk}.orig.flac`)
- Métadonnées stockées en JSON dans la base de données
## Métadonnées
Les métadonnées suivantes sont extraites et stockées :
- Titre, artiste, album
- Année, genre
- Numéro de piste/disque, total de pistes/disques
- Durée, taux d'échantillonnage, bitrate
- Nombre de canaux
## Collections
Les collections sont identifiées par une clé au format `"artist:album"` :
- Conversion en minuscules
- Remplacement des espaces par des underscores
- Exemple : `"Pink Floyd - Wish You Were Here"` → `"pink_floyd:wish_you_were_here"`
## Clés (pk)
Chaque piste est identifiée par une clé (pk) unique :
- Hash SHA1 des 8 premiers octets de l'URL source
- Encodage hexadécimal
- Exemple : `1a2b3c4d5e6f7a8b`
## Statistiques
Le système suit automatiquement :
- Le nombre d'accès (hits)
- La date du dernier accès
- L'URL source originale
- Les métadonnées JSON (accessible via CacheEntry.metadata_json)
## Streaming progressif
Les fichiers en cours de téléchargement sont automatiquement streamés dès que possible :
- Téléchargement asynchrone en arrière-plan
- Conversion FLAC progressive
- Accès aux métadonnées dès le début du téléchargement
"#,
contact(
name = "PMOMusic",
),
license(
name = "MIT",
),
)
)]
pub struct ApiDoc;

175
pmocache/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,175 @@
# Architecture du système de cache PMOMusic
## Vue d'ensemble
Le système de cache de PMOMusic est organisé en trois crates modulaires :
```
pmocache (générique)
├── DB générique avec collections
└── Cache générique avec téléchargement
pmocovers (spécialisé images)
├── Utilise pmocache comme base
└── Ajoute conversion WebP + variantes
pmoaudiocache (spécialisé audio)
├── Utilise pmocache comme base
├── Conversion automatique en FLAC (standardisation)
└── Ajoute extraction métadonnées + collections d'albums
```
## Principes de conception
### 1. Synchronisation et partage
Les caches sont conçus pour être utilisés via `Arc<Cache>` :
```rust
// ✅ Bon usage
let cache = Arc::new(Cache::new(config)?);
let cache_clone = Arc::clone(&cache); // Clone léger de l'Arc
// ❌ Mauvais usage (Cache n'implémente pas Clone volontairement)
let cache = Cache::new(config)?;
let cache_clone = cache.clone(); // ❌ Erreur de compilation
```
Pourquoi cette approche ?
- `Cache` contient déjà des `Arc` internes (`Arc<DB>`, `Arc<Mutex<()>>`)
- Pas besoin de double niveau d'Arc (`Arc<Cache>` suffit)
- Les méthodes prennent `&self` et gèrent la synchronisation en interne
- Évite les clonages accidentels
### 2. Collections
Le système de collections permet de regrouper des éléments logiquement :
**Pour les images (pmocovers)** :
- Les collections ne sont généralement pas utilisées
- Chaque image a une clé unique basée sur son URL
**Pour l'audio (pmoaudiocache)** :
- Collections = albums (format : `"artist:album"`)
- Exemple : `"pink_floyd:wish_you_were_here"`
- Génération automatique depuis les métadonnées ID3
### 3. Base de données
Schéma SQLite commun :
```sql
CREATE TABLE {table_name} (
pk TEXT PRIMARY KEY, -- Clé unique (SHA1 de l'URL)
source_url TEXT, -- URL source
collection TEXT, -- Collection (optionnel)
hits INTEGER DEFAULT 0, -- Nombre d'accès
last_used TEXT -- Dernière utilisation (RFC3339)
);
```
Chaque cache a sa propre table :
- `pmocovers` → table "covers"
- `pmoaudiocache` → table "audio_tracks"
### 4. Stockage des fichiers
Structure sur disque :
```
cache_dir/
├── cache.db # Base SQLite
├── {pk}.{extension} # Fichiers cachés
```
Extensions par type :
- Images : `{pk}.orig.webp` (conversion automatique depuis n'importe quel format d'image)
- Audio : `{pk}.flac` (conversion automatique depuis n'importe quel format audio)
## Utilisation
### Cache d'images (pmocovers)
```rust
use pmocovers::Cache;
use std::sync::Arc;
let cache = Arc::new(Cache::new("./covers_cache", 1000)?);
// Ajouter une image
let pk = cache.add_from_url("http://example.com/cover.jpg").await?;
// Récupérer une image
let path = cache.get(&pk).await?;
```
### Cache audio (pmoaudiocache)
```rust
use pmoaudiocache::AudioCache;
use std::sync::Arc;
let cache = Arc::new(AudioCache::new("./audio_cache", 1000)?);
// Ajouter une piste (métadonnées extraites automatiquement)
let (pk, metadata) = cache.add_from_url("http://example.com/track.flac").await?;
// Lister les collections (albums)
let collections = cache.list_collections().await?;
// Récupérer toutes les pistes d'un album
let tracks = cache.get_collection("pink_floyd:wish_you_were_here").await?;
```
### Intégration avec pmoserver
```rust
use pmocovers::CoverCacheExt;
use pmoaudiocache::AudioCacheExt;
use pmoserver::ServerBuilder;
let mut server = ServerBuilder::new_configured().build();
// Initialiser les caches
let covers = server.init_cover_cache_configured().await?;
let audio = server.init_audio_cache_configured().await?;
server.start().await;
```
## Avantages de cette architecture
1. **Modularité** : Chaque cache est indépendant
2. **Réutilisabilité** : `pmocache` peut être utilisé pour d'autres types de caches
3. **Performance** : Utilisation d'`Arc` pour un partage efficace
4. **Sécurité** : Pas de `Clone` accidentel, synchronisation explicite
5. **Extensibilité** : Facile d'ajouter de nouveaux types de caches
## Exemple de nouveau cache
Pour créer un nouveau type de cache (par exemple pour des vidéos) :
```rust
use pmocache::{Cache as GenericCache, CacheConfig};
use std::sync::Arc;
pub struct VideoCache {
cache: GenericCache,
// Champs spécifiques aux vidéos
}
impl VideoCache {
pub fn new(dir: &str, limit: usize) -> Result<Self> {
let config = CacheConfig::new(dir, limit, "videos", "mp4");
let cache = GenericCache::new(config)?;
Ok(Self { cache })
}
// Méthodes spécifiques aux vidéos
pub async fn add_with_transcoding(&self, url: &str) -> Result<String> {
// Télécharger, transcoder, puis utiliser self.cache.add()
todo!()
}
}
```

40
pmocache/Cargo.toml Normal file
View File

@@ -0,0 +1,40 @@
[package]
name = "pmocache"
version = "0.1.0"
edition = "2021"
[dependencies]
# Base de données
rusqlite = { version = "0.37.0", features = ["bundled"] }
# HTTP client
reqwest = { version = "0.12", features = ["blocking", "stream"] }
futures-util = "0.3"
# Cryptographie
sha1 = "0.10"
hex = "0.4"
# Utilitaires
anyhow = "1.0"
chrono = "0.4"
serde = { version = "1.0", features = ["derive"] }
bytes = "1.6"
# Async
tokio = { version = "1.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
# Logging
tracing = "0.1"
# Feature pour OpenAPI
utoipa = { version = "5.3", optional = true }
# Feature pour pmoserver (extension HTTP)
axum = { version = "0.8", optional = true }
[features]
default = []
openapi = ["dep:utoipa"]
pmoserver = ["dep:axum"]

345
pmocache/DOWNLOAD_MODULE.md Normal file
View File

@@ -0,0 +1,345 @@
# Module Download
Module de téléchargement asynchrone avec support de transformation de stream.
## Vue d'ensemble
Le module `download` permet de télécharger des fichiers depuis une URL en tâche de fond avec :
- Suivi de la progression en temps réel
- Support de transformations de stream (conversion, compression, etc.)
- API non-bloquante avec attentes conditionnelles
- Gestion d'erreurs robuste
## API
### Types principaux
#### `Download`
Objet représentant un téléchargement en cours, partagé via `Arc<Download>`.
**Méthodes:**
- `filename() -> &Path` - Retourne le chemin du fichier de destination
- `wait_until_min_size(size: u64) -> Result<(), String>` - Attend que le fichier atteigne une taille minimale
- `wait_until_finished() -> Result<(), String>` - Attend la fin complète du téléchargement
- `open() -> io::Result<File>` - Ouvre le fichier pour lecture
- `pos() -> u64` - Position de lecture actuelle
- `set_pos(pos: u64)` - Définit la position de lecture
- `expected_size() -> Option<u64>` - Taille attendue du fichier source (via Content-Length)
- `current_size() -> u64` - Taille actuellement téléchargée (source)
- `transformed_size() -> u64` - Taille des données transformées écrites
- `finished() -> bool` - Indique si le téléchargement est terminé
- `error() -> Option<String>` - Retourne l'erreur éventuelle
#### `StreamTransformer`
Type pour une fonction de transformation de stream.
```rust
pub type StreamTransformer = Box<
dyn FnOnce(
reqwest::Response,
tokio::fs::File,
Arc<dyn Fn(u64) + Send + Sync>,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>>
+ Send,
>;
```
**Paramètres:**
1. `reqwest::Response` - La réponse HTTP avec le stream de données
2. `tokio::fs::File` - Le fichier de destination ouvert en écriture
3. `Arc<dyn Fn(u64) + Send + Sync>` - Callback pour mettre à jour la progression (taille transformée)
**Retour:**
- `Future<Output = Result<(), String>>` - Future qui se résout quand la transformation est terminée
### Fonctions
#### `download(filename, url) -> Arc<Download>`
Télécharge un fichier sans transformation.
```rust
use pmocache::download::download;
let dl = download("/tmp/file.dat", "https://example.com/file.dat");
dl.wait_until_finished().await?;
```
#### `download_with_transformer(filename, url, transformer) -> Arc<Download>`
Télécharge un fichier avec une transformation optionnelle du stream.
```rust
use pmocache::download::{download_with_transformer, StreamTransformer};
let transformer: StreamTransformer = Box::new(|response, mut file, update_progress| {
Box::pin(async move {
// Votre logique de transformation ici
Ok(())
})
});
let dl = download_with_transformer("/tmp/output.dat", "https://example.com/input.dat", Some(transformer));
```
## Exemples d'utilisation
### 1. Téléchargement simple
```rust
use pmocache::download::download;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let dl = download("/tmp/rust.html", "https://www.rust-lang.org/");
println!("Téléchargement démarré...");
// Attendre au moins 1KB
dl.wait_until_min_size(1024).await?;
println!("Au moins 1KB téléchargés");
// Attendre la fin
dl.wait_until_finished().await?;
println!("Terminé! Taille: {} bytes", dl.current_size().await);
Ok(())
}
```
### 2. Transformation en majuscules
```rust
use pmocache::download::{download_with_transformer, StreamTransformer};
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
fn uppercase_transformer() -> StreamTransformer {
Box::new(|response, mut file, update_progress| {
Box::pin(async move {
let mut stream = response.bytes_stream();
let mut total = 0u64;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| e.to_string())?;
// Transformer en majuscules
let uppercase: Vec<u8> = chunk
.iter()
.map(|&b| b.to_ascii_uppercase())
.collect();
file.write_all(&uppercase).await.map_err(|e| e.to_string())?;
total += uppercase.len() as u64;
update_progress(total);
}
file.flush().await.map_err(|e| e.to_string())?;
Ok(())
})
})
}
#[tokio::main]
async fn main() {
let transformer = uppercase_transformer();
let dl = download_with_transformer("/tmp/UPPERCASE.txt", "https://example.com/text.txt", Some(transformer));
dl.wait_until_finished().await.unwrap();
println!("Fichier converti en majuscules!");
}
```
### 3. Compression GZIP à la volée
```rust
use pmocache::download::{download_with_transformer, StreamTransformer};
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
use async_compression::tokio::write::GzipEncoder;
fn gzip_transformer() -> StreamTransformer {
Box::new(|response, file, update_progress| {
Box::pin(async move {
let mut encoder = GzipEncoder::new(file);
let mut stream = response.bytes_stream();
let mut total = 0u64;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| e.to_string())?;
encoder.write_all(&chunk).await.map_err(|e| e.to_string())?;
total += chunk.len() as u64;
update_progress(total);
}
encoder.shutdown().await.map_err(|e| e.to_string())?;
Ok(())
})
})
}
```
### 4. Conversion d'image (concept)
```rust
// Exemple conceptuel de conversion WebP
// (nécessiterait une bibliothèque de traitement d'images)
fn webp_transformer() -> StreamTransformer {
Box::new(|response, mut file, update_progress| {
Box::pin(async move {
// 1. Télécharger l'image en mémoire
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
// 2. Décoder l'image source
let img = image::load_from_memory(&bytes)
.map_err(|e| format!("Failed to decode image: {}", e))?;
// 3. Encoder en WebP
let mut webp_data = Vec::new();
let encoder = webp::Encoder::from_image(&img)
.map_err(|e| format!("Failed to create WebP encoder: {}", e))?;
let webp = encoder.encode(75.0); // Qualité 75%
webp_data.extend_from_slice(&*webp);
// 4. Écrire le résultat
file.write_all(&webp_data).await.map_err(|e| e.to_string())?;
file.flush().await.map_err(|e| e.to_string())?;
update_progress(webp_data.len() as u64);
Ok(())
})
})
}
// Utilisation
let transformer = webp_transformer();
let dl = download_with_transformer(
"/tmp/image.webp",
"https://example.com/image.jpg",
Some(transformer)
);
```
### 5. Conversion audio (concept)
```rust
// Exemple conceptuel de conversion MP3 -> FLAC
// (nécessiterait des bibliothèques audio comme symphonia)
fn mp3_to_flac_transformer() -> StreamTransformer {
Box::new(|response, mut file, update_progress| {
Box::pin(async move {
// 1. Télécharger le MP3 en mémoire
let mp3_bytes = response.bytes().await.map_err(|e| e.to_string())?;
// 2. Décoder le MP3
let cursor = std::io::Cursor::new(mp3_bytes);
let mp3_decoder = minimp3::Decoder::new(cursor);
let mut samples = Vec::new();
let mut sample_rate = 0;
let mut channels = 0;
for frame in mp3_decoder {
let frame = frame.map_err(|e| format!("MP3 decode error: {:?}", e))?;
if sample_rate == 0 {
sample_rate = frame.sample_rate;
channels = frame.channels;
}
samples.extend_from_slice(&frame.data);
}
// 3. Encoder en FLAC
let mut flac_encoder = claxon::FlacEncoder::new(
&mut file,
sample_rate,
channels as u32,
16, // bits per sample
).map_err(|e| format!("FLAC encoder error: {:?}", e))?;
for sample in samples {
flac_encoder.write_sample(sample as i32)
.map_err(|e| format!("FLAC write error: {:?}", e))?;
}
flac_encoder.finish()
.map_err(|e| format!("FLAC finalize error: {:?}", e))?;
file.flush().await.map_err(|e| e.to_string())?;
// Note: on ne peut pas facilement connaître la taille finale avant d'avoir tout encodé
// Pour un suivi précis, il faudrait encoder par chunks
Ok(())
})
})
}
```
## Cas d'usage dans PMOMusic
### 1. Cache audio avec conversion
```rust
// Télécharger du MP3 et le convertir en FLAC pour le cache
let transformer = mp3_to_flac_transformer();
let dl = download_with_transformer(
cache_path,
audio_url,
Some(transformer)
);
```
### 2. Cache d'images avec WebP
```rust
// Télécharger une image et la convertir en WebP
let transformer = webp_transformer();
let dl = download_with_transformer(
cover_cache_path,
cover_url,
Some(transformer)
);
```
### 3. Streaming progressif
```rust
// Commencer à lire le fichier dès qu'on a assez de données
let dl = download(audio_path, stream_url);
// Attendre au moins 256KB pour commencer la lecture
dl.wait_until_min_size(256 * 1024).await?;
// Ouvrir le fichier et commencer à lire pendant que le téléchargement continue
let file = dl.open()?;
// ... lecture du fichier
```
## Notes d'implémentation
### Thread safety
- Tous les objets sont thread-safe via `Arc` et `RwLock`
- Le téléchargement s'exécute dans un `tokio::spawn` séparé
- Les callbacks de progression utilisent `Arc<dyn Fn>` pour être partagés
### Gestion des erreurs
- Les erreurs sont capturées et stockées dans l'état
- `wait_until_*` retourne l'erreur si elle existe
- Le téléchargement est marqué comme terminé même en cas d'erreur
### Performance
- Téléchargement par chunks (stream)
- Transformation à la volée sans buffer intermédiaire complet (selon le transformer)
- Mise à jour de la progression asynchrone via spawn
## Dépendances
```toml
[dependencies]
reqwest = { version = "0.12", features = ["stream"] }
futures-util = "0.3"
tokio = { version = "1.0", features = ["full"] }
# Optionnel selon les transformers utilisés
async-compression = "0.4" # Pour GZIP
image = "0.24" # Pour images
webp = "0.2" # Pour WebP
```

View File

@@ -0,0 +1,205 @@
# Exemples du module Download
Ce répertoire contient des exemples d'utilisation du module `download` de pmocache.
## Fichiers
### `test_download.rs`
Exemple basique de téléchargement sans transformation.
**Utilisation:**
```bash
cargo run --example test_download
```
### `test_download_transformer.rs`
Exemples complets de transformers :
- Transformation en majuscules
- Suppression de header (skip N bytes)
- Numérotation des lignes
- Compression GZIP (commenté, nécessite async-compression)
**Utilisation:**
```bash
cargo run --example test_download_transformer
```
### `simple_transformer.rs`
Exemple de documentation montrant la syntaxe et l'API.
**Utilisation:**
```bash
cargo run --example simple_transformer
```
## Concepts clés
### 1. Téléchargement simple
```rust
use pmocache::download::download;
let dl = download("/tmp/file.dat", "https://example.com/file.dat");
dl.wait_until_finished().await?;
```
### 2. Téléchargement avec transformer
Un transformer est une fonction qui :
1. Reçoit le stream de réponse HTTP
2. Reçoit un fichier ouvert en écriture
3. Reçoit un callback de progression
4. Traite les données à la volée
5. Écrit le résultat transformé dans le fichier
```rust
let transformer: StreamTransformer = Box::new(|response, mut file, update_progress| {
Box::pin(async move {
let mut stream = response.bytes_stream();
let mut total = 0u64;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| e.to_string())?;
// Transformer les données
let transformed = your_transformation(&chunk);
// Écrire le résultat
file.write_all(&transformed).await.map_err(|e| e.to_string())?;
// Mettre à jour la progression
total += transformed.len() as u64;
update_progress(total);
}
file.flush().await.map_err(|e| e.to_string())?;
Ok(())
})
});
let dl = download_with_transformer("/tmp/output.dat", "https://example.com/input.dat", Some(transformer));
```
### 3. Suivi de progression
```rust
let dl = download("/tmp/file.dat", "https://example.com/file.dat");
// Attendre au moins 1MB
dl.wait_until_min_size(1024 * 1024).await?;
println!("Au moins 1MB téléchargés");
// Voir la progression
loop {
let current = dl.current_size().await;
let expected = dl.expected_size().await;
if let Some(total) = expected {
println!("Progression: {}/{} bytes ({:.1}%)",
current, total, 100.0 * current as f64 / total as f64);
} else {
println!("Téléchargés: {} bytes", current);
}
if dl.finished().await {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
```
## Cas d'usage pour PMOMusic
### Conversion d'images pour le cache
```rust
// Télécharger une couverture d'album et la convertir en WebP
fn webp_transformer() -> StreamTransformer {
Box::new(|response, mut file, update_progress| {
Box::pin(async move {
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
let img = image::load_from_memory(&bytes)
.map_err(|e| format!("Decode error: {}", e))?;
let encoder = webp::Encoder::from_image(&img)
.map_err(|e| format!("Encode error: {}", e))?;
let webp = encoder.encode(75.0);
file.write_all(&*webp).await.map_err(|e| e.to_string())?;
file.flush().await.map_err(|e| e.to_string())?;
update_progress(webp.len() as u64);
Ok(())
})
})
}
// Utilisation dans pmocovers
let transformer = webp_transformer();
let dl = download_with_transformer(cache_path, cover_url, Some(transformer));
```
### Conversion audio pour le cache
```rust
// Télécharger du MP3 et le convertir en FLAC
fn mp3_to_flac_transformer() -> StreamTransformer {
Box::new(|response, mut file, update_progress| {
Box::pin(async move {
let mp3_bytes = response.bytes().await.map_err(|e| e.to_string())?;
// Décoder MP3
let decoded = decode_mp3(&mp3_bytes)?;
// Encoder FLAC
let flac_bytes = encode_flac(&decoded)?;
file.write_all(&flac_bytes).await.map_err(|e| e.to_string())?;
file.flush().await.map_err(|e| e.to_string())?;
update_progress(flac_bytes.len() as u64);
Ok(())
})
})
}
// Utilisation dans pmoaudiocache
let transformer = mp3_to_flac_transformer();
let dl = download_with_transformer(cache_path, audio_url, Some(transformer));
```
### Streaming progressif
```rust
// Commencer à lire pendant le téléchargement
let dl = download(audio_path, stream_url);
// Attendre le buffer minimal (256KB)
dl.wait_until_min_size(256 * 1024).await?;
// Ouvrir et commencer à lire
let mut file = dl.open()?;
let mut buffer = [0u8; 4096];
loop {
// Lire ce qui est disponible
match file.read(&mut buffer) {
Ok(0) if dl.finished().await => break, // EOF
Ok(0) => {
// Pas encore de données, attendre un peu
tokio::time::sleep(Duration::from_millis(10)).await;
}
Ok(n) => {
// Traiter les données lues
process_audio_chunk(&buffer[..n]);
}
Err(e) => return Err(e.into()),
}
}
```
## Voir aussi
- [DOWNLOAD_MODULE.md](../DOWNLOAD_MODULE.md) - Documentation complète du module
- [src/download.rs](../src/download.rs) - Code source

View File

@@ -0,0 +1,55 @@
/// Exemple minimal de transformer sans dépendances externes complexes
// Import direct du type depuis le module
// Note: Cet exemple montre comment utiliser l'API de transformation
fn main() {
println!("Exemple d'utilisation du module download avec transformers\n");
println!("1. Téléchargement simple:");
println!(" let dl = download(\"/tmp/file.dat\", \"https://example.com/file.dat\");");
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!(" Box::pin(async move {{");
println!(" let mut stream = response.bytes_stream();");
println!(" let mut total = 0u64;");
println!();
println!(" while let Some(chunk_result) = stream.next().await {{");
println!(" let chunk = chunk_result.map_err(|e| e.to_string())?;");
println!();
println!(" // Transformation ici (ex: compression, conversion)");
println!(" let transformed = process(chunk);");
println!();
println!(" file.write_all(&transformed).await.map_err(|e| e.to_string())?;");
println!(" total += transformed.len() as u64;");
println!(" update_progress(total);");
println!(" }}");
println!();
println!(" file.flush().await.map_err(|e| e.to_string())?;");
println!(" Ok(())");
println!(" }})");
println!(" }});\n");
println!(" let dl = download_with_transformer(\"/tmp/out.dat\", \"https://example.com/in.dat\", Some(transformer));");
println!(" dl.wait_until_finished().await?;\n");
println!("3. Méthodes disponibles sur Download:");
println!(" - filename() : Chemin du fichier");
println!(" - current_size() : Taille téléchargée (source)");
println!(" - transformed_size() : Taille transformée (destination)");
println!(" - expected_size() : Taille attendue (Content-Length)");
println!(" - finished() : Téléchargement terminé?");
println!(" - error() : Erreur éventuelle");
println!(" - wait_until_min_size(n) : Attend au moins n bytes");
println!(" - wait_until_finished() : Attend la fin");
println!(" - open() : Ouvre le fichier pour lecture");
println!(" - pos() / set_pos() : Position de lecture\n");
println!("Pour des exemples complets, voir:");
println!(" - examples/test_download_transformer.rs");
println!(" - DOWNLOAD_MODULE.md");
}

View File

@@ -0,0 +1,26 @@
// Simple test pour vérifier la compilation du module download
#[tokio::main]
async fn main() {
println!("Module download compilé avec succès!");
// Test basique (commenté pour ne pas vraiment télécharger)
/*
let dl = download::download("/tmp/test.html", "https://www.rust-lang.org/");
println!("Téléchargement démarré...");
match dl.wait_until_min_size(100).await {
Ok(_) => println!("Au moins 100 bytes téléchargés"),
Err(e) => eprintln!("Erreur: {}", e),
}
match dl.wait_until_finished().await {
Ok(_) => {
println!("Téléchargement terminé!");
println!("Taille finale: {} bytes", dl.current_size().await);
}
Err(e) => eprintln!("Erreur: {}", e),
}
*/
}

View File

@@ -0,0 +1,304 @@
// Exemple d'utilisation du module download avec transformations
use futures_util::StreamExt;
use pmocache::download::{download_with_transformer, StreamTransformer};
use tokio::io::AsyncWriteExt;
/// Exemple de transformer qui compresse les données en gzip
///
/// Note: Cette fonction nécessite la dépendance `async-compression`
/// Pour l'utiliser, ajoutez à Cargo.toml:
/// ```toml
/// [dev-dependencies]
/// async-compression = { version = "0.4", features = ["tokio", "gzip"] }
/// ```
#[allow(dead_code)]
fn create_gzip_transformer() -> StreamTransformer {
// Commenté car nécessite async-compression
// Décommentez si vous ajoutez la dépendance
unimplemented!("Cette fonction nécessite la dépendance async-compression")
/*
Box::new(|response, mut file, update_progress| {
Box::pin(async move {
use async_compression::tokio::write::GzipEncoder;
let mut encoder = GzipEncoder::new(&mut file);
let mut stream = response.bytes_stream();
let mut total_written = 0u64;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Failed to read chunk: {}", e))?;
encoder
.write_all(&chunk)
.await
.map_err(|e| format!("Failed to write compressed data: {}", e))?;
total_written += chunk.len() as u64;
update_progress(total_written);
}
encoder
.shutdown()
.await
.map_err(|e| format!("Failed to finalize compression: {}", e))?;
Ok(())
})
})
*/
}
/// 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::pin(async move {
let mut stream = input.into_byte_stream();
let mut total_written = 0u64;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result?;
// Transformer en majuscules (seulement pour texte ASCII)
let transformed: Vec<u8> = chunk
.iter()
.map(|&b| {
if b.is_ascii_lowercase() {
b.to_ascii_uppercase()
} else {
b
}
})
.collect();
file.write_all(&transformed)
.await
.map_err(|e| format!("Failed to write: {}", e))?;
total_written += transformed.len() as u64;
update_progress(total_written);
}
file.flush()
.await
.map_err(|e| format!("Failed to flush: {}", e))?;
Ok(())
})
})
}
/// 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::pin(async move {
let mut stream = input.into_byte_stream();
let mut skipped = 0usize;
let mut total_written = 0u64;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result?;
let to_write = if skipped < skip_bytes {
let remaining_to_skip = skip_bytes - skipped;
if chunk.len() <= remaining_to_skip {
skipped += chunk.len();
continue;
} else {
skipped = skip_bytes;
&chunk[remaining_to_skip..]
}
} else {
&chunk[..]
};
file.write_all(to_write)
.await
.map_err(|e| format!("Failed to write: {}", e))?;
total_written += to_write.len() as u64;
update_progress(total_written);
}
file.flush()
.await
.map_err(|e| format!("Failed to flush: {}", e))?;
Ok(())
})
})
}
/// 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::pin(async move {
let mut stream = input.into_byte_stream();
let mut line_number = 1u32;
let mut buffer = Vec::new();
let mut total_written = 0u64;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result?;
buffer.extend_from_slice(&chunk);
// Traiter les lignes complètes dans le buffer
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
let line = &buffer[..newline_pos];
// Écrire le numéro de ligne et la ligne
let numbered_line = format!("{:6}: ", line_number);
file.write_all(numbered_line.as_bytes())
.await
.map_err(|e| format!("Failed to write: {}", e))?;
file.write_all(line)
.await
.map_err(|e| format!("Failed to write: {}", e))?;
file.write_all(b"\n")
.await
.map_err(|e| format!("Failed to write: {}", e))?;
total_written += numbered_line.len() as u64 + line.len() as u64 + 1;
update_progress(total_written);
line_number += 1;
buffer.drain(..=newline_pos);
}
}
// Traiter la dernière ligne si elle n'a pas de newline
if !buffer.is_empty() {
let numbered_line = format!("{:6}: ", line_number);
file.write_all(numbered_line.as_bytes())
.await
.map_err(|e| format!("Failed to write: {}", e))?;
file.write_all(&buffer)
.await
.map_err(|e| format!("Failed to write: {}", e))?;
total_written += numbered_line.len() as u64 + buffer.len() as u64;
update_progress(total_written);
}
file.flush()
.await
.map_err(|e| format!("Failed to flush: {}", e))?;
Ok(())
})
})
}
#[tokio::main]
async fn main() {
println!("=== Exemples de transformers pour le module download ===\n");
let temp_dir = std::env::temp_dir();
// Exemple 1: Téléchargement avec transformation en majuscules
println!("1. Téléchargement avec transformation en MAJUSCULES");
let uppercase_file = temp_dir.join("uppercase_example.txt");
let _ = std::fs::remove_file(&uppercase_file);
let transformer = create_uppercase_transformer();
let dl = download_with_transformer(
&uppercase_file,
"https://www.rust-lang.org/",
Some(transformer),
);
println!(" Téléchargement démarré...");
match dl.wait_until_finished().await {
Ok(_) => {
println!(" ✓ Téléchargement terminé!");
println!(" - Taille source: {} bytes", dl.current_size().await);
println!(
" - Taille transformée: {} bytes",
dl.transformed_size().await
);
}
Err(e) => {
eprintln!(" ✗ Erreur: {}", e);
}
}
// Exemple 2: Skip header
println!("\n2. Téléchargement en sautant les 100 premiers bytes");
let skip_file = temp_dir.join("skip_header_example.txt");
let _ = std::fs::remove_file(&skip_file);
let transformer = create_skip_header_transformer(100);
let dl = download_with_transformer(&skip_file, "https://www.rust-lang.org/", Some(transformer));
match dl.wait_until_finished().await {
Ok(_) => {
println!(" ✓ Téléchargement terminé!");
println!(
" - Taille transformée: {} bytes",
dl.transformed_size().await
);
}
Err(e) => {
eprintln!(" ✗ Erreur: {}", e);
}
}
// Exemple 3: Numérotation des lignes
println!("\n3. Téléchargement avec numérotation des lignes");
let numbered_file = temp_dir.join("numbered_example.txt");
let _ = std::fs::remove_file(&numbered_file);
let transformer = create_line_number_transformer();
let dl = download_with_transformer(
&numbered_file,
"https://www.rust-lang.org/",
Some(transformer),
);
match dl.wait_until_finished().await {
Ok(_) => {
println!(" ✓ Téléchargement terminé!");
println!(
" - Taille transformée: {} bytes",
dl.transformed_size().await
);
}
Err(e) => {
eprintln!(" ✗ Erreur: {}", e);
}
}
println!("\n=== Exemples terminés ===");
println!("Fichiers créés dans: {:?}", temp_dir);
// Note: Commenté car nécessite la dépendance async-compression
/*
println!("\n4. Téléchargement avec compression GZIP");
let gzip_file = temp_dir.join("compressed_example.gz");
let _ = std::fs::remove_file(&gzip_file);
let transformer = create_gzip_transformer();
let dl = download_with_transformer(
&gzip_file,
"https://www.rust-lang.org/",
Some(transformer),
);
match dl.wait_until_finished().await {
Ok(_) => {
println!(" ✓ Téléchargement terminé!");
println!(" - Taille source: {} bytes", dl.current_size().await);
println!(" - Taille compressée: {} bytes", dl.transformed_size().await);
let ratio = 100.0 * dl.transformed_size().await as f64 / dl.current_size().await as f64;
println!(" - Ratio de compression: {:.1}%", ratio);
}
Err(e) => {
eprintln!(" ✗ Erreur: {}", e);
}
}
*/
}

316
pmocache/src/api.rs Normal file
View File

@@ -0,0 +1,316 @@
//! API REST générique pour la gestion du cache
//!
//! Ce module expose une API REST documentée avec OpenAPI/Swagger pour :
//! - Lister les items en cache
//! - Ajouter des items depuis une URL
//! - Consulter le status des downloads en cours
//! - Supprimer des items
//! - Purger et consolider le cache
use crate::{Cache, CacheConfig};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
/// Statut d'un téléchargement
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct DownloadStatus {
/// Clé primaire de l'item
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
pub pk: String,
/// Téléchargement en cours
pub in_progress: bool,
/// Taille actuelle téléchargée (source)
pub current_size: Option<u64>,
/// Taille après transformation
pub transformed_size: Option<u64>,
/// Taille totale attendue
pub expected_size: Option<u64>,
/// Téléchargement terminé
pub finished: bool,
/// Erreur éventuelle
pub error: Option<String>,
}
/// Requête pour ajouter un item au cache
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct AddItemRequest {
/// URL de la source
#[cfg_attr(feature = "openapi", schema(example = "https://example.com/file.dat"))]
pub url: String,
/// Collection optionnelle
#[cfg_attr(feature = "openapi", schema(example = "album:the_wall"))]
pub collection: Option<String>,
}
/// Réponse après ajout d'un item
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct AddItemResponse {
/// Clé primaire (pk) de l'item ajouté
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
pub pk: String,
/// URL source de l'item
#[cfg_attr(feature = "openapi", schema(example = "https://example.com/file.dat"))]
pub url: String,
/// Message de succès
#[cfg_attr(feature = "openapi", schema(example = "Item added successfully"))]
pub message: String,
}
/// Réponse de suppression d'un item
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct DeleteItemResponse {
/// Message de succès
#[cfg_attr(feature = "openapi", schema(example = "Item deleted successfully"))]
pub message: String,
}
/// Réponse d'erreur générique
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ErrorResponse {
/// Code d'erreur
#[cfg_attr(feature = "openapi", schema(example = "NOT_FOUND"))]
pub error: String,
/// Message descriptif
#[cfg_attr(feature = "openapi", schema(example = "Item not found in cache"))]
pub message: String,
}
/// Liste tous les items en cache avec leurs statistiques
///
/// Retourne la liste complète des entrées du cache triées par nombre d'accès décroissant.
pub async fn list_items<C: CacheConfig>(State(cache): State<Arc<Cache<C>>>) -> impl IntoResponse {
match cache.db.get_all() {
Ok(entries) => (StatusCode::OK, Json(entries)).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "DATABASE_ERROR".to_string(),
message: format!("Cannot retrieve cache entries: {}", e),
}),
)
.into_response(),
}
}
/// Récupère les informations d'un item spécifique
///
/// Retourne les métadonnées d'un item identifié par sa clé (pk).
pub async fn get_item_info<C: CacheConfig>(
State(cache): State<Arc<Cache<C>>>,
Path(pk): Path<String>,
) -> impl IntoResponse {
match cache.db.get(&pk) {
Ok(entry) => (StatusCode::OK, Json(entry)).into_response(),
Err(_) => (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "NOT_FOUND".to_string(),
message: format!("Item with pk '{}' not found in cache", pk),
}),
)
.into_response(),
}
}
/// Récupère le statut du téléchargement d'un item
///
/// Retourne le statut actuel du téléchargement (progression, tailles, erreurs).
/// Si le téléchargement est terminé, retourne les informations du fichier.
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).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 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 error = if let Some(download) = cache.get_download(&pk).await {
download.error().await
} else {
None
};
let status = DownloadStatus {
pk,
in_progress,
current_size,
transformed_size,
expected_size,
finished,
error,
};
(StatusCode::OK, Json(status)).into_response()
}
/// Ajoute un item au cache depuis une URL
///
/// Télécharge l'item depuis l'URL fournie et l'ajoute au cache.
/// Si l'item existe déjà, il est mis à jour.
pub async fn add_item<C: CacheConfig>(
State(cache): State<Arc<Cache<C>>>,
Json(req): Json<AddItemRequest>,
) -> impl IntoResponse {
if req.url.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "INVALID_REQUEST".to_string(),
message: "URL cannot be empty".to_string(),
}),
)
.into_response();
}
match cache
.add_from_url(&req.url, req.collection.as_deref())
.await
{
Ok(pk) => (
StatusCode::CREATED,
Json(AddItemResponse {
pk,
url: req.url,
message: "Item added successfully".to_string(),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "PROCESSING_ERROR".to_string(),
message: format!("Cannot add item: {}", e),
}),
)
.into_response(),
}
}
/// Supprime un item du cache
///
/// Supprime l'item et toutes ses variantes du disque et de la base de données.
pub async fn delete_item<C: CacheConfig>(
State(cache): State<Arc<Cache<C>>>,
Path(pk): Path<String>,
) -> impl IntoResponse {
// Vérifier que l'item existe
if cache.db.get(&pk).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();
}
// Supprimer tous les fichiers avec ce pk (toutes variantes)
let cache_dir = cache.cache_dir();
if let Ok(mut entries) = tokio::fs::read_dir(cache_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if let Some(filename) = entry.file_name().to_str() {
// Format: {pk}.{param}.{ext}
if filename.starts_with(&pk) && filename.starts_with(&format!("{}.", pk)) {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
}
// Supprimer de la base de données
match cache.db.delete(&pk) {
Ok(_) => (
StatusCode::OK,
Json(DeleteItemResponse {
message: format!("Item '{}' deleted successfully", pk),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "DATABASE_ERROR".to_string(),
message: format!("Cannot delete from database: {}", e),
}),
)
.into_response(),
}
}
/// Purge complètement le cache
///
/// Supprime tous les items et vide la base de données. Opération irréversible.
pub async fn purge_cache<C: CacheConfig>(State(cache): State<Arc<Cache<C>>>) -> impl IntoResponse {
match cache.purge().await {
Ok(_) => (
StatusCode::OK,
Json(DeleteItemResponse {
message: "Cache purged successfully".to_string(),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "PURGE_ERROR".to_string(),
message: format!("Cannot purge cache: {}", e),
}),
)
.into_response(),
}
}
/// Consolide le cache
///
/// Re-télécharge les items manquants et supprime les fichiers orphelins.
/// Utile pour réparer un cache corrompu.
pub async fn consolidate_cache<C: CacheConfig>(
State(cache): State<Arc<Cache<C>>>,
) -> impl IntoResponse {
match cache.consolidate().await {
Ok(_) => (
StatusCode::OK,
Json(DeleteItemResponse {
message: "Cache consolidated successfully".to_string(),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "CONSOLIDATE_ERROR".to_string(),
message: format!("Cannot consolidate cache: {}", e),
}),
)
.into_response(),
}
}

642
pmocache/src/cache.rs Normal file
View File

@@ -0,0 +1,642 @@
//! Module de gestion du cache générique
//!
//! Ce module fournit une interface générique pour gérer un cache de fichiers
//! avec métadonnées dans une base de données SQLite.
use crate::cache_trait::{pk_from_url, FileCache};
use crate::db::DB;
use crate::download::{
download_with_transformer, ingest_with_transformer, Download, StreamTransformer,
};
use anyhow::{anyhow, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::io::AsyncRead;
use tokio::sync::RwLock;
use tracing;
/// Trait pour définir les paramètres du cache
pub trait CacheConfig: Send + Sync {
/// Extension des fichiers (ex: "webp", "flac")
fn file_extension() -> &'static str;
/// Nom de la table dans la base de données (ex: "covers", "audio")
fn table_name() -> &'static str {
"cached_items"
}
/// Type de cache (ex: "audio", "image")
fn cache_type() -> &'static str {
"file"
}
/// Cache name (ex: "covers", "audio", "cache")
fn cache_name() -> &'static str {
"cache"
}
/// Default param extension ("orig")
fn default_param() -> &'static str {
"orig"
}
}
/// Cache générique pour stocker des fichiers avec métadonnées
///
/// Gère le téléchargement, le stockage et la récupération de fichiers
/// avec une base de données SQLite pour les métadonnées.
///
/// # Paramètres de type
///
/// * `C` - Configuration du cache (implémente `CacheConfig`)
///
/// Note : Ce type est conçu pour être utilisé derrière un `Arc<Cache>`.
/// La synchronisation est gérée par le Mutex interne de la base de données SQLite
/// et par le RwLock pour la map des downloads.
pub struct Cache<C: CacheConfig> {
/// Répertoire de stockage
dir: PathBuf,
/// Limite de taille du cache (nombre d'éléments)
limit: usize,
/// Base de données SQLite
pub db: Arc<DB>,
/// Map des downloads en cours (pk -> Download)
downloads: Arc<RwLock<HashMap<String, Arc<Download>>>>,
/// Factory pour créer des transformers (optionnel)
transformer_factory: Option<Arc<dyn Fn() -> StreamTransformer + Send + Sync>>,
/// Phantom data pour le type de configuration
_phantom: std::marker::PhantomData<C>,
}
impl<C: CacheConfig> Cache<C> {
/// Crée un nouveau cache sans transformer
///
/// # Arguments
///
/// * `dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (nombre d'éléments)
pub fn new(dir: &str, limit: usize) -> Result<Self> {
Self::with_transformer(dir, limit, None)
}
/// Crée un nouveau cache avec un transformer optionnel
///
/// # Arguments
///
/// * `dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (nombre d'éléments)
/// * `transformer_factory` - Factory pour créer des transformers à chaque téléchargement
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocache::{Cache, CacheConfig, StreamTransformer};
/// use std::sync::Arc;
///
/// struct MyConfig;
/// impl CacheConfig for MyConfig {
/// fn file_extension() -> &'static str { "dat" }
/// }
///
/// let transformer_factory = Arc::new(|| {
/// // Créer un transformer qui convertit les données
/// Box::new(|input, file, progress| {
/// Box::pin(async move {
/// // Transformation personnalisée
/// Ok(())
/// })
/// }) as StreamTransformer
/// });
///
/// let cache = Cache::<MyConfig>::with_transformer(
/// "./cache",
/// 1000,
/// Some(transformer_factory)
/// ).unwrap();
/// ```
pub fn with_transformer(
dir: &str,
limit: usize,
transformer_factory: Option<Arc<dyn Fn() -> StreamTransformer + Send + Sync>>,
) -> Result<Self> {
let directory = PathBuf::from(dir);
std::fs::create_dir_all(&directory)?;
let db = DB::init(&directory.join("cache.db"), C::table_name())?;
Ok(Self {
dir: directory,
limit,
db: Arc::new(db),
downloads: Arc::new(RwLock::new(HashMap::new())),
transformer_factory,
_phantom: std::marker::PhantomData,
})
}
/// Télécharge un fichier depuis une URL et l'ajoute au cache
///
/// Utilise le module download pour gérer le téléchargement asynchrone.
/// Le download est tracké dans la map jusqu'à sa fin.
///
/// # Arguments
///
/// * `url` - URL du fichier à télécharger
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
///
/// # Returns
///
/// La clé primaire (pk) du fichier dans le cache
pub async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
let pk = pk_from_url(url);
let file_path = self.file_path(&pk);
// Vérifier si déjà en cours de téléchargement
{
let downloads = self.downloads.read().await;
if downloads.contains_key(&pk) {
// Download déjà en cours, retourner la clé
return Ok(pk);
}
}
// Lancer le téléchargement avec transformer
let transformer = self.transformer_factory.as_ref().map(|f| f());
let download = download_with_transformer(&file_path, url, transformer);
// Stocker dans la map des downloads en cours
{
let mut downloads = self.downloads.write().await;
downloads.insert(pk.clone(), download.clone());
}
// Ajouter immédiatement à la DB
self.db.add(&pk, url, collection)?;
// Appliquer la politique d'éviction LRU si nécessaire
// Cela garantit que le cache respecte toujours la limite configurée
if let Err(e) = self.enforce_limit().await {
tracing::warn!("Error enforcing cache limit: {}", e);
}
// Lancer une tâche de nettoyage en background
let downloads_clone = self.downloads.clone();
let pk_clone = pk.clone();
tokio::spawn(async move {
// Attendre la fin du téléchargement
let _ = download.wait_until_finished().await;
// Retirer de la map
downloads_clone.write().await.remove(&pk_clone);
});
Ok(pk)
}
/// Ajoute un fichier à partir d'un flux asynchrone.
///
/// Le flux peut provenir de n'importe quelle source (stream HTTP custom, décodeur,
/// extraction en mémoire, etc.). Les mêmes transformers que `add_from_url` sont
/// appliqués.
///
/// # Arguments
///
/// * `source_uri` - Identifiant logique du flux (utilisé pour générer le pk)
/// * `reader` - Flux asynchrone fournissant les données
/// * `length` - Taille attendue (si connue)
/// * `collection` - Collection optionnelle à laquelle appartient l'élément
pub async fn add_from_reader<R>(
&self,
source_uri: &str,
reader: R,
length: Option<u64>,
collection: Option<&str>,
) -> Result<String>
where
R: AsyncRead + Send + Unpin + 'static,
{
let pk = pk_from_url(source_uri);
let file_path = self.file_path(&pk);
{
let downloads = self.downloads.read().await;
if downloads.contains_key(&pk) {
return Ok(pk);
}
}
let transformer = self.transformer_factory.as_ref().map(|factory| factory());
let download = ingest_with_transformer(&file_path, reader, length, transformer);
{
let mut downloads = self.downloads.write().await;
downloads.insert(pk.clone(), download.clone());
}
self.db.add(&pk, source_uri, collection)?;
if let Err(e) = self.enforce_limit().await {
tracing::warn!("Error enforcing cache limit: {}", e);
}
let downloads_clone = self.downloads.clone();
let pk_clone = pk.clone();
tokio::spawn(async move {
let _ = download.wait_until_finished().await;
downloads_clone.write().await.remove(&pk_clone);
});
Ok(pk)
}
/// Ajoute un fichier local au cache
///
/// Le fichier est copié dans le cache via une URL file://
///
/// # Arguments
///
/// * `path` - Chemin du fichier local
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
///
/// # Returns
///
/// La clé primaire (pk) du fichier dans le cache
pub async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String> {
let canonical_path = std::fs::canonicalize(path)?;
let file_url = format!("file://{}", canonical_path.display());
let length = tokio::fs::metadata(&canonical_path)
.await
.ok()
.map(|m| m.len());
let reader = tokio::fs::File::open(&canonical_path).await?;
self.add_from_reader(&file_url, reader, length, collection)
.await
}
/// S'assure qu'un fichier est présent dans le cache
///
/// Si le fichier existe déjà, retourne sa clé. Sinon, le télécharge.
///
/// # Arguments
///
/// * `url` - URL du fichier
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
pub async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
let pk = pk_from_url(url);
if self.db.get(&pk).is_ok() {
let file_path = self.file_path(&pk);
if file_path.exists() {
return Ok(pk);
}
}
self.add_from_url(url, collection).await
}
/// Récupère le chemin d'un fichier dans le cache
///
/// # Arguments
///
/// * `pk` - Clé primaire du fichier
pub async fn get(&self, pk: &str) -> Result<PathBuf> {
self.db.get(pk)?;
self.db.update_hit(pk)?;
let file_path = self.file_path(pk);
if file_path.exists() {
Ok(file_path)
} else {
Err(anyhow!("File not found"))
}
}
/// Récupère tous les fichiers d'une collection
///
/// # Arguments
///
/// * `collection` - Identifiant de la collection
pub async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>> {
let entries = self.db.get_by_collection(collection)?;
let mut paths = Vec::new();
for entry in entries {
let path = self.file_path(&entry.pk);
if path.exists() {
paths.push(path);
}
}
Ok(paths)
}
/// Supprime tous les fichiers et entrées du cache
pub async fn purge(&self) -> Result<()> {
let mut entries = tokio::fs::read_dir(&self.dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.path().is_file() && entry.path() != self.dir.join("cache.db") {
tokio::fs::remove_file(entry.path()).await?;
}
}
self.db
.purge()
.map_err(|e| anyhow!("Database error: {}", e))
}
/// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants
pub async fn consolidate(&self) -> Result<()> {
// Récupérer la liste des entrées à traiter
let entries = self.db.get_all()?;
// Supprimer les entrées sans fichiers correspondants
for entry in entries {
let file_path = self.file_path(&entry.pk);
if !file_path.exists() {
// Re-télécharger le fichier manquant
match self
.add_from_url(&entry.source_url, entry.collection.as_deref())
.await
{
Ok(_) => {}
Err(_) => {
// Si le téléchargement échoue, supprimer l'entrée DB
self.db.delete(&entry.pk)?;
}
}
}
}
// Supprimer les fichiers sans entrées DB correspondantes
let mut dir_entries = tokio::fs::read_dir(&self.dir).await?;
while let Some(entry) = dir_entries.next_entry().await? {
let path = entry.path();
if path.is_file() && path != self.dir.join("cache.db") {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
// Format attendu: {pk}.{qualifier}.{EXT}
// On extrait le pk (première partie avant le premier point)
if let Some(pk) = file_name.split('.').next() {
if self.db.get(pk).is_err() {
tokio::fs::remove_file(path).await?;
}
}
}
}
}
Ok(())
}
/// Récupère l'objet Download pour un pk donné (si en cours)
///
/// # Arguments
///
/// * `pk` - Clé primaire du fichier
///
/// # Returns
///
/// Some(Download) si le téléchargement est en cours, None sinon
pub async fn get_download(&self, pk: &str) -> Option<Arc<Download>> {
let downloads = self.downloads.read().await;
downloads.get(pk).cloned()
}
/// Retourne la taille actuelle téléchargée (source)
///
/// Si le download est en cours, retourne la taille téléchargée.
/// Sinon, retourne la taille du fichier sur disque.
///
/// # Arguments
///
/// * `pk` - Clé primaire du fichier
pub async fn current_size(&self, pk: &str) -> Option<u64> {
if let Some(download) = self.get_download(pk).await {
Some(download.current_size().await)
} else {
// Fichier terminé, lire la taille du fichier
let file_path = self.file_path(pk);
if file_path.exists() {
std::fs::metadata(file_path).ok().map(|m| m.len())
} else {
None
}
}
}
/// Retourne la taille des données transformées
///
/// Si le download est en cours, retourne la taille transformée.
/// Sinon, retourne la taille du fichier sur disque.
///
/// # Arguments
///
/// * `pk` - Clé primaire du fichier
pub async fn transformed_size(&self, pk: &str) -> Option<u64> {
if let Some(download) = self.get_download(pk).await {
Some(download.transformed_size().await)
} else {
// Fichier terminé, lire la taille du fichier
let file_path = self.file_path(pk);
if file_path.exists() {
std::fs::metadata(file_path).ok().map(|m| m.len())
} else {
None
}
}
}
/// Retourne la taille attendue du fichier (si disponible)
///
/// # Arguments
///
/// * `pk` - Clé primaire du fichier
pub async fn expected_size(&self, pk: &str) -> Option<u64> {
if let Some(download) = self.get_download(pk).await {
download.expected_size().await
} else {
// Fichier terminé, la taille finale est la taille du fichier
self.transformed_size(pk).await
}
}
/// Indique si le téléchargement est terminé
///
/// # Arguments
///
/// * `pk` - Clé primaire du fichier
pub async fn is_finished(&self, pk: &str) -> bool {
if let Some(download) = self.get_download(pk).await {
download.finished().await
} else {
// Pas dans la map = terminé (ou n'existe pas)
self.file_path(pk).exists()
}
}
/// Attend qu'un fichier atteigne au moins une taille minimale
///
/// # Arguments
///
/// * `pk` - Clé primaire du fichier
/// * `min_size` - Taille minimale attendue en bytes
pub async fn wait_until_min_size(&self, pk: &str, min_size: u64) -> Result<()> {
if let Some(download) = self.get_download(pk).await {
download
.wait_until_min_size(min_size)
.await
.map_err(|e| anyhow!("Download error: {}", e))
} else {
// Déjà terminé ou n'existe pas
if self.file_path(pk).exists() {
Ok(())
} else {
Err(anyhow!("File not found"))
}
}
}
/// Attend que le téléchargement soit complètement terminé
///
/// # Arguments
///
/// * `pk` - Clé primaire du fichier
pub async fn wait_until_finished(&self, pk: &str) -> Result<()> {
if let Some(download) = self.get_download(pk).await {
download
.wait_until_finished()
.await
.map_err(|e| anyhow!("Download error: {}", e))
} else {
// Déjà terminé ou n'existe pas
if self.file_path(pk).exists() {
Ok(())
} else {
Err(anyhow!("File not found"))
}
}
}
/// Retourne le répertoire du cache
pub fn cache_dir(&self) -> &Path {
&self.dir
}
/// Construit le chemin complet d'un fichier dans le cache avec le param par défaut
///
/// Format: `{pk}.{default_param}.{extension}`
pub fn file_path(&self, pk: &str) -> PathBuf {
self.file_path_with_qualifier(pk, C::default_param())
}
/// Construit le chemin d'un fichier dans le cache avec un qualificatif
///
/// Format: `{pk}.{qualifier}.{extension}`
pub fn file_path_with_qualifier(&self, pk: &str, qualifier: &str) -> PathBuf {
self.dir
.join(format!("{}.{}.{}", pk, qualifier, C::file_extension()))
}
/// Valide les données avant de les stocker
/// Par défaut, accepte toutes les données
pub fn validate_data(&self, data: &[u8]) -> Result<Vec<u8>> {
Ok(data.to_vec())
}
/// Applique la politique d'éviction LRU (Least Recently Used)
///
/// Si le nombre d'entrées dépasse la limite configurée, supprime
/// les entrées les plus anciennes (moins récemment utilisées).
///
/// Cette méthode :
/// 1. Compte le nombre total d'entrées
/// 2. Si > limit, récupère les N entrées les plus anciennes
/// 3. Supprime ces entrées de la DB et leurs fichiers du disque
///
/// # Returns
///
/// Le nombre d'entrées supprimées
pub async fn enforce_limit(&self) -> Result<usize> {
let count = self.db.count()?;
if count <= self.limit {
return Ok(0);
}
let to_remove = count - self.limit;
let old_entries = self.db.get_oldest(to_remove)?;
let mut removed = 0;
for entry in old_entries {
// Supprimer tous les fichiers avec ce pk (toutes variantes)
if let Ok(mut dir_entries) = tokio::fs::read_dir(&self.dir).await {
while let Ok(Some(dir_entry)) = dir_entries.next_entry().await {
if let Some(filename) = dir_entry.file_name().to_str() {
// Format: {pk}.{param}.{ext}
if filename.starts_with(&entry.pk)
&& filename.starts_with(&format!("{}.", entry.pk))
{
let _ = tokio::fs::remove_file(dir_entry.path()).await;
}
}
}
}
// Supprimer de la base de données
if let Err(e) = self.db.delete(&entry.pk) {
tracing::warn!("Error deleting entry {} from DB: {}", entry.pk, e);
} else {
removed += 1;
}
}
if removed > 0 {
tracing::info!(
"LRU eviction: removed {} old entries (cache size: {} -> {})",
removed,
count,
count - removed
);
}
Ok(removed)
}
}
/// Implémentation du trait FileCache pour Cache
impl<C: CacheConfig> FileCache<C> for Cache<C> {
fn get_cache_dir(&self) -> &Path {
self.cache_dir()
}
fn get_database(&self) -> Arc<DB> {
self.db.clone()
}
fn validate_data(&self, data: &[u8]) -> Result<Vec<u8>> {
// Le cache générique accepte toutes les données
Ok(data.to_vec())
}
async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
self.add_from_url(url, collection).await
}
async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String> {
self.add_from_file(path, collection).await
}
async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
self.ensure_from_url(url, collection).await
}
async fn get(&self, pk: &str) -> Result<PathBuf> {
self.get(pk).await
}
async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>> {
self.get_collection(collection).await
}
async fn purge(&self) -> Result<()> {
self.purge().await
}
async fn consolidate(&self) -> Result<()> {
self.consolidate().await
}
}

158
pmocache/src/cache_trait.rs Normal file
View File

@@ -0,0 +1,158 @@
use anyhow::Result;
use sha1::{Digest, Sha1};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use crate::{CacheConfig, DB};
/// Trait générique pour les caches de fichiers
///
/// Définit l'interface commune pour tous les types de caches (images, audio, etc.)
pub trait FileCache<C: CacheConfig>: Send + Sync {
fn get_cache_dir(&self) -> &Path;
fn get_database(&self) -> Arc<DB>;
/// Valide les données avant de les stocker dans le cache
///
/// Cette méthode peut être surchargée pour vérifier le type MIME,
/// le magic number, ou effectuer des conversions (ex: WebP, FLAC)
///
/// # Arguments
///
/// * `data` - Données brutes à valider
///
/// # Returns
///
/// Les données validées/converties ou une erreur
fn validate_data(&self, data: &[u8]) -> Result<Vec<u8>> {
// Par défaut, on accepte les données telles quelles
Ok(data.to_vec())
}
/// Retourne le type de cache
fn cache_type(&self) -> &'static str {
C::cache_type()
}
/// Retourne le nom du cache
fn cache_name(&self) -> &'static str {
C::cache_name()
}
/// Retourne le paramètre par défaut
fn default_param(&self) -> &'static str {
C::default_param()
}
/// Retourne l'extension des fichiers
fn file_extension(&self) -> &'static str {
C::file_extension()
}
/// Retourne le nom de la table
fn table_name(&self) -> &'static str {
C::table_name()
}
/// Construit le chemin complet d'un fichier dans le cache
///
/// Format: `{pk}.{qualificatif}.{extension}`
/// Pour le fichier original: `{pk}.orig.{extension}`
fn file_path(&self, pk: &str) -> PathBuf {
self.file_path_with_qualifier(pk, self.default_param())
}
/// Construit le chemin d'un fichier avec un qualificatif
///
/// Format: `{pk}.{qualificatif}.{extension}`
fn file_path_with_qualifier(&self, pk: &str, qualifier: &str) -> PathBuf {
self.get_cache_dir()
.join(format!("{}.{}.{}", pk, qualifier, C::file_extension()))
}
/// Retourne la route relative pour accéder à un item du cache
///
/// # Arguments
///
/// * `pk` - Clé primaire de la piste
/// * `param` - Paramètre optionnel (ex: "orig", "128k", etc.)
///
/// # Returns
///
/// Route relative (ex: "/audio/flac/abc123" ou "/audio/tracks/abc123/orig")
fn route_for(&self, pk: &str, param: Option<&str>) -> String {
if let Some(p) = param {
format!("/{}/{}/{}/{}", C::cache_name(), C::cache_type(), pk, p)
} else {
format!("/{}/{}/{}", C::cache_name(), C::cache_type(), pk)
}
}
/// Télécharge un fichier depuis une URL et l'ajoute au cache
///
/// # Arguments
///
/// * `url` - URL du fichier à télécharger
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
///
/// # Returns
///
/// La clé primaire (pk) du fichier dans le cache
async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
/// Ajoute un fichier local au cache
///
/// Le fichier est copié dans le cache via une URL file://
///
/// # Arguments
///
/// * `path` - Chemin du fichier local
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
///
/// # Returns
///
/// La clé primaire (pk) du fichier dans le cache
async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
/// S'assure qu'un fichier est présent dans le cache
///
/// Si le fichier existe déjà, retourne sa clé. Sinon, le télécharge.
///
/// # Arguments
///
/// * `url` - URL du fichier
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
/// Récupère le chemin d'un fichier dans le cache
///
/// # Arguments
///
/// * `pk` - Clé primaire du fichier
async fn get(&self, pk: &str) -> Result<PathBuf>;
/// Récupère tous les fichiers d'une collection
///
/// # Arguments
///
/// * `collection` - Identifiant de la collection
async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
/// Supprime tous les fichiers et entrées du cache
async fn purge(&self) -> Result<()>;
/// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants
async fn consolidate(&self) -> Result<()>;
}
/// Génère une clé primaire à partir d'une URL
///
/// Utilise SHA1 pour hasher l'URL et retourne les 8 premiers octets en hexadécimal.
pub fn pk_from_url(url: &str) -> String {
let mut hasher = Sha1::new();
hasher.update(url.as_bytes());
let result = hasher.finalize();
hex::encode(&result[..8])
}

371
pmocache/src/db.rs Normal file
View File

@@ -0,0 +1,371 @@
//! Module de gestion de la base de données SQLite pour le cache
//!
//! Ce module fournit une interface générique pour gérer les métadonnées
//! des éléments en cache, avec tracking des accès et des statistiques.
use chrono::Utc;
use rusqlite::{params, Connection};
use serde::Serialize;
use std::path::Path;
use std::sync::Mutex;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
/// Entrée de cache représentant un élément dans la base de données
#[derive(Debug, Serialize, Clone)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct CacheEntry {
/// Clé primaire unique de l'élément (hash SHA1 de l'URL)
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
pub pk: String,
/// URL source de l'élément
#[cfg_attr(feature = "openapi", schema(example = "https://example.com/resource"))]
pub source_url: String,
/// Collection à laquelle appartient l'élément (optionnel)
#[cfg_attr(feature = "openapi", schema(example = "album:123"))]
pub collection: Option<String>,
/// Nombre d'accès à l'élément
#[cfg_attr(feature = "openapi", schema(example = 42))]
pub hits: i32,
/// Date/heure du dernier accès (RFC3339)
#[cfg_attr(feature = "openapi", schema(example = "2025-01-15T10:30:00Z"))]
pub last_used: Option<String>,
/// Métadonnées JSON optionnelles (ex: métadonnées audio, EXIF images, etc.)
#[cfg_attr(
feature = "openapi",
schema(example = r#"{"title":"Track","artist":"Artist"}"#)
)]
pub metadata_json: Option<String>,
}
/// Base de données SQLite pour le cache
///
/// Gère les métadonnées des éléments en cache :
/// - Clés primaires (pk) et URLs sources
/// - Statistiques d'utilisation (hits, last_used)
/// - Opérations CRUD de base
#[derive(Debug)]
pub struct DB {
conn: Mutex<Connection>,
table_name: String,
}
impl DB {
/// Initialise une nouvelle base de données avec une table personnalisée
///
/// # Arguments
///
/// * `path` - Chemin vers le fichier de base de données SQLite
/// * `table_name` - Nom de la table à créer
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocache::db::DB;
/// use std::path::Path;
///
/// let db = DB::init(Path::new("cache.db"), "my_cache").unwrap();
/// ```
pub fn init(path: &Path, table_name: &str) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
let create_table_sql = format!(
"CREATE TABLE IF NOT EXISTS {} (
pk TEXT PRIMARY KEY,
source_url TEXT,
collection TEXT,
hits INTEGER DEFAULT 0,
last_used TEXT,
metadata_json TEXT
)",
table_name
);
conn.execute(&create_table_sql, [])?;
// Créer un index sur la collection pour les requêtes rapides
let create_index_sql = format!(
"CREATE INDEX IF NOT EXISTS idx_{}_collection ON {} (collection)",
table_name, table_name
);
conn.execute(&create_index_sql, [])?;
// Créer un index composite pour optimiser la politique LRU (get_oldest)
let create_lru_index_sql = format!(
"CREATE INDEX IF NOT EXISTS idx_{}_lru ON {} (last_used ASC, hits ASC)",
table_name, table_name
);
conn.execute(&create_lru_index_sql, [])?;
Ok(Self {
conn: Mutex::new(conn),
table_name: table_name.to_string(),
})
}
/// Ajoute ou met à jour une entrée dans la base de données
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'élément
/// * `url` - URL source de l'élément
/// * `collection` - Collection optionnelle à laquelle appartient l'élément
pub fn add(&self, pk: &str, url: &str, collection: Option<&str>) -> rusqlite::Result<()> {
self.add_with_metadata(pk, url, collection, None)
}
/// Ajoute ou met à jour une entrée avec métadonnées JSON optionnelles
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'élément
/// * `url` - URL source de l'élément
/// * `collection` - Collection optionnelle à laquelle appartient l'élément
/// * `metadata_json` - Métadonnées JSON optionnelles
pub fn add_with_metadata(
&self,
pk: &str,
url: &str,
collection: Option<&str>,
metadata_json: Option<&str>,
) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
let sql = format!(
"INSERT INTO {} (pk, source_url, collection, hits, last_used, metadata_json)
VALUES (?1, ?2, ?3, 0, ?4, ?5)
ON CONFLICT(pk) DO UPDATE SET
source_url = excluded.source_url,
collection = excluded.collection,
last_used = excluded.last_used,
metadata_json = excluded.metadata_json",
self.table_name
);
conn.execute(
&sql,
params![pk, url, collection, Utc::now().to_rfc3339(), metadata_json],
)?;
Ok(())
}
/// Récupère une entrée de la base de données par sa clé
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'élément à récupérer
pub fn get(&self, pk: &str) -> rusqlite::Result<CacheEntry> {
let conn = self.conn.lock().unwrap();
let sql = format!(
"SELECT pk, source_url, collection, hits, last_used, metadata_json FROM {} WHERE pk = ?1",
self.table_name
);
conn.query_row(&sql, [pk], |row| {
Ok(CacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
metadata_json: row.get(5)?,
})
})
}
/// Met à jour le compteur d'accès et la date du dernier accès
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'élément
pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
let sql = format!(
"UPDATE {} SET hits = hits + 1, last_used = ?1 WHERE pk = ?2",
self.table_name
);
conn.execute(&sql, params![Utc::now().to_rfc3339(), pk])?;
Ok(())
}
/// Purge toutes les entrées de la base de données
pub fn purge(&self) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
let sql = format!("DELETE FROM {}", self.table_name);
conn.execute(&sql, [])?;
Ok(())
}
/// Récupère toutes les entrées, triées par nombre d'accès décroissant
pub fn get_all(&self) -> rusqlite::Result<Vec<CacheEntry>> {
let conn = self.conn.lock().unwrap();
let sql = format!(
"SELECT pk, source_url, collection, hits, last_used, metadata_json FROM {} ORDER BY hits DESC",
self.table_name
);
let mut stmt = conn.prepare(&sql)?;
let entries = stmt
.query_map([], |row| {
Ok(CacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
metadata_json: row.get(5)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
/// Récupère toutes les entrées d'une collection spécifique
///
/// # Arguments
///
/// * `collection` - Identifiant de la collection
pub fn get_by_collection(&self, collection: &str) -> rusqlite::Result<Vec<CacheEntry>> {
let conn = self.conn.lock().unwrap();
let sql = format!(
"SELECT pk, source_url, collection, hits, last_used, metadata_json FROM {} WHERE collection = ?1 ORDER BY hits DESC",
self.table_name
);
let mut stmt = conn.prepare(&sql)?;
let entries = stmt
.query_map([collection], |row| {
Ok(CacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
metadata_json: row.get(5)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
/// Supprime toutes les entrées d'une collection
///
/// # Arguments
///
/// * `collection` - Identifiant de la collection à supprimer
pub fn delete_collection(&self, collection: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
let sql = format!("DELETE FROM {} WHERE collection = ?1", self.table_name);
conn.execute(&sql, [collection])?;
Ok(())
}
/// Supprime une entrée de la base de données
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'élément à supprimer
pub fn delete(&self, pk: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
let sql = format!("DELETE FROM {} WHERE pk = ?1", self.table_name);
conn.execute(&sql, [pk])?;
Ok(())
}
/// Compte le nombre total d'entrées dans le cache
///
/// # Returns
///
/// Le nombre total d'entrées
pub fn count(&self) -> rusqlite::Result<usize> {
let conn = self.conn.lock().unwrap();
let sql = format!("SELECT COUNT(*) FROM {}", self.table_name);
let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?;
Ok(count as usize)
}
/// Récupère les N entrées les plus anciennes (LRU - Least Recently Used)
///
/// Trie par last_used (les plus anciens en premier), puis par hits (les moins utilisés).
/// Utile pour implémenter une politique d'éviction LRU.
///
/// # Arguments
///
/// * `limit` - Nombre maximum d'entrées à récupérer
///
/// # Returns
///
/// Liste des entrées les plus anciennes, triées par last_used ASC
pub fn get_oldest(&self, limit: usize) -> rusqlite::Result<Vec<CacheEntry>> {
let conn = self.conn.lock().unwrap();
let sql = format!(
"SELECT pk, source_url, collection, hits, last_used, metadata_json
FROM {}
ORDER BY last_used ASC, hits ASC
LIMIT ?1",
self.table_name
);
let mut stmt = conn.prepare(&sql)?;
let entries = stmt
.query_map([limit], |row| {
Ok(CacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
metadata_json: row.get(5)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
/// Récupère uniquement les métadonnées JSON d'une entrée
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'élément
///
/// # Returns
///
/// Les métadonnées JSON si présentes, None sinon
pub fn get_metadata_json(&self, pk: &str) -> rusqlite::Result<Option<String>> {
let conn = self.conn.lock().unwrap();
let sql = format!(
"SELECT metadata_json FROM {} WHERE pk = ?1",
self.table_name
);
conn.query_row(&sql, [pk], |row| row.get(0))
}
/// Met à jour uniquement les métadonnées JSON d'une entrée existante
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'élément
/// * `metadata_json` - Métadonnées JSON à stocker
pub fn update_metadata(&self, pk: &str, metadata_json: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
let sql = format!(
"UPDATE {} SET metadata_json = ?1 WHERE pk = ?2",
self.table_name
);
conn.execute(&sql, params![metadata_json, pk])?;
Ok(())
}
}

462
pmocache/src/download.rs Normal file
View File

@@ -0,0 +1,462 @@
use bytes::Bytes;
use futures_util::{stream, Future, Stream, StreamExt};
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::sync::RwLock;
use tokio_util::io::ReaderStream;
/// Type pour une fonction de transformation de stream.
///
/// 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
///
/// 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>,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>>
+ Send,
>;
type ByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>>;
/// Source générique (HTTP ou lecteur) exposée aux transformers.
pub struct CacheInput {
inner: CacheInputInner,
}
enum CacheInputInner {
Http {
response: Option<reqwest::Response>,
buffer: Option<Bytes>,
length: Option<u64>,
},
Reader {
reader: Option<Box<dyn AsyncRead + Send + Unpin>>,
buffer: Option<Bytes>,
length: Option<u64>,
},
}
impl CacheInput {
pub fn from_response(response: reqwest::Response) -> Self {
let length = response.content_length();
Self {
inner: CacheInputInner::Http {
response: Some(response),
buffer: None,
length,
},
}
}
pub fn from_reader<R>(reader: R, length: Option<u64>) -> Self
where
R: AsyncRead + Send + Unpin + 'static,
{
Self::from_reader_box(Box::new(reader), length)
}
pub fn from_reader_box(reader: Box<dyn AsyncRead + Send + Unpin>, length: Option<u64>) -> Self {
Self {
inner: CacheInputInner::Reader {
reader: Some(reader),
buffer: None,
length,
},
}
}
pub fn content_length(&self) -> Option<u64> {
match &self.inner {
CacheInputInner::Http { length, buffer, .. } => {
length.or_else(|| buffer.as_ref().map(|b| b.len() as u64))
}
CacheInputInner::Reader { length, buffer, .. } => {
length.or_else(|| buffer.as_ref().map(|b| b.len() as u64))
}
}
}
pub async fn bytes(&mut self) -> Result<Bytes, String> {
match &mut self.inner {
CacheInputInner::Http {
response, buffer, ..
} => {
if let Some(bytes) = buffer.clone() {
return Ok(bytes);
}
let resp = response
.take()
.ok_or_else(|| "stream already consumed".to_string())?;
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
*buffer = Some(bytes.clone());
Ok(bytes)
}
CacheInputInner::Reader { reader, buffer, .. } => {
if let Some(bytes) = buffer.clone() {
return Ok(bytes);
}
let mut reader = reader
.take()
.ok_or_else(|| "stream already consumed".to_string())?;
let mut data = Vec::new();
reader
.read_to_end(&mut data)
.await
.map_err(|e| e.to_string())?;
let bytes = Bytes::from(data);
*buffer = Some(bytes.clone());
Ok(bytes)
}
}
}
pub fn into_byte_stream(self) -> ByteStream {
match self.inner {
CacheInputInner::Http {
response, buffer, ..
} => {
if let Some(response) = response {
Box::pin(
response
.bytes_stream()
.map(|res| res.map_err(|e| e.to_string())),
)
} else if let Some(bytes) = buffer {
Box::pin(stream::once(async move { Ok(bytes) }))
} else {
Box::pin(stream::once(async {
Err("stream already consumed".to_string())
}))
}
}
CacheInputInner::Reader { reader, buffer, .. } => {
if let Some(bytes) = buffer {
Box::pin(stream::once(async move { Ok(bytes) }))
} else if let Some(reader) = reader {
let stream = ReaderStream::new(reader);
Box::pin(stream.map(|res| {
res.map(Bytes::from)
.map_err(|e| format!("Stream read error: {}", e))
}))
} else {
Box::pin(stream::once(async {
Err("stream already consumed".to_string())
}))
}
}
}
}
}
enum DownloadSource {
Url(String),
Reader {
reader: Box<dyn AsyncRead + Send + Unpin>,
length: Option<u64>,
},
}
/// État interne du téléchargement
#[derive(Debug, Clone)]
struct DownloadState {
current_size: u64,
expected_size: Option<u64>,
transformed_size: u64,
finished: bool,
read_position: u64,
error: Option<String>,
}
/// Objet représentant un téléchargement en cours
#[derive(Debug)]
pub struct Download {
filename: PathBuf,
state: Arc<RwLock<DownloadState>>,
}
impl Download {
fn new(filename: PathBuf) -> Arc<Self> {
Arc::new(Self {
filename,
state: Arc::new(RwLock::new(DownloadState {
current_size: 0,
expected_size: None,
transformed_size: 0,
finished: false,
read_position: 0,
error: None,
})),
})
}
pub fn filename(&self) -> &Path {
&self.filename
}
pub async fn wait_until_min_size(&self, min_size: u64) -> Result<(), String> {
loop {
let state = self.state.read().await;
if let Some(err) = &state.error {
return Err(err.clone());
}
if state.transformed_size >= min_size || state.finished {
return Ok(());
}
drop(state);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
pub async fn wait_until_finished(&self) -> Result<(), String> {
loop {
let state = self.state.read().await;
if let Some(err) = &state.error {
return Err(err.clone());
}
if state.finished {
return Ok(());
}
drop(state);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
pub fn open(&self) -> io::Result<File> {
File::open(&self.filename)
}
pub async fn pos(&self) -> u64 {
let state = self.state.read().await;
state.read_position
}
pub async fn set_pos(&self, pos: u64) {
let mut state = self.state.write().await;
state.read_position = pos;
}
pub async fn expected_size(&self) -> Option<u64> {
let state = self.state.read().await;
state.expected_size
}
pub async fn current_size(&self) -> u64 {
let state = self.state.read().await;
state.current_size
}
pub async fn transformed_size(&self) -> u64 {
let state = self.state.read().await;
state.transformed_size
}
pub async fn finished(&self) -> bool {
let state = self.state.read().await;
state.finished
}
pub async fn error(&self) -> Option<String> {
let state = self.state.read().await;
state.error.clone()
}
}
/// Lance le téléchargement d'une URL dans un fichier.
pub fn download<P: AsRef<Path>>(filename: P, url: &str) -> Arc<Download> {
download_with_transformer(filename, url, None)
}
/// Lance le téléchargement d'une URL avec transformation du stream.
pub fn download_with_transformer<P: AsRef<Path>>(
filename: P,
url: &str,
transformer: Option<StreamTransformer>,
) -> Arc<Download> {
spawn_download(filename, DownloadSource::Url(url.to_string()), transformer)
}
/// Ingère un flux (AsyncRead) dans le cache avec transformation optionnelle.
pub fn ingest_with_transformer<P, R>(
filename: P,
reader: R,
length: Option<u64>,
transformer: Option<StreamTransformer>,
) -> Arc<Download>
where
P: AsRef<Path>,
R: AsyncRead + Send + Unpin + 'static,
{
spawn_download(
filename,
DownloadSource::Reader {
reader: Box::new(reader),
length,
},
transformer,
)
}
fn spawn_download<P: AsRef<Path>>(
filename: P,
source: DownloadSource,
transformer: Option<StreamTransformer>,
) -> Arc<Download> {
let filename = filename.as_ref().to_path_buf();
let download = Download::new(filename.clone());
let state = Arc::clone(&download.state);
tokio::spawn(async move {
if let Err(e) = download_impl(filename, source, state, transformer).await {
tracing::error!("Download error: {}", e);
}
});
download
}
async fn download_impl(
filename: PathBuf,
source: DownloadSource,
state: Arc<RwLock<DownloadState>>,
transformer: Option<StreamTransformer>,
) -> Result<(), String> {
let input = match source {
DownloadSource::Url(url) => {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.build()
.map_err(|e| e.to_string())?;
let response = match client.get(&url).send().await {
Ok(resp) => resp,
Err(e) => {
let mut s = state.write().await;
let error = format!("Failed to fetch URL: {}", e);
s.error = Some(error.clone());
s.finished = true;
return Err(error);
}
};
if !response.status().is_success() {
let mut s = state.write().await;
let error = format!("HTTP error: {}", response.status());
s.error = Some(error.clone());
s.finished = true;
return Err(error);
}
let length = response.content_length();
{
let mut s = state.write().await;
s.expected_size = length;
}
CacheInput::from_response(response)
}
DownloadSource::Reader { reader, length } => {
{
let mut s = state.write().await;
s.expected_size = length;
}
CacheInput::from_reader_box(reader, length)
}
};
let file = tokio::fs::File::create(&filename)
.await
.map_err(|e| format!("Failed to create file: {}", e))?;
process_input(input, file, state, transformer).await
}
async fn process_input(
input: CacheInput,
file: tokio::fs::File,
state: Arc<RwLock<DownloadState>>,
transformer: Option<StreamTransformer>,
) -> Result<(), String> {
if let Some(transformer) = transformer {
let progress_state = Arc::clone(&state);
let progress_callback: Arc<dyn Fn(u64) + Send + Sync> =
Arc::new(move |transformed_bytes| {
let progress_state = Arc::clone(&progress_state);
tokio::spawn(async move {
let mut s = progress_state.write().await;
s.transformed_size = transformed_bytes;
});
});
match transformer(input, file, Arc::clone(&progress_callback)).await {
Ok(_) => {
let mut s = state.write().await;
if s.current_size == 0 {
s.current_size = s.transformed_size;
}
s.finished = true;
Ok(())
}
Err(e) => {
let mut s = state.write().await;
s.error = Some(e.clone());
s.finished = true;
Err(e)
}
}
} else {
default_copy(input, file, state).await
}
}
async fn default_copy(
input: CacheInput,
mut file: tokio::fs::File,
state: Arc<RwLock<DownloadState>>,
) -> Result<(), String> {
use tokio::io::AsyncWriteExt;
let mut stream = input.into_byte_stream();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result?;
if let Err(e) = file.write_all(&chunk).await {
let mut s = state.write().await;
let error = format!("Failed to write to file: {}", e);
s.error = Some(error.clone());
s.finished = true;
return Err(error);
}
let mut s = state.write().await;
let len = chunk.len() as u64;
s.current_size += len;
s.transformed_size += len;
}
if let Err(e) = file.flush().await {
let mut s = state.write().await;
let error = format!("Failed to flush file: {}", e);
s.error = Some(error.clone());
s.finished = true;
return Err(error);
}
let mut s = state.write().await;
s.finished = true;
Ok(())
}

150
pmocache/src/lib.rs Normal file
View File

@@ -0,0 +1,150 @@
//! # pmocache - Système de cache générique pour PMOMusic
//!
//! Cette crate fournit un système de cache générique avec support de base de données SQLite
//! et stockage sur disque. Elle est utilisée comme base pour des caches spécialisés comme
//! `pmocovers` (cache d'images) et `pmoaudiocache` (cache de pistes audio).
//!
//! ## Vue d'ensemble
//!
//! `pmocache` fournit les composants de base pour :
//! - Stocker des fichiers sur disque avec une base de données SQLite pour les métadonnées
//! - Gérer des collections d'éléments (albums, playlists, etc.)
//! - Suivre les statistiques d'utilisation (hits, dernière utilisation)
//! - Télécharger automatiquement depuis des URLs
//! - Consolider et purger le cache
//!
//! ## Architecture
//!
//! `pmocache` est conçu comme une base générique :
//!
//! ```text
//! pmocache (générique)
//! ├── db.rs - Base de données SQLite générique
//! └── cache.rs - Système de cache générique
//!
//! pmocovers (spécialisé pour les images)
//! └── Utilise pmocache + conversion WebP
//!
//! pmoaudiocache (spécialisé pour l'audio)
//! └── Utilise pmocache + métadonnées audio
//! ```
//!
//! ## Utilisation
//!
//! ### Exemple basique
//!
//! ```rust,no_run
//! use pmocache::{Cache, CacheConfig};
//!
//! // Définir la configuration du cache
//! struct MyConfig;
//! impl CacheConfig for MyConfig {
//! fn file_extension() -> &'static str { "dat" }
//! fn table_name() -> &'static str { "my_cache" }
//! fn cache_type() -> &'static str { "generic" }
//! }
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let cache = Cache::<MyConfig>::new("./cache", 1000, "http://localhost:8080")?;
//!
//! // Ajouter un fichier depuis une URL
//! let pk = cache.add_from_url("http://example.com/file.dat", None).await?;
//! println!("Fichier ajouté avec clé: {}", pk);
//!
//! // Récupérer le fichier
//! let path = cache.get(&pk).await?;
//! println!("Fichier stocké à: {:?}", path);
//!
//! Ok(())
//! }
//! ```
//!
//! ### Utilisation avec des collections
//!
//! ```rust,no_run
//! use pmocache::{Cache, CacheConfig};
//!
//! struct AudioConfig;
//! impl CacheConfig for AudioConfig {
//! fn file_extension() -> &'static str { "flac" }
//! fn table_name() -> &'static str { "audio" }
//! fn cache_type() -> &'static str { "audio" }
//! }
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let cache = Cache::<AudioConfig>::new("./cache", 1000, "http://localhost:8080")?;
//!
//! // Ajouter des pistes d'un album
//! let album_id = "album:the_wall";
//! cache.add_from_url("http://example.com/track1.flac", Some(album_id)).await?;
//! cache.add_from_url("http://example.com/track2.flac", Some(album_id)).await?;
//!
//! // Récupérer toutes les pistes de l'album
//! let tracks = cache.get_collection(album_id).await?;
//! println!("Album contient {} pistes", tracks.len());
//!
//! Ok(())
//! }
//! ```
//!
//! ## Structure des fichiers
//!
//! ```text
//! cache/
//! ├── cache.db # Base de données SQLite
//! ├── 1a2b3c4d.webp # Fichier 1
//! └── 5e6f7a8b.flac # Fichier 2
//! ```
//!
//! ## Schéma de base de données
//!
//! ```sql
//! CREATE TABLE {table_name} (
//! pk TEXT PRIMARY KEY, -- Clé unique (hash SHA1 de l'URL)
//! source_url TEXT, -- URL source
//! collection TEXT, -- Collection (album, playlist, etc.)
//! hits INTEGER DEFAULT 0, -- Nombre d'accès
//! last_used TEXT -- Dernière utilisation (RFC3339)
//! );
//! ```
//!
//! ## Dépendances principales
//!
//! - `rusqlite` : Base de données SQLite
//! - `reqwest` : Téléchargement HTTP
//! - `sha1` : Génération de clés
//! - `tokio` : Runtime asynchrone
//!
//! ## Voir aussi
//!
//! - [`pmocovers`] : Cache d'images avec conversion WebP
//! - [`pmoaudiocache`] : Cache de pistes audio
pub mod cache;
pub mod cache_trait;
pub mod db;
pub mod download;
#[cfg(feature = "pmoserver")]
pub mod pmoserver_ext;
#[cfg(feature = "pmoserver")]
pub mod api;
#[cfg(feature = "openapi")]
pub mod openapi;
pub use cache::{Cache, CacheConfig};
pub use cache_trait::{pk_from_url, FileCache};
pub use db::{CacheEntry, DB};
pub use download::{
download, download_with_transformer, ingest_with_transformer, Download, StreamTransformer,
};
#[cfg(feature = "pmoserver")]
pub use pmoserver_ext::{create_api_router, create_file_router, GenericCacheExt};
#[cfg(all(feature = "pmoserver", feature = "openapi"))]
pub use api::{AddItemRequest, AddItemResponse, DeleteItemResponse, DownloadStatus, ErrorResponse};

62
pmocache/src/openapi.rs Normal file
View File

@@ -0,0 +1,62 @@
//! Génération de documentation OpenAPI pour l'API du cache générique
//!
//! Ce module fournit une macro pour créer dynamiquement la documentation OpenAPI
//! selon le type de cache (images, audio, etc.).
/// Macro pour créer une documentation OpenAPI pour un type de cache
///
/// # Exemple
///
/// ```rust,ignore
/// use pmocache::create_cache_openapi;
///
/// // Génère une struct OpenApi pour le cache de couvertures
/// create_cache_openapi!(
/// CoversApiDoc,
/// "covers",
/// "Covers",
/// "Gestion du cache d'images de couvertures"
/// );
/// ```
#[macro_export]
macro_rules! create_cache_openapi {
($doc_name:ident, $cache_name:expr, $cache_title:expr, $cache_description:expr) => {
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
$crate::api::list_items::<Self>,
$crate::api::get_item_info::<Self>,
$crate::api::get_download_status::<Self>,
$crate::api::add_item::<Self>,
$crate::api::delete_item::<Self>,
$crate::api::purge_cache::<Self>,
$crate::api::consolidate_cache::<Self>,
),
components(
schemas(
$crate::db::CacheEntry,
$crate::api::DownloadStatus,
$crate::api::AddItemRequest,
$crate::api::AddItemResponse,
$crate::api::DeleteItemResponse,
$crate::api::ErrorResponse,
)
),
tags(
(name = $cache_name, description = concat!("Gestion du cache de ", $cache_title))
),
info(
title = concat!("PMO", $cache_title, " API"),
version = "0.1.0",
description = $cache_description,
contact(
name = "PMOMusic",
),
license(
name = "MIT",
),
)
)]
pub struct $doc_name;
};
}

View File

@@ -0,0 +1,377 @@
//! Extension pmoserver pour servir les fichiers du cache via HTTP
//!
//! Ce module fournit des handlers génériques pour servir les fichiers
//! d'un cache via des routes HTTP structurées, avec support du streaming progressif.
//!
//! ## Routes générées
//!
//! Format: `/{cache_name}/{cache_type}/{pk}[/{param}]`
//!
//! Exemples:
//! - `/covers/images/abc123` - Image avec param par défaut (orig)
//! - `/covers/images/abc123/256` - Image redimensionnée 256x256
//! - `/audio/tracks/def456` - Piste audio par défaut
//! - `/audio/tracks/def456/stream` - Piste audio streamable
//!
//! ## Streaming progressif
//!
//! Les fichiers en cours de téléchargement sont automatiquement streamés
//! au fur et à mesure de leur disponibilité.
//!
//! ## Utilisation
//!
//! ```rust,no_run
//! use pmocache::pmoserver_ext;
//! use axum::Router;
//!
//! # async fn example(cache: std::sync::Arc<pmocache::Cache<CoversConfig>>) {
//! // Créer un router pour servir les fichiers
//! let router = pmoserver_ext::create_file_router(
//! cache.clone(),
//! "image/webp" // Content-Type
//! );
//!
//! // Le router sera monté à la racine avec les routes complètes
//! // Exemple: GET /covers/images/{pk}
//! // GET /covers/images/{pk}/{param}
//! # }
//! ```
#[cfg(feature = "pmoserver")]
use crate::{Cache, CacheConfig};
#[cfg(feature = "pmoserver")]
use axum::{
body::Body,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
Router,
};
#[cfg(feature = "pmoserver")]
use std::future::Future;
#[cfg(feature = "pmoserver")]
use std::pin::Pin;
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
#[cfg(feature = "pmoserver")]
use tokio_util::io::ReaderStream;
#[cfg(feature = "pmoserver")]
use tracing::warn;
/// Type pour le callback de génération de param
///
/// Appelé quand un fichier avec param n'existe pas.
/// Permet de générer à la volée (ex: redimensionnement d'images).
///
/// # Arguments
///
/// - `cache`: le cache
/// - `pk`: clé primaire
/// - `param`: paramètre demandé (ex: "256" pour une taille)
///
/// # Retourne
///
/// Les données générées ou None si le param n'est pas supporté
#[cfg(feature = "pmoserver")]
pub type ParamGenerator<C> = Arc<
dyn Fn(Arc<Cache<C>>, String, String) -> Pin<Box<dyn Future<Output = Option<Vec<u8>>> + Send>>
+ Send
+ Sync,
>;
/// Handler générique pour GET /{cache_name}/{cache_type}/{pk}
/// Sert un fichier avec le param par défaut
#[cfg(feature = "pmoserver")]
async fn get_file<C: CacheConfig + 'static>(
State((cache, content_type, param_generator)): State<(
Arc<Cache<C>>,
&'static str,
Option<ParamGenerator<C>>,
)>,
Path(pk): Path<String>,
) -> Response {
// Utiliser le param par défaut
let param = C::default_param();
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator).await
}
/// Handler générique pour GET /{cache_name}/{cache_type}/{pk}/{param}
/// Sert un fichier avec un param spécifique
#[cfg(feature = "pmoserver")]
async fn get_file_with_param<C: CacheConfig + 'static>(
State((cache, content_type, param_generator)): State<(
Arc<Cache<C>>,
&'static str,
Option<ParamGenerator<C>>,
)>,
Path((pk, param)): Path<(String, String)>,
) -> Response {
serve_file_with_streaming(&cache, &pk, &param, content_type, param_generator).await
}
/// Fonction utilitaire pour servir un fichier avec streaming progressif
///
/// Si le fichier est en cours de téléchargement, il est streamé au fur et à mesure.
/// Sinon, le fichier complet est servi normalement.
/// Si le fichier n'existe pas et qu'un param_generator est fourni, tente de générer le param.
#[cfg(feature = "pmoserver")]
async fn serve_file_with_streaming<C: CacheConfig>(
cache: &Arc<Cache<C>>,
pk: &str,
param: &str,
content_type: &'static str,
param_generator: Option<ParamGenerator<C>>,
) -> Response {
let file_path = cache.file_path_with_qualifier(pk, param);
// Si le fichier n'existe pas et qu'on a un générateur, l'utiliser
if !file_path.exists() {
if let Some(generator) = param_generator {
if let Some(data) = generator(cache.clone(), pk.to_string(), param.to_string()).await {
// Le générateur a créé les données, les servir directement
return (StatusCode::OK, [("content-type", content_type)], data).into_response();
}
}
}
// Mettre à jour les stats d'utilisation
if let Err(e) = cache.db.update_hit(pk) {
warn!("Error updating hit count for {}: {}", pk, e);
}
// Vérifier si le download est en cours
if let Some(download) = cache.get_download(pk).await {
// Le fichier est en cours de téléchargement
if !download.finished().await {
// Streaming progressif
return stream_file_progressive(file_path, download, content_type).await;
}
}
// Fichier terminé ou pas de download en cours, servir normalement
serve_complete_file(file_path, content_type).await
}
/// Stream un fichier en cours de téléchargement de manière progressive
#[cfg(feature = "pmoserver")]
async fn stream_file_progressive(
file_path: std::path::PathBuf,
download: Arc<crate::download::Download>,
content_type: &'static str,
) -> Response {
// Attendre qu'au moins 64 KB soient disponibles avant de commencer
const MIN_SIZE_TO_START: u64 = 64 * 1024;
if let Err(e) = download.wait_until_min_size(MIN_SIZE_TO_START).await {
warn!("Error waiting for download to start: {}", e);
if let Some(error_msg) = download.error().await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Download error: {}", error_msg),
)
.into_response();
}
return (StatusCode::NOT_FOUND, "File not available").into_response();
}
// Ouvrir le fichier en lecture
let file = match tokio::fs::File::open(&file_path).await {
Ok(f) => f,
Err(e) => {
warn!("Error opening file {:?}: {}", file_path, e);
return (StatusCode::NOT_FOUND, "File not found").into_response();
}
};
// Créer un stream à partir du fichier
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
(
StatusCode::OK,
[
("content-type", content_type),
("transfer-encoding", "chunked"),
],
body,
)
.into_response()
}
/// Sert un fichier complet déjà téléchargé
#[cfg(feature = "pmoserver")]
async fn serve_complete_file(
file_path: std::path::PathBuf,
content_type: &'static str,
) -> Response {
if !file_path.exists() {
warn!("File not found: {:?}", file_path);
return (StatusCode::NOT_FOUND, "File not found").into_response();
}
match tokio::fs::read(&file_path).await {
Ok(data) => (StatusCode::OK, [("content-type", content_type)], data).into_response(),
Err(e) => {
warn!("Error reading file {:?}: {}", file_path, e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response()
}
}
}
/// Crée un router pour servir les fichiers d'un cache
///
/// Crée un router avec les routes complètes incluant cache_name et cache_type.
///
/// # Arguments
///
/// * `cache` - Instance du cache
/// * `content_type` - Type MIME des fichiers (ex: "image/webp", "audio/flac")
///
/// # Routes créées
///
/// - `GET /{cache_name}/{cache_type}/{pk}` - Fichier avec param par défaut
/// - `GET /{cache_name}/{cache_type}/{pk}/{param}` - Fichier avec param spécifique
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocache::pmoserver_ext;
/// use axum::Router;
/// use pmoserver::Server;
///
/// # async fn example(server: &mut Server, cache: std::sync::Arc<pmocache::Cache<CoversConfig>>) {
/// let router = pmoserver_ext::create_file_router(
/// cache.clone(),
/// "image/webp"
/// );
///
/// // Le router sera monté à la racine avec les routes complètes:
/// // GET /covers/images/{pk}
/// // GET /covers/images/{pk}/{param}
/// server.add_router("/", router).await;
/// # }
/// ```
#[cfg(feature = "pmoserver")]
pub fn create_file_router<C: CacheConfig + 'static>(
cache: Arc<Cache<C>>,
content_type: &'static str,
) -> Router {
create_file_router_with_generator(cache, content_type, None)
}
/// Crée un router pour servir les fichiers d'un cache avec générateur de param
///
/// Similaire à `create_file_router` mais permet de fournir un générateur
/// pour créer des variantes à la volée (ex: redimensionnement d'images).
///
/// # Arguments
///
/// * `cache` - Instance du cache
/// * `content_type` - Type MIME des fichiers (ex: "image/webp", "audio/flac")
/// * `param_generator` - Générateur optionnel pour créer des params à la volée
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocache::pmoserver_ext::{create_file_router_with_generator, ParamGenerator};
/// use std::sync::Arc;
///
/// # async fn example(cache: std::sync::Arc<pmocache::Cache<CoversConfig>>) {
/// let generator: ParamGenerator<CoversConfig> = Arc::new(|cache, pk, param| {
/// Box::pin(async move {
/// // Générer une variante si param est numérique
/// if let Ok(size) = param.parse::<usize>() {
/// // Générer et retourner les données
/// Some(vec![])
/// } else {
/// None
/// }
/// })
/// });
///
/// let router = create_file_router_with_generator(
/// cache.clone(),
/// "image/webp",
/// Some(generator)
/// );
/// # }
/// ```
#[cfg(feature = "pmoserver")]
pub fn create_file_router_with_generator<C: CacheConfig + 'static>(
cache: Arc<Cache<C>>,
content_type: &'static str,
param_generator: Option<ParamGenerator<C>>,
) -> Router {
let cache_name = C::cache_name();
let cache_type = C::cache_type();
let path_with_param = format!("/{}/{}/{{pk}}/{{param}}", cache_name, cache_type);
let path_without_param = format!("/{}/{}/{{pk}}", cache_name, cache_type);
Router::new()
.route(&path_without_param, get(get_file::<C>))
.route(&path_with_param, get(get_file_with_param::<C>))
.with_state((cache, content_type, param_generator))
}
/// Crée un router pour l'API REST du cache
///
/// # Arguments
///
/// * `cache` - Instance du cache
///
/// # Routes créées
///
/// - `GET /` - Liste des items
/// - `POST /` - Ajouter un item
/// - `DELETE /` - Purger le cache
/// - `GET /{pk}` - Info d'un item
/// - `GET /{pk}/status` - Status du download
/// - `DELETE /{pk}` - Supprimer un item
/// - `POST /consolidate` - Consolider le cache
#[cfg(feature = "pmoserver")]
pub fn create_api_router<C: CacheConfig + 'static>(cache: Arc<Cache<C>>) -> Router {
use crate::api;
Router::new()
.route(
"/",
get(api::list_items::<C>)
.post(api::add_item::<C>)
.delete(api::purge_cache::<C>),
)
.route(
"/{pk}",
get(api::get_item_info::<C>).delete(api::delete_item::<C>),
)
.route("/{pk}/status", get(api::get_download_status::<C>))
.route("/consolidate", post(api::consolidate_cache::<C>))
.with_state(cache)
}
/// Trait d'extension pour pmoserver::Server
///
/// Permet d'initialiser un cache générique avec routes HTTP complètes
#[cfg(feature = "pmoserver")]
pub trait GenericCacheExt {
/// Initialise un cache générique avec routes complètes
///
/// # Arguments
///
/// * `cache_dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (nombre d'éléments)
/// * `content_type` - Type MIME des fichiers (ex: "image/webp", "audio/flac")
///
/// # Routes créées
///
/// - Fichiers: `/{cache_name}/{cache_type}/{pk}[/{param}]`
/// - API: `/api/{cache_name}/*`
/// - Swagger: `/swagger-ui/{cache_name}`
async fn init_generic_cache<C: CacheConfig + 'static>(
&mut self,
cache_dir: &str,
limit: usize,
content_type: &'static str,
) -> anyhow::Result<Arc<Cache<C>>>;
}

View File

@@ -2,7 +2,7 @@ use anyhow::{anyhow, Result};
use dirs::home_dir;
use lazy_static::lazy_static;
use pmoutils::guess_local_ip;
use serde_yaml::{Mapping, Value};
use serde_yaml::{Mapping, Number, Value};
use std::{
env, fs,
path::{Path, PathBuf},
@@ -40,7 +40,6 @@ impl Clone for Config {
}
impl Config {
pub fn load_config(filename: &str) -> Result<Self> {
let mut path = filename.to_string();
let mut data: Option<Vec<u8>> = None;
@@ -100,10 +99,9 @@ impl Config {
DEFAULT_CONFIG.as_bytes().to_vec()
};
let external_value: Value = serde_yaml::from_slice(&yaml_data)?;
merge_yaml(&mut default_value, &external_value);
let mut config_value = Self::lower_keys_value(default_value);
let mut config_value = Self::lower_keys_value(default_value);
Self::apply_env_overrides(&mut config_value);
@@ -181,7 +179,6 @@ impl Config {
fn get_value_internal(data: &Value, path: &[&str]) -> Result<Value> {
let mut current = data;
for (i, key) in path.iter().enumerate() {
if let Value::Mapping(map) = current {
let key = key.to_lowercase();
@@ -293,6 +290,11 @@ impl Config {
}
}
pub fn set_http_port(&self, port: u16) -> Result<()> {
let n = Number::from(port);
self.set_value(&["host", "http_port"], Value::Number(n))
}
pub fn get_device_udn(&self, devtype: &str, name: &str) -> Result<String> {
let path = &["devices", devtype, name, "udn"];
match self.get_value(path) {
@@ -305,6 +307,10 @@ impl Config {
}
}
pub fn set_device_udn(&self, devtype: &str, name: &str, udn: String) -> Result<()> {
self.set_value(&["devices", devtype, name, "udn"], Value::String(udn))
}
pub fn get_cover_cache_dir(&self) -> Result<String> {
match self.get_value(&["host", "cover_cache", "directory"])? {
Value::String(s) => Ok(s),
@@ -312,6 +318,13 @@ impl Config {
}
}
pub fn set_cover_cache_dir(&self, directory: String) -> Result<()> {
self.set_value(
&["host", "cover_cache", "directory"],
Value::String(directory),
)
}
pub fn get_cover_cache_size(&self) -> Result<usize> {
match self.get_value(&["host", "cover_cache", "size"])? {
Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
@@ -319,6 +332,114 @@ impl Config {
_ => Ok(2000),
}
}
pub fn set_cover_cache_size(&self, size: usize) -> Result<()> {
let n = Number::from(size);
self.set_value(&["host", "cover_cache", "size"], Value::Number(n))
}
pub fn get_audio_cache_dir(&self) -> Result<String> {
match self.get_value(&["host", "audio_cache", "directory"])? {
Value::String(s) => Ok(s),
_ => Ok("./.pmomusic_audio".to_string()),
}
}
pub fn set_audio_cache_dir(&self, directory: String) -> Result<()> {
self.set_value(
&["host", "audio_cache", "directory"],
Value::String(directory),
)
}
pub fn get_audio_cache_size(&self) -> Result<usize> {
match self.get_value(&["host", "audio_cache", "size"])? {
Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
Value::Number(n) if n.is_u64() => Ok(n.as_u64().unwrap() as usize),
_ => Ok(500),
}
}
pub fn set_audio_cache_size(&self, size: usize) -> Result<()> {
let n = Number::from(size);
self.set_value(&["host", "audio_cache", "size"], Value::Number(n))
}
/// Récupère le nom d'utilisateur Qobuz depuis la configuration
pub fn get_qobuz_username(&self) -> Result<String> {
match self.get_value(&["accounts", "qobuz", "username"])? {
Value::String(s) => Ok(s),
_ => Err(anyhow!("Qobuz username not configured")),
}
}
/// Définit le nom d'utilisateur Qobuz dans la configuration
pub fn set_qobuz_username(&self, username: &str) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "username"],
Value::String(username.to_string()),
)
}
/// Récupère le mot de passe Qobuz depuis la configuration
pub fn get_qobuz_password(&self) -> Result<String> {
match self.get_value(&["accounts", "qobuz", "password"])? {
Value::String(s) => Ok(s),
_ => Err(anyhow!("Qobuz password not configured")),
}
}
/// Définit le mot de passe Qobuz dans la configuration
pub fn set_qobuz_password(&self, password: &str) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "password"],
Value::String(password.to_string()),
)
}
/// Récupère les credentials Qobuz (username + password) depuis la configuration
pub fn get_qobuz_credentials(&self) -> Result<(String, String)> {
let username = self.get_qobuz_username()?;
let password = self.get_qobuz_password()?;
Ok((username, password))
}
pub fn get_log_cache_size(&self) -> Result<usize> {
match self.get_value(&["host", "logger", "buffer_capacity"])? {
Value::Number(n) => n
.as_u64()
.map(|v| v as usize)
.ok_or_else(|| anyhow::anyhow!("Number is not an unsigned integer")),
_ => Ok(1000),
}
}
pub fn set_log_cache_size(&self, size: usize) -> Result<()> {
let n = Number::from(size);
self.set_value(&["host", "logger", "buffer_capacity"], Value::Number(n))
}
pub fn get_log_enable_console(&self) -> Result<bool> {
match self.get_value(&["host", "logger", "enable_console"])? {
Value::Bool(b) => Ok(b),
_ => Ok(true),
}
}
pub fn set_log_enable_console(&self, enable: bool) -> Result<()> {
self.set_value(&["host", "logger", "enable_console"], Value::Bool(enable))
}
pub fn get_log_min_level(&self) -> Result<String> {
match self.get_value(&["host", "logger", "min_level"])? {
Value::String(s) => Ok(s),
_ => Ok("TRACE".to_string()),
}
}
pub fn set_log_min_level(&self, level: String) -> Result<()> {
self.set_value(&["host", "logger", "min_level"], Value::String(level))
}
}
/// Retourne l'instance globale
@@ -332,7 +453,9 @@ fn merge_yaml(default: &mut Value, external: &Value) {
for (k, v) in emap {
match dmap.get_mut(k) {
Some(dv) => merge_yaml(dv, v),
None => { dmap.insert(k.clone(), v.clone()); }
None => {
dmap.insert(k.clone(), v.clone());
}
}
}
}

View File

@@ -3,7 +3,14 @@ host:
cover_cache:
directory: "./.pmomusic_covers"
size: 2000
devices:
audio_cache:
directory: "./.pmomusic_audio"
size: 500
logger:
buffer_capacity: 200
enable_console: true
min_level: "INFO"
mediarenderer:
mpd_renderer:
mediaserver:

View File

@@ -4,23 +4,18 @@ version = "0.1.0"
edition = "2021"
[dependencies]
# Cache générique
pmocache = { path = "../pmocache" }
# Gestion d'images
image = "0.25"
webp = "0.3"
# Base de données
rusqlite = { version = "0.32", features = ["bundled"] }
# HTTP client
reqwest = { version = "0.12", features = ["blocking"] }
# Cryptographie
sha1 = "0.10"
hex = "0.4"
# Utilitaires
anyhow = "1.0"
chrono = "0.4"
serde = { version = "1.0", features = ["derive"] }
# Async
@@ -36,4 +31,4 @@ tracing = "0.1.41"
[features]
default = ["pmoserver"]
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa"]
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/openapi", "pmocache/pmoserver"]

View File

@@ -1,311 +0,0 @@
//! API REST pour la gestion du cache de couvertures
//!
//! Ce module expose une API REST documentée avec OpenAPI/Swagger pour :
//! - Lister les images en cache
//! - Ajouter des images depuis une URL
//! - Supprimer des images
//! - Consulter les statistiques
use crate::{Cache, CacheEntry};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use utoipa::ToSchema;
/// Requête pour ajouter une image au cache
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AddImageRequest {
/// URL de l'image source
#[schema(example = "https://example.com/cover.jpg")]
pub url: String,
}
/// Réponse après ajout d'une image
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AddImageResponse {
/// Clé primaire (pk) de l'image ajoutée
#[schema(example = "1a2b3c4d5e6f7a8b")]
pub pk: String,
/// URL source de l'image
#[schema(example = "https://example.com/cover.jpg")]
pub url: String,
/// Message de succès
#[schema(example = "Image added successfully")]
pub message: String,
}
/// Réponse de suppression d'une image
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct DeleteImageResponse {
/// Message de succès
#[schema(example = "Image deleted successfully")]
pub message: String,
}
/// Réponse d'erreur générique
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ErrorResponse {
/// Code d'erreur
#[schema(example = "NOT_FOUND")]
pub error: String,
/// Message descriptif
#[schema(example = "Image not found in cache")]
pub message: String,
}
/// Liste toutes les images en cache avec leurs statistiques
///
/// Retourne la liste complète des entrées du cache triées par nombre d'accès décroissant.
#[utoipa::path(
get,
path = "/api/covers",
responses(
(status = 200, description = "Liste des images en cache", body = Vec<CacheEntry>),
(status = 500, description = "Erreur serveur", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn list_images(State(cache): State<Arc<Cache>>) -> impl IntoResponse {
match cache.db.get_all() {
Ok(entries) => (StatusCode::OK, Json(entries)).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "DATABASE_ERROR".to_string(),
message: format!("Cannot retrieve cache entries: {}", e),
}),
)
.into_response(),
}
}
/// Récupère les informations d'une image spécifique
///
/// Retourne les métadonnées d'une image identifiée par sa clé (pk).
#[utoipa::path(
get,
path = "/api/covers/{pk}",
params(
("pk" = String, Path, description = "Clé primaire de l'image", example = "1a2b3c4d5e6f7a8b")
),
responses(
(status = 200, description = "Informations de l'image", body = CacheEntry),
(status = 404, description = "Image non trouvée", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn get_image_info(
State(cache): State<Arc<Cache>>,
Path(pk): Path<String>,
) -> impl IntoResponse {
match cache.db.get(&pk) {
Ok(entry) => (StatusCode::OK, Json(entry)).into_response(),
Err(_) => (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "NOT_FOUND".to_string(),
message: format!("Image with pk '{}' not found in cache", pk),
}),
)
.into_response(),
}
}
/// Ajoute une image au cache depuis une URL
///
/// Télécharge l'image depuis l'URL fournie, la convertit en WebP et l'ajoute au cache.
/// Si l'image existe déjà, elle est mise à jour.
#[utoipa::path(
post,
path = "/api/covers",
request_body = AddImageRequest,
responses(
(status = 201, description = "Image ajoutée avec succès", body = AddImageResponse),
(status = 400, description = "Requête invalide", body = ErrorResponse),
(status = 500, description = "Erreur lors du téléchargement ou de la conversion", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn add_image(
State(cache): State<Arc<Cache>>,
Json(req): Json<AddImageRequest>,
) -> impl IntoResponse {
if req.url.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "INVALID_REQUEST".to_string(),
message: "URL cannot be empty".to_string(),
}),
)
.into_response();
}
match cache.add_from_url(&req.url).await {
Ok(pk) => (
StatusCode::CREATED,
Json(AddImageResponse {
pk,
url: req.url,
message: "Image added successfully".to_string(),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "PROCESSING_ERROR".to_string(),
message: format!("Cannot add image: {}", e),
}),
)
.into_response(),
}
}
/// Supprime une image du cache
///
/// Supprime l'image et toutes ses variantes du disque et de la base de données.
#[utoipa::path(
delete,
path = "/api/covers/{pk}",
params(
("pk" = String, Path, description = "Clé primaire de l'image à supprimer", example = "1a2b3c4d5e6f7a8b")
),
responses(
(status = 200, description = "Image supprimée avec succès", body = DeleteImageResponse),
(status = 404, description = "Image non trouvée", body = ErrorResponse),
(status = 500, description = "Erreur lors de la suppression", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn delete_image(
State(cache): State<Arc<Cache>>,
Path(pk): Path<String>,
) -> impl IntoResponse {
// Vérifier que l'image existe
if cache.db.get(&pk).is_err() {
return (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "NOT_FOUND".to_string(),
message: format!("Image with pk '{}' not found in cache", pk),
}),
)
.into_response();
}
// Supprimer les fichiers (original + variantes)
let orig_path = cache.dir.join(format!("{}.orig.webp", pk));
if orig_path.exists() {
if let Err(e) = tokio::fs::remove_file(&orig_path).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "FILE_DELETE_ERROR".to_string(),
message: format!("Cannot delete original file: {}", e),
}),
)
.into_response();
}
}
// Supprimer toutes les variantes (*.{pk}.*.webp)
if let Ok(mut entries) = tokio::fs::read_dir(&cache.dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if let Some(filename) = entry.file_name().to_str() {
if filename.starts_with(&pk) && filename.ends_with(".webp") && filename != format!("{}.orig.webp", pk) {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
}
// Supprimer de la base de données
match cache.db.delete(&pk) {
Ok(_) => (
StatusCode::OK,
Json(DeleteImageResponse {
message: format!("Image '{}' deleted successfully", pk),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "DATABASE_ERROR".to_string(),
message: format!("Cannot delete from database: {}", e),
}),
)
.into_response(),
}
}
/// Purge complètement le cache
///
/// Supprime toutes les images et vide la base de données. Opération irréversible.
#[utoipa::path(
delete,
path = "/api/covers",
responses(
(status = 200, description = "Cache purgé avec succès", body = DeleteImageResponse),
(status = 500, description = "Erreur lors de la purge", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn purge_cache(State(cache): State<Arc<Cache>>) -> impl IntoResponse {
match cache.purge().await {
Ok(_) => (
StatusCode::OK,
Json(DeleteImageResponse {
message: "Cache purged successfully".to_string(),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "PURGE_ERROR".to_string(),
message: format!("Cannot purge cache: {}", e),
}),
)
.into_response(),
}
}
/// Consolide le cache
///
/// Re-télécharge les images manquantes et supprime les fichiers orphelins.
/// Utile pour réparer un cache corrompu.
#[utoipa::path(
post,
path = "/api/covers/consolidate",
responses(
(status = 200, description = "Cache consolidé avec succès", body = DeleteImageResponse),
(status = 500, description = "Erreur lors de la consolidation", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn consolidate_cache(State(cache): State<Arc<Cache>>) -> impl IntoResponse {
match cache.consolidate().await {
Ok(_) => (
StatusCode::OK,
Json(DeleteImageResponse {
message: "Cache consolidated successfully".to_string(),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "CONSOLIDATE_ERROR".to_string(),
message: format!("Cannot consolidate cache: {}", e),
}),
)
.into_response(),
}
}

View File

@@ -1,145 +1,83 @@
use std::path::PathBuf;
//! Module de gestion du cache d'images avec conversion WebP
//!
//! Ce module étend le cache générique de `pmocache` avec des fonctionnalités
//! spécifiques aux images : conversion WebP automatique lors du téléchargement.
use anyhow::Result;
use pmocache::{CacheConfig, StreamTransformer};
use std::sync::Arc;
use anyhow::{anyhow, Result};
use sha1::{Sha1, Digest};
use tokio::sync::Mutex;
use crate::db::DB;
use crate::webp;
#[derive(Debug)]
pub struct Cache {
pub(crate) dir: PathBuf,
pub(crate) limit: usize,
pub db: DB,
mu: Arc<Mutex<()>>,
/// Configuration pour le cache de couvertures
pub struct CoversConfig;
impl CacheConfig for CoversConfig {
fn file_extension() -> &'static str {
"webp"
}
fn table_name() -> &'static str {
"covers"
}
fn cache_type() -> &'static str {
"image"
}
fn cache_name() -> &'static str {
"covers"
}
}
impl Cache {
pub fn new(dir: &str, limit: usize) -> Result<Self> {
std::fs::create_dir_all(dir)?;
let db = DB::init(&PathBuf::from(dir).join("cache.db"))?;
/// Type alias pour le cache de couvertures avec conversion WebP
pub type Cache = pmocache::Cache<CoversConfig>;
Ok(Self {
dir: PathBuf::from(dir),
limit,
db,
mu: Arc::new(Mutex::new(())),
/// Créateur de transformer WebP
///
/// Convertit automatiquement toute image téléchargée en format WebP
fn create_webp_transformer() -> StreamTransformer {
Box::new(|mut input, mut file, progress| {
Box::pin(async move {
// Télécharger tout en mémoire
let bytes = input.bytes().await?;
// Convertir en WebP
let img = image::load_from_memory(&bytes)
.map_err(|e| format!("Image decode error: {}", e))?;
let webp_data =
crate::webp::encode_webp(&img).map_err(|e| format!("WebP encode error: {}", e))?;
// Écrire et mettre à jour la progression
use tokio::io::AsyncWriteExt;
file.write_all(&webp_data)
.await
.map_err(|e| e.to_string())?;
file.flush().await.map_err(|e| e.to_string())?;
progress(webp_data.len() as u64);
Ok(())
})
}
pub async fn add_from_url(&self, url: &str) -> Result<String> {
let response = reqwest::get(url).await?;
if !response.status().is_success() {
return Err(anyhow!("Bad status: {}", response.status()));
}
let data = response.bytes().await?;
self.add(url, &data).await
}
pub async fn ensure_from_url(&self, url: &str) -> Result<String> {
let pk = pk_from_url(url);
if self.db.get(&pk).is_ok() {
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
if orig_path.exists() {
return Ok(pk);
}
}
self.add_from_url(url).await
}
pub async fn add(&self, url: &str, data: &[u8]) -> Result<String> {
let pk = pk_from_url(url);
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
let _lock = self.mu.lock().await;
if !orig_path.exists() {
let img = image::load_from_memory(data)?;
let webp_data = webp::encode_webp(&img)?;
tokio::fs::write(&orig_path, webp_data).await?;
}
self.db.add(&pk, url)?;
Ok(pk)
}
pub async fn get(&self, pk: &str) -> Result<PathBuf> {
let _lock = self.mu.lock().await;
self.db.get(pk)?;
self.db.update_hit(pk)?;
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
if orig_path.exists() {
Ok(orig_path)
} else {
Err(anyhow!("File not found"))
}
}
pub async fn purge(&self) -> Result<()> {
let _lock = self.mu.lock().await;
let mut entries = tokio::fs::read_dir(&self.dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.path().is_file() {
tokio::fs::remove_file(entry.path()).await?;
}
}
self.db.purge().map_err(|e| anyhow!("Database error: {}", e))
}
pub async fn consolidate(&self) -> Result<()> {
let _lock = self.mu.lock().await;
let entries = self.db.get_all()?;
for entry in entries {
let orig_path = self.dir.join(format!("{}.orig.webp", entry.pk));
if !orig_path.exists() {
match reqwest::get(&entry.source_url).await {
Ok(response) if response.status().is_success() => {
let data = response.bytes().await?;
self.add(&entry.source_url, &data).await?;
}
_ => {
self.db.delete(&entry.pk)?;
}
}
}
}
let mut dir_entries = tokio::fs::read_dir(&self.dir).await?;
while let Some(entry) = dir_entries.next_entry().await? {
let path = entry.path();
if path.is_file() {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.ends_with(".orig.webp") {
let pk = file_name.trim_end_matches(".orig.webp");
if self.db.get(pk).is_err() {
tokio::fs::remove_file(path).await?;
}
}
}
}
}
Ok(())
}
pub fn cache_dir(&self) -> String {
self.dir.to_string_lossy().to_string()
}
})
}
fn pk_from_url(url: &str) -> String {
let mut hasher = Sha1::new();
hasher.update(url.as_bytes());
let result = hasher.finalize();
hex::encode(&result[..8])
}
/// Crée un cache de couvertures avec conversion WebP automatique
///
/// # Arguments
///
/// * `dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (nombre d'images)
///
/// # Returns
///
/// Instance du cache configurée pour la conversion WebP automatique
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocovers::cache;
///
/// let cache = cache::new_cache("./cache", 1000).unwrap();
/// ```
pub fn new_cache(dir: &str, limit: usize) -> Result<Cache> {
let transformer_factory = Arc::new(|| create_webp_transformer());
Cache::with_transformer(dir, limit, Some(transformer_factory))
}

View File

@@ -1,118 +1,7 @@
use rusqlite::{Connection, params};
use serde::Serialize;
use chrono::Utc;
use std::path::Path;
use std::sync::Mutex;
//! Module de compatibilité pour l'ancien module db
//!
//! Ce module réexporte les types de `pmocache::db` pour maintenir
//! la compatibilité avec l'API existante.
#[cfg(feature = "pmoserver")]
use utoipa::ToSchema;
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "pmoserver", derive(ToSchema))]
pub struct CacheEntry {
/// Clé primaire unique de l'image (hash SHA1 de l'URL)
#[cfg_attr(feature = "pmoserver", schema(example = "1a2b3c4d5e6f7a8b"))]
pub pk: String,
/// URL source de l'image
#[cfg_attr(feature = "pmoserver", schema(example = "https://example.com/cover.jpg"))]
pub source_url: String,
/// Nombre d'accès à l'image
#[cfg_attr(feature = "pmoserver", schema(example = 42))]
pub hits: i32,
/// Date/heure du dernier accès (RFC3339)
#[cfg_attr(feature = "pmoserver", schema(example = "2025-01-15T10:30:00Z"))]
pub last_used: Option<String>,
}
#[derive(Debug)]
pub struct DB {
conn: Mutex<Connection>,
}
impl DB {
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS covers (
pk TEXT PRIMARY KEY,
source_url TEXT,
hits INTEGER DEFAULT 0,
last_used TEXT
)",
[],
)?;
Ok(Self { conn: Mutex::new(conn) })
}
pub fn add(&self, pk: &str, url: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO covers (pk, source_url, hits, last_used)
VALUES (?1, ?2, 0, ?3)
ON CONFLICT(pk) DO UPDATE SET
source_url = excluded.source_url,
last_used = excluded.last_used",
params![pk, url, Utc::now().to_rfc3339()],
)?;
Ok(())
}
pub fn get(&self, pk: &str) -> rusqlite::Result<CacheEntry> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT pk, source_url, hits, last_used FROM covers WHERE pk = ?1",
[pk],
|row| {
Ok(CacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
hits: row.get(2)?,
last_used: row.get(3)?,
})
},
)
}
pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE covers SET hits = hits + 1, last_used = ?1 WHERE pk = ?2",
params![Utc::now().to_rfc3339(), pk],
)?;
Ok(())
}
pub fn purge(&self) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM covers", [])?;
Ok(())
}
pub fn get_all(&self) -> rusqlite::Result<Vec<CacheEntry>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT pk, source_url, hits, last_used FROM covers ORDER BY hits DESC",
)?;
let entries = stmt.query_map([], |row| {
Ok(CacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
hits: row.get(2)?,
last_used: row.get(3)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
pub fn delete(&self, pk: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM covers WHERE pk = ?1", [pk])?;
Ok(())
}
}
// Réexporter les types de pmocache
pub use pmocache::db::{CacheEntry, DB};

View File

@@ -3,62 +3,24 @@
//! Cette crate fournit un système de cache d'images optimisé pour les couvertures d'albums,
//! avec conversion automatique en WebP et génération de variantes de tailles.
//!
//! ## Vue d'ensemble
//! ## Fonctionnalités
//!
//! `pmocovers` gère le téléchargement, la conversion, le stockage et la distribution
//! d'images de couvertures d'albums, avec :
//! - Conversion automatique en WebP pour réduire la taille
//! - Génération de variantes de tailles à la demande
//! - Cache persistant avec base de données SQLite
//! - API HTTP pour récupérer les images
//!
//! ## Fonctionnalités
//!
//! ### 📦 Gestion du cache
//! - Téléchargement automatique depuis des URLs
//! - Conversion des images en WebP (format optimisé)
//! - Stockage persistant sur disque
//! - Base de données SQLite pour le tracking
//!
//! ### 🎨 Génération de variantes
//! - Redimensionnement automatique à la demande
//! - Création d'images carrées avec centrage
//! - Cache des variantes générées
//! - Support de multiples tailles
//!
//! ### 📊 Statistiques d'utilisation
//! - Comptage des accès (hits)
//! - Suivi de la dernière utilisation
//! - API de statistiques complètes
//! - API HTTP complète (fournie par `pmocache`)
//!
//! ## Architecture
//!
//! `pmocovers` suit le pattern d'extension des autres crates PMO :
//! `pmocovers` est une spécialisation minimale de `pmocache` qui ajoute :
//! 1. La conversion WebP automatique lors du téléchargement (via transformer)
//! 2. La génération de variantes redimensionnées à la demande (via param generator)
//!
//! - `pmoserver` définit un serveur HTTP générique
//! - `pmocovers` étend ce serveur avec des méthodes de cache via un trait
//! - Le serveur n'a pas besoin de connaître `pmocovers`
//!
//! ## Structure des fichiers
//!
//! ```text
//! pmocovers/
//! ├── Cargo.toml
//! ├── src/
//! │ ├── lib.rs # Module principal (ce fichier)
//! │ ├── cache.rs # Gestion du cache
//! │ ├── db.rs # Base de données SQLite
//! │ ├── webp.rs # Conversion et redimensionnement WebP
//! │ └── pmoserver_impl.rs # Extension de pmoserver::Server
//! └── cache/ # Répertoire de cache (généré)
//! ├── cache.db # Base SQLite
//! ├── *.orig.webp # Images originales
//! └── *.{size}.webp # Variantes de tailles
//! ```
//! Tout le reste (API REST, serveur de fichiers, DB) est fourni par `pmocache`.
//!
//! ## Utilisation
//!
//! ### Exemple basique avec configuration automatique
//! ### Exemple avec configuration automatique
//!
//! ```rust,no_run
//! use pmocovers::CoverCacheExt;
@@ -67,160 +29,62 @@
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let mut server = ServerBuilder::new_configured().build();
//!
//! // Utilise automatiquement la config (pmoconfig)
//! server.init_cover_cache_configured().await?;
//!
//! server.start().await;
//! server.wait().await;
//! Ok(())
//! }
//! ```
//!
//! ### Exemple avec paramètres personnalisés
//!
//! ```rust,no_run
//! use pmocovers::CoverCacheExt;
//! use pmoserver::ServerBuilder;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build();
//!
//! // Paramètres personnalisés
//! server.init_cover_cache("./cache", 1000).await?;
//!
//! server.start().await;
//! server.wait().await;
//! Ok(())
//! }
//! ```
//!
//! ### Utilisation du cache directement
//!
//! ```rust,no_run
//! use pmocovers::Cache;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let cache = Cache::new("./cache", 1000)?;
//!
//! // Ajouter une image depuis une URL
//! let pk = cache.add_from_url("http://example.com/cover.jpg").await?;
//! println!("Image ajoutée avec clé: {}", pk);
//!
//! // Récupérer l'image originale
//! let path = cache.get(&pk).await?;
//! println!("Image stockée à: {:?}", path);
//!
//! Ok(())
//! }
//! ```
//!
//! ## API HTTP
//!
//! Une fois enregistré sur un serveur via `CoverCacheExt`, les endpoints suivants sont disponibles :
//!
//! ### GET /covers/images/{pk}
//! Récupère l'image originale en WebP
//!
//! ### GET /covers/images/{pk}/{size}
//! Récupère une variante de taille spécifique (ex: `/covers/images/abc123/256`)
//!
//! ### GET /covers/stats
//! Récupère les statistiques du cache (JSON)
//!
//! ## Format des clés (pk)
//!
//! Les images sont identifiées par une clé (pk) dérivée de l'URL source :
//! - Hash SHA1 de l'URL
//! - Encodé en hexadécimal (8 premiers octets)
//! - Exemple: `"1a2b3c4d5e6f7a8b"`
//!
//! ## Stockage
//!
//! Les fichiers sont organisés comme suit :
//!
//! ```text
//! cache/
//! ├── cache.db # Base SQLite
//! ├── 1a2b3c4d.orig.webp # Image originale
//! ├── 1a2b3c4d.256.webp # Variante 256x256
//! └── 1a2b3c4d.512.webp # Variante 512x512
//! ```
//!
//! ## Opérations de maintenance
//!
//! ### Purge du cache
//!
//! ```rust,no_run
//! # use pmocovers::Cache;
//! # async fn example(cache: &Cache) -> anyhow::Result<()> {
//! // Supprimer tous les fichiers et entrées DB
//! cache.purge().await?;
//! # Ok(())
//! # }
//! ```
//!
//! ### Consolidation du cache
//!
//! ```rust,no_run
//! # use pmocovers::Cache;
//! # async fn example(cache: &Cache) -> anyhow::Result<()> {
//! // Re-télécharger les images manquantes et supprimer les orphelins
//! cache.consolidate().await?;
//! # Ok(())
//! # }
//! ```
//!
//! ## Dépendances principales
//!
//! - `image` : Chargement et manipulation d'images
//! - `webp` : Encodage WebP
//! - `rusqlite` : Base de données SQLite
//! - `reqwest` : Téléchargement HTTP
//! - `sha1` : Génération de clés
//!
//! ## Voir aussi
//!
//! - [`pmoserver`] : Serveur HTTP Axum
//! - [`pmoapp`] : Application web frontend
//! - [`pmoupnp`] : Bibliothèque UPnP MediaRenderer
pub mod cache;
pub mod db;
pub mod webp;
#[cfg(feature = "pmoserver")]
pub mod api;
#[cfg(feature = "pmoserver")]
pub mod openapi;
pub use cache::Cache;
pub use db::{CacheEntry, DB};
pub use cache::{new_cache, Cache, CoversConfig};
#[cfg(feature = "pmoserver")]
pub use openapi::ApiDoc;
use anyhow::Result;
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
#[cfg(feature = "pmoserver")]
use utoipa::OpenApi;
/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache d'images.
/// Générateur de variantes d'images
///
/// Ce trait permet à `pmocovers` d'ajouter des méthodes d'extension sur des types
/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dépendent de `pmocovers`.
///
/// # Architecture
///
/// Similaire au pattern utilisé par `pmoapp` pour `WebAppExt`, ce trait permet
/// une extension propre et découplée :
///
/// - `pmoserver` définit un serveur HTTP générique
/// - `pmocovers` étend ce serveur avec des méthodes de cache via ce trait
/// - Le serveur n'a pas besoin de connaître `pmocovers`
/// Si param est numérique, génère une variante redimensionnée
#[cfg(feature = "pmoserver")]
fn create_variant_generator() -> pmocache::pmoserver_ext::ParamGenerator<CoversConfig> {
Arc::new(|cache, pk, param| {
Box::pin(async move {
// Si le param est numérique, c'est une taille de variante
if let Ok(size) = param.parse::<usize>() {
match webp::generate_variant(&cache, &pk, size).await {
Ok(data) => return Some(data),
Err(e) => {
tracing::warn!(
"Cannot generate variant {}x{} for {}: {}",
size,
size,
pk,
e
);
return None;
}
}
}
// Param non reconnu
None
})
})
}
/// Trait d'extension pour ajouter le cache de couvertures à pmoserver
#[cfg(feature = "pmoserver")]
pub trait CoverCacheExt {
/// Initialise le cache d'images et enregistre les routes HTTP.
/// Initialise le cache d'images et enregistre les routes HTTP
///
/// # Arguments
///
@@ -229,49 +93,62 @@ pub trait CoverCacheExt {
///
/// # Returns
///
/// * `Arc<Cache>` - Instance partagée du cache
/// Instance partagée du cache
///
/// # Routes enregistrées
///
/// - `GET /covers/images/{pk}` - Image originale
/// - `GET /covers/images/{pk}/{size}` - Variante de taille
/// - `GET /covers/stats` - Statistiques
/// - `GET /covers/image/{pk}` - Image originale
/// - `GET /covers/image/{pk}/{size}` - Variante de taille (ex: 256, 512)
/// - `GET /api/covers` - Liste des images (API REST)
/// - `POST /api/covers` - Ajouter une image (API REST)
/// - `DELETE /api/covers/{pk}` - Supprimer une image (API REST)
/// - `GET /swagger-ui` - Documentation interactive
async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> Result<Arc<Cache>>;
/// - `GET /api/covers/{pk}/status` - Statut du téléchargement
/// - `GET /swagger-ui/covers` - Documentation interactive
async fn init_cover_cache(
&mut self,
cache_dir: &str,
limit: usize,
) -> anyhow::Result<Arc<Cache>>;
/// Initialise le cache d'images avec la configuration par défaut.
/// Initialise le cache d'images avec la configuration par défaut
///
/// Utilise automatiquement les paramètres de `pmoconfig::Config` :
/// - `host.cover_cache.directory` pour le répertoire
/// - `host.cover_cache.size` pour la limite de taille
///
/// # Returns
///
/// * `Arc<Cache>` - Instance partagée du cache
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocovers::CoverCacheExt;
/// use pmoserver::ServerBuilder;
///
/// #[tokio::main]
/// async fn main() -> anyhow::Result<()> {
/// let mut server = ServerBuilder::new_configured().build();
///
/// // Utilise automatiquement la config
/// server.init_cover_cache_configured().await?;
///
/// server.start().await;
/// Ok(())
/// }
/// ```
async fn init_cover_cache_configured(&mut self) -> Result<Arc<Cache>>;
/// Utilise automatiquement les paramètres de `pmoconfig::Config`
async fn init_cover_cache_configured(&mut self) -> anyhow::Result<Arc<Cache>>;
}
// Implémentation du trait pour pmoserver::Server (feature-gated)
#[cfg(feature = "pmoserver")]
mod pmoserver_impl;
impl CoverCacheExt for pmoserver::Server {
async fn init_cover_cache(
&mut self,
cache_dir: &str,
limit: usize,
) -> anyhow::Result<Arc<Cache>> {
use pmocache::pmoserver_ext::{create_api_router, create_file_router_with_generator};
let cache = Arc::new(cache::new_cache(cache_dir, limit)?);
// Router de fichiers avec génération de variantes
// Routes: GET /covers/image/{pk} et GET /covers/image/{pk}/{size}
let file_router = create_file_router_with_generator(
cache.clone(),
"image/webp",
Some(create_variant_generator()),
);
self.add_router("/", file_router).await;
// API REST générique (pmocache)
// Routes: GET/POST/DELETE /api/covers, etc.
let api_router = create_api_router(cache.clone());
let openapi = crate::ApiDoc::openapi();
self.add_openapi(api_router, openapi, "covers").await;
Ok(cache)
}
async fn init_cover_cache_configured(&mut self) -> anyhow::Result<Arc<Cache>> {
let config = pmoconfig::get_config();
let cache_dir = config.get_cover_cache_dir()?;
let limit = config.get_cover_cache_size()?;
self.init_cover_cache(&cache_dir, limit).await
}
}

Some files were not shown because too many files have changed in this diff Show More