Compare commits
12 Commits
push-xsnmp
...
push-ytvsv
| Author | SHA1 | Date | |
|---|---|---|---|
| 3154c11845 | |||
| 24be870e4a | |||
| 9bf5bc7fe2 | |||
| e260330877 | |||
| c57ef5df30 | |||
| c4ace53ea0 | |||
| dd5299f59a | |||
| 8a55dafe8d | |||
| f87f467680 | |||
| 05a9c7100a | |||
| aaab49274c | |||
| 57239e2d1f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,8 +10,7 @@ xxx
|
|||||||
/dcai/
|
/dcai/
|
||||||
**/.pmomusic.yml
|
**/.pmomusic.yml
|
||||||
**/.pmomusic_covers/**
|
**/.pmomusic_covers/**
|
||||||
**/.DS_Strore/**
|
.DS_Store
|
||||||
**/.DS_Strore
|
|
||||||
/target/
|
/target/
|
||||||
.pmomusic_covers
|
.pmomusic_covers
|
||||||
C/src/soxr-0.1.3/Release/tests
|
C/src/soxr-0.1.3/Release/tests
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
host:
|
||||||
|
http_port: '8080'
|
||||||
|
cover_cache:
|
||||||
|
directory: ./.pmomusic_covers
|
||||||
|
size: 2000
|
||||||
devices:
|
devices:
|
||||||
mediarenderer:
|
mediarenderer:
|
||||||
|
mpd_renderer: null
|
||||||
fakerenderer:
|
fakerenderer:
|
||||||
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
|
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
|
||||||
|
mediarenderer:
|
||||||
|
udn: f9ef6c21-0ed3-470c-9846-bc1ae85fea62
|
||||||
mediaserver:
|
mediaserver:
|
||||||
qobuz:
|
qobuz:
|
||||||
udn: 28963b75-4c5f-4da7-b10e-ffafd
|
udn: 28963b75-4c5f-4da7-b10e-ffafd
|
||||||
host:
|
|
||||||
http_port: '8080'
|
|
||||||
|
|||||||
1090
Cargo.lock
generated
1090
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl"]
|
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocovers"]
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -2,7 +2,7 @@
|
|||||||
# Variables de configuration
|
# Variables de configuration
|
||||||
CARGO = cargo
|
CARGO = cargo
|
||||||
NPM = npm
|
NPM = npm
|
||||||
WEBAPP_DIR = pmoupnp/webapp
|
WEBAPP_DIR = pmoapp/webapp
|
||||||
DIST_DIR = $(WEBAPP_DIR)/dist
|
DIST_DIR = $(WEBAPP_DIR)/dist
|
||||||
RUST_TARGET = target/release
|
RUST_TARGET = target/release
|
||||||
DOC_DIR = target/doc
|
DOC_DIR = target/doc
|
||||||
@@ -29,13 +29,13 @@ build: webapp release
|
|||||||
@echo "$(GREEN)✓ Build complet terminé$(NC)"
|
@echo "$(GREEN)✓ Build complet terminé$(NC)"
|
||||||
|
|
||||||
## release: Compile le binaire Rust en mode release
|
## release: Compile le binaire Rust en mode release
|
||||||
release:
|
release: webapp
|
||||||
@echo "$(YELLOW)→ Compilation Rust (release)...$(NC)"
|
@echo "$(YELLOW)→ Compilation Rust (release)...$(NC)"
|
||||||
$(CARGO) build --release
|
$(CARGO) build --release
|
||||||
@echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)"
|
@echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)"
|
||||||
|
|
||||||
## debug: Compile le binaire Rust en mode debug
|
## debug: Compile le binaire Rust en mode debug
|
||||||
debug:
|
debug: webapp
|
||||||
@echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)"
|
@echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)"
|
||||||
$(CARGO) build
|
$(CARGO) build
|
||||||
@echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)"
|
@echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)"
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
pmoconfig = { path = "../pmoconfig" }
|
pmoconfig = { path = "../pmoconfig" }
|
||||||
pmoupnp = { path = "../pmoupnp"}
|
pmoupnp = { path = "../pmoupnp"}
|
||||||
|
pmoserver = { path = "../pmoserver" }
|
||||||
|
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||||
|
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||||
|
|
||||||
|
|
||||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
||||||
|
|||||||
@@ -1,54 +1,69 @@
|
|||||||
use pmoupnp::{mediarenderer::avtransport::{actions::SETAVTRANSPORTURI, AVTTRANSPORT}, server::{
|
use pmoupnp::{
|
||||||
logs::{log_dump, log_sse, LogState, SseLayer}, ServerBuilder, Webapp
|
mediarenderer::MEDIA_RENDERER,
|
||||||
}, UpnpObject}; // ton module pmoupnp::server
|
ssdp::SsdpServer,
|
||||||
use tracing_subscriber::Registry;
|
UpnpServer,
|
||||||
use tracing_subscriber::prelude::*;
|
UpnpModel,
|
||||||
|
};
|
||||||
|
use pmoserver::{
|
||||||
|
logs::LoggingOptions,
|
||||||
|
ServerBuilder
|
||||||
|
};
|
||||||
|
use pmoapp::{Webapp, WebAppExt};
|
||||||
|
use pmocovers::CoverCacheExt;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
// Charger la config
|
// Créer le serveur
|
||||||
|
|
||||||
let mut server = ServerBuilder::new_configured().build();
|
let mut server = ServerBuilder::new_configured().build();
|
||||||
|
|
||||||
// Ajouter des routes
|
// Initialiser le logging et enregistrer les routes de logs
|
||||||
server
|
server.init_logging(LoggingOptions::default()).await;
|
||||||
.add_route("/hello", || async {
|
|
||||||
serde_json::json!({"message": "Hello World"})
|
|
||||||
})
|
|
||||||
.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
|
||||||
server
|
server
|
||||||
.add_route("/info", || async {
|
.add_route("/info", || async {
|
||||||
serde_json::json!({"version": "1.0.0"})
|
serde_json::json!({"version": "1.0.0"})
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
server.add_spa::<Webapp>("/app").await;
|
|
||||||
|
|
||||||
// Gère la sortie des logs et sur le serveur SSE pour l'interface web et sur la console
|
// Ajouter la webapp via le trait WebAppExt
|
||||||
let log_state = LogState::new(1000);
|
info!("📡 Registering Web application...");
|
||||||
let subscriber = Registry::default()
|
server.add_webapp_with_redirect::<Webapp>("/app").await;
|
||||||
.with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
|
||||||
.with_target(true)
|
|
||||||
.with_level(true)
|
|
||||||
.with_ansi(true), // Couleurs dans le terminal
|
|
||||||
)
|
|
||||||
.with(SseLayer::new(log_state.clone()));
|
|
||||||
tracing::subscriber::set_global_default(subscriber).unwrap();
|
|
||||||
|
|
||||||
server
|
info!("📡 Registering MediaRenderer...");
|
||||||
.add_handler_with_state("/log-sse", log_sse, log_state.clone())
|
let renderer_instance = server.register_device(MEDIA_RENDERER.clone())
|
||||||
.await;
|
.await
|
||||||
server
|
.expect("Failed to register MediaRenderer routes");
|
||||||
.add_handler_with_state("/log-dump", log_dump, log_state.clone())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
server.add_redirect("/", "/app").await;
|
info!("✅ MediaRenderer ready at {}{}",
|
||||||
|
renderer_instance.base_url(),
|
||||||
|
renderer_instance.description_route()
|
||||||
|
);
|
||||||
|
|
||||||
info!("{}",AVTTRANSPORT.to_markdown());
|
// Créer et démarrer le serveur SSDP
|
||||||
info!("{}",AVTTRANSPORT.scpd_xml());
|
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");
|
||||||
|
|
||||||
server.start().await;
|
server.start().await;
|
||||||
server.wait().await;
|
server.wait().await;
|
||||||
|
|||||||
15
pmoapp/Cargo.toml
Normal file
15
pmoapp/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "pmoapp"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rust-embed = "8.5.0"
|
||||||
|
|
||||||
|
[dependencies.pmoserver]
|
||||||
|
path = "../pmoserver"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
pmoserver = ["dep:pmoserver"]
|
||||||
321
pmoapp/src/lib.rs
Normal file
321
pmoapp/src/lib.rs
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
//! # pmoapp - Application web UPnP pour PMOMusic
|
||||||
|
//!
|
||||||
|
//! Cette crate fournit l'application web frontend pour le contrôle et la visualisation
|
||||||
|
//! des devices UPnP MediaRenderer, intégrée via RustEmbed pour être servie par pmoserver.
|
||||||
|
//!
|
||||||
|
//! ## Vue d'ensemble
|
||||||
|
//!
|
||||||
|
//! `pmoapp` est une application Vue.js 3 moderne avec TypeScript qui offre une interface
|
||||||
|
//! utilisateur pour :
|
||||||
|
//! - Visualiser les logs système en temps réel (Server-Sent Events)
|
||||||
|
//! - Contrôler les devices UPnP MediaRenderer
|
||||||
|
//! - Afficher et formater automatiquement le XML dans les logs
|
||||||
|
//!
|
||||||
|
//! ## Fonctionnalités
|
||||||
|
//!
|
||||||
|
//! ### 📦 Frontend intégré
|
||||||
|
//! - Application web compilée et embarquée dans le binaire Rust
|
||||||
|
//! - Aucun fichier statique externe à gérer en production
|
||||||
|
//! - Intégration via `RustEmbed` pour une distribution simplifiée
|
||||||
|
//!
|
||||||
|
//! ### 🎨 Interface utilisateur
|
||||||
|
//! - **LogView** : Visualisation des logs en temps réel avec filtres par niveau
|
||||||
|
//! - **Auto-scroll** : Défilement automatique des nouveaux logs (désactivable)
|
||||||
|
//! - **Formatage XML** : Détection et coloration syntaxique automatique du XML
|
||||||
|
//! - **Design responsive** : Compatible desktop et mobile
|
||||||
|
//! - **Thème sombre** : Style inspiré de VS Code pour une meilleure lisibilité
|
||||||
|
//!
|
||||||
|
//! ### 🚀 Zero configuration
|
||||||
|
//! - Pas besoin de serveur web séparé pour les assets
|
||||||
|
//! - Les fichiers sont servis directement depuis la mémoire du binaire
|
||||||
|
//! - Configuration automatique du routing Vue Router
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! ### Stack technique
|
||||||
|
//!
|
||||||
|
//! - **Frontend** : Vue.js 3 avec Composition API
|
||||||
|
//! - **Langage** : TypeScript
|
||||||
|
//! - **Build** : Vite (rapide, moderne, HMR)
|
||||||
|
//! - **Routing** : Vue Router
|
||||||
|
//! - **Markdown** : Marked.js pour le rendu
|
||||||
|
//! - **Sécurité** : DOMPurify pour la sanitization HTML
|
||||||
|
//!
|
||||||
|
//! ### Structure des fichiers
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! pmoapp/
|
||||||
|
//! ├── Cargo.toml # Dépendances Rust (rust-embed)
|
||||||
|
//! ├── src/
|
||||||
|
//! │ └── lib.rs # Point d'entrée Rust (ce fichier)
|
||||||
|
//! └── webapp/
|
||||||
|
//! ├── src/
|
||||||
|
//! │ ├── main.ts # Point d'entrée Vue.js
|
||||||
|
//! │ ├── App.vue # Composant racine
|
||||||
|
//! │ ├── router/ # Configuration Vue Router
|
||||||
|
//! │ └── components/
|
||||||
|
//! │ ├── LogView.vue # Visualiseur de logs SSE
|
||||||
|
//! │ └── ...
|
||||||
|
//! ├── dist/ # Build output (généré, non versionné)
|
||||||
|
//! ├── package.json # Dépendances npm
|
||||||
|
//! └── vite.config.ts # Configuration Vite
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Workflow de build
|
||||||
|
//!
|
||||||
|
//! ### 1. Build de la webapp (Vue.js)
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! # Installation des dépendances
|
||||||
|
//! cd pmoapp/webapp
|
||||||
|
//! npm install
|
||||||
|
//!
|
||||||
|
//! # Build de production
|
||||||
|
//! npm run build
|
||||||
|
//! # Génère : webapp/dist/index.html, assets/*.js, assets/*.css
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### 2. Compilation Rust
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! cargo build
|
||||||
|
//! # RustEmbed inclut automatiquement les fichiers de webapp/dist/
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### 3. Utilisation avec Makefile
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! # Build complet (webapp + Rust)
|
||||||
|
//! make build
|
||||||
|
//!
|
||||||
|
//! # Ou juste la webapp
|
||||||
|
//! make webapp
|
||||||
|
//!
|
||||||
|
//! # Clean
|
||||||
|
//! make clean
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Utilisation
|
||||||
|
//!
|
||||||
|
//! ### Exemple basique
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmoapp::Webapp;
|
||||||
|
//! use pmoserver::ServerBuilder;
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() {
|
||||||
|
//! let mut server = ServerBuilder::new("MyApp")
|
||||||
|
//! .http_port(8080)
|
||||||
|
//! .build();
|
||||||
|
//!
|
||||||
|
//! // Ajouter la webapp comme Single Page Application
|
||||||
|
//! server.add_spa::<Webapp>("/app").await;
|
||||||
|
//!
|
||||||
|
//! // Ajouter une redirection de la racine vers /app
|
||||||
|
//! server.add_redirect("/", "/app").await;
|
||||||
|
//!
|
||||||
|
//! server.start().await;
|
||||||
|
//! server.wait().await;
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Exemple avec logs SSE
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmoapp::Webapp;
|
||||||
|
//! use pmoserver::{ServerBuilder, logs::{LogState, SseLayer}};
|
||||||
|
//! use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() {
|
||||||
|
//! // Configuration des logs avec SSE
|
||||||
|
//! let log_state = LogState::new(1000); // Buffer de 1000 logs
|
||||||
|
//! tracing_subscriber::registry()
|
||||||
|
//! .with(tracing_subscriber::fmt::layer())
|
||||||
|
//! .with(SseLayer::new(log_state.clone()))
|
||||||
|
//! .init();
|
||||||
|
//!
|
||||||
|
//! let mut server = ServerBuilder::new("MyApp").build();
|
||||||
|
//!
|
||||||
|
//! // Endpoints SSE pour les logs
|
||||||
|
//! server.add_handler_with_state("/log-sse", pmoserver::logs::log_sse, log_state.clone()).await;
|
||||||
|
//! server.add_handler_with_state("/log-dump", pmoserver::logs::log_dump, log_state).await;
|
||||||
|
//!
|
||||||
|
//! // Webapp (consommera les logs via /log-sse)
|
||||||
|
//! server.add_spa::<Webapp>("/app").await;
|
||||||
|
//! server.add_redirect("/", "/app").await;
|
||||||
|
//!
|
||||||
|
//! server.start().await;
|
||||||
|
//! server.wait().await;
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Développement
|
||||||
|
//!
|
||||||
|
//! ### Mode développement Vue.js
|
||||||
|
//!
|
||||||
|
//! Pour développer la webapp avec Hot Module Replacement :
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! cd pmoapp/webapp
|
||||||
|
//! npm run dev
|
||||||
|
//! # Serveur de dev sur http://localhost:5173
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Rebuild après modifications
|
||||||
|
//!
|
||||||
|
//! Après avoir modifié le code Vue.js :
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! # Rebuild webapp + recompile Rust
|
||||||
|
//! make build
|
||||||
|
//!
|
||||||
|
//! # Ou séparément
|
||||||
|
//! make webapp # Build Vue.js seulement
|
||||||
|
//! cargo build # Recompile Rust (intègre le nouveau dist/)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Composants Vue.js
|
||||||
|
//!
|
||||||
|
//! ### LogView
|
||||||
|
//!
|
||||||
|
//! Composant principal pour la visualisation des logs :
|
||||||
|
//!
|
||||||
|
//! - **Connexion SSE** : Stream temps réel via EventSource
|
||||||
|
//! - **Filtrage** : Par niveau (TRACE, DEBUG, INFO, WARN, ERROR)
|
||||||
|
//! - **Auto-scroll** : Activable/désactivable
|
||||||
|
//! - **Formatage** : Markdown + détection XML automatique
|
||||||
|
//! - **Buffer** : Limite à 1000 logs en mémoire
|
||||||
|
//! - **Déduplication** : Évite les logs en double
|
||||||
|
//!
|
||||||
|
//! ### Formatage XML
|
||||||
|
//!
|
||||||
|
//! Le composant LogView détecte automatiquement le XML dans les messages :
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! Input: "INFO: <?xml version=\"1.0\"?><scpd>...</scpd>"
|
||||||
|
//! Output: Bloc de code avec coloration syntaxique XML
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! - Détection via regex : `<?xml` ou balises courantes (`<scpd>`, `<service>`, etc.)
|
||||||
|
//! - Conversion en bloc markdown : ` ```xml ... ``` `
|
||||||
|
//! - Rendu avec coloration et scrollbar pour le XML long
|
||||||
|
//!
|
||||||
|
//! ## Intégration avec pmoupnp
|
||||||
|
//!
|
||||||
|
//! La webapp communique avec les devices UPnP via les endpoints HTTP fournis par
|
||||||
|
//! `pmoserver` et `pmoupnp` :
|
||||||
|
//!
|
||||||
|
//! - `/log-sse` : Stream de logs (Server-Sent Events)
|
||||||
|
//! - `/log-dump` : Historique des logs
|
||||||
|
//! - `/device/*/description.xml` : Descripteurs UPnP
|
||||||
|
//! - `/service/*/control` : Endpoints de contrôle SOAP
|
||||||
|
//! - `/service/*/event` : Souscription aux événements UPnP
|
||||||
|
//!
|
||||||
|
//! ## Notes de déploiement
|
||||||
|
//!
|
||||||
|
//! ### Taille du binaire
|
||||||
|
//!
|
||||||
|
//! La webapp ajoutera ~150KB au binaire (compressé avec gzip par RustEmbed).
|
||||||
|
//!
|
||||||
|
//! ### Cache du navigateur
|
||||||
|
//!
|
||||||
|
//! Les assets sont servis avec des hashes dans les noms de fichiers
|
||||||
|
//! (`index-BBZcSinC.js`) pour un cache busting automatique.
|
||||||
|
//!
|
||||||
|
//! ### Compatibilité navigateurs
|
||||||
|
//!
|
||||||
|
//! - Chrome/Edge : ✅ Moderne
|
||||||
|
//! - Firefox : ✅ Moderne
|
||||||
|
//! - Safari : ✅ iOS 13+
|
||||||
|
//! - IE11 : ❌ Non supporté (utilise ES modules)
|
||||||
|
//!
|
||||||
|
//! ## Voir aussi
|
||||||
|
//!
|
||||||
|
//! - [`pmoserver`] : Serveur HTTP Axum pour servir la webapp
|
||||||
|
//! - [`pmoupnp`] : Bibliothèque UPnP MediaRenderer
|
||||||
|
//! - [Vue.js Documentation](https://vuejs.org/)
|
||||||
|
//! - [Vite Documentation](https://vitejs.dev/)
|
||||||
|
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
/// Structure représentant l'application web embarquée.
|
||||||
|
///
|
||||||
|
/// Cette structure utilise `RustEmbed` pour inclure tous les fichiers
|
||||||
|
/// du répertoire `webapp/dist` dans le binaire au moment de la compilation.
|
||||||
|
///
|
||||||
|
/// ## Exemple
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pmoapp::{Webapp, WebAppExt};
|
||||||
|
/// use pmoserver::ServerBuilder;
|
||||||
|
///
|
||||||
|
/// # async fn example() {
|
||||||
|
/// let mut server = ServerBuilder::new("MyApp").build();
|
||||||
|
///
|
||||||
|
/// // Ajouter la webapp via le trait WebAppExt
|
||||||
|
/// server.add_webapp::<Webapp>("/app").await;
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
#[derive(RustEmbed, Clone)]
|
||||||
|
#[folder = "webapp/dist"]
|
||||||
|
pub struct Webapp;
|
||||||
|
|
||||||
|
/// Trait pour étendre un serveur HTTP avec des fonctionnalités webapp.
|
||||||
|
///
|
||||||
|
/// Ce trait permet à `pmoapp` d'ajouter des méthodes d'extension sur des types
|
||||||
|
/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dépendent de `pmoapp`.
|
||||||
|
///
|
||||||
|
/// # Architecture
|
||||||
|
///
|
||||||
|
/// Similaire au pattern utilisé par `pmoupnp` pour `UpnpServer`, ce trait permet
|
||||||
|
/// une extension propre et découplée :
|
||||||
|
///
|
||||||
|
/// - `pmoserver` définit un serveur HTTP générique
|
||||||
|
/// - `pmoapp` étend ce serveur avec des méthodes webapp via ce trait
|
||||||
|
/// - Le serveur n'a pas besoin de connaître `pmoapp`
|
||||||
|
///
|
||||||
|
/// # Exemple d'implémentation
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// impl WebAppExt for pmoserver::Server {
|
||||||
|
/// fn add_webapp<W: RustEmbed>(&mut self, path: &str) -> ... {
|
||||||
|
/// // Délègue à la méthode interne add_spa
|
||||||
|
/// self.add_spa::<W>(path)
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub trait WebAppExt {
|
||||||
|
/// Ajoute une Single Page Application au serveur.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - Le chemin où monter la webapp (ex: "/app")
|
||||||
|
///
|
||||||
|
/// # 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 + '_>>
|
||||||
|
where
|
||||||
|
W: RustEmbed + Clone + Send + Sync + 'static;
|
||||||
|
|
||||||
|
/// Ajoute une webapp avec une redirection automatique depuis la racine.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - Le chemin où monter la webapp (ex: "/app")
|
||||||
|
///
|
||||||
|
/// # 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 + '_>>
|
||||||
|
where
|
||||||
|
W: RustEmbed + Clone + Send + Sync + 'static;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implémentation du trait pour pmoserver::Server (feature-gated)
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
mod pmoserver_impl;
|
||||||
57
pmoapp/src/pmoserver_impl.rs
Normal file
57
pmoapp/src/pmoserver_impl.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//! Implémentation du trait WebAppExt pour le serveur pmoserver
|
||||||
|
//!
|
||||||
|
//! Ce module enrichit `pmoserver::Server` avec les fonctionnalités webapp en
|
||||||
|
//! implémentant le trait [`WebAppExt`](crate::WebAppExt). Cette implémentation
|
||||||
|
//! permet d'enregistrer facilement des webapps embarquées sur le serveur.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! `pmoapp` étend `pmoserver::Server` sans que `pmoserver` connaisse `pmoapp`.
|
||||||
|
//! C'est le pattern d'extension : `pmoapp` ajoute des fonctionnalités à un type
|
||||||
|
//! externe via un trait, similaire au pattern utilisé par `pmoupnp` pour `UpnpServer`.
|
||||||
|
//!
|
||||||
|
//! ## Exemple d'utilisation
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmoapp::{Webapp, WebAppExt};
|
||||||
|
//! use pmoserver::ServerBuilder;
|
||||||
|
//!
|
||||||
|
//! # async fn example() {
|
||||||
|
//! let mut server = ServerBuilder::new("MyApp").build();
|
||||||
|
//!
|
||||||
|
//! // Le trait WebAppExt est automatiquement disponible
|
||||||
|
//! server.add_webapp::<Webapp>("/app").await;
|
||||||
|
//!
|
||||||
|
//! // Ou avec redirection
|
||||||
|
//! server.add_webapp_with_redirect::<Webapp>("/app").await;
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
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 + '_>>
|
||||||
|
where
|
||||||
|
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let path = path.to_string();
|
||||||
|
Box::pin(async move {
|
||||||
|
self.add_spa::<W>(&path).await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_webapp_with_redirect<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||||
|
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;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -2,7 +2,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<nav>
|
<nav>
|
||||||
<router-link to="/">Accueil</router-link> |
|
<router-link to="/">Accueil</router-link> |
|
||||||
<router-link to="/logs">Logs</router-link>
|
<router-link to="/logs">Logs</router-link> |
|
||||||
|
<router-link to="/covers-cache">Cover Cache</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 496 B |
518
pmoapp/webapp/src/components/CoverCacheManager.vue
Normal file
518
pmoapp/webapp/src/components/CoverCacheManager.vue
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cover-cache-manager">
|
||||||
|
<div class="header">
|
||||||
|
<h2>🖼️ Cover Cache Manager</h2>
|
||||||
|
<div class="stats">
|
||||||
|
<span>{{ images.length }} images</span>
|
||||||
|
<span v-if="totalHits > 0">{{ totalHits }} hits</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire d'ajout -->
|
||||||
|
<div class="add-form">
|
||||||
|
<h3>➕ Add New Cover</h3>
|
||||||
|
<form @submit.prevent="handleAddImage">
|
||||||
|
<div class="form-group">
|
||||||
|
<input
|
||||||
|
v-model="newImageUrl"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/cover.jpg"
|
||||||
|
required
|
||||||
|
:disabled="isAdding"
|
||||||
|
/>
|
||||||
|
<button type="submit" :disabled="isAdding || !newImageUrl">
|
||||||
|
{{ isAdding ? "Adding..." : "Add Image" }}
|
||||||
|
</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="refreshImages" :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>
|
||||||
|
|
||||||
|
<!-- Galerie d'images -->
|
||||||
|
<div v-if="isLoading && images.length === 0" class="loading-state">
|
||||||
|
⏳ Loading images...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="images.length === 0" class="empty-state">
|
||||||
|
📭 No images in cache. Add one using the form above!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="image-grid">
|
||||||
|
<div
|
||||||
|
v-for="image in sortedImages"
|
||||||
|
:key="image.pk"
|
||||||
|
class="image-card"
|
||||||
|
@click="selectedImage = image"
|
||||||
|
>
|
||||||
|
<div class="image-wrapper">
|
||||||
|
<img
|
||||||
|
:src="getImageUrl(image.pk, 256)"
|
||||||
|
:alt="image.source_url"
|
||||||
|
loading="lazy"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
<div class="image-overlay">
|
||||||
|
<span class="hits">👁️ {{ image.hits }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-info">
|
||||||
|
<div class="pk">{{ image.pk }}</div>
|
||||||
|
<div class="url" :title="image.source_url">
|
||||||
|
{{ truncateUrl(image.source_url) }}
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span v-if="image.last_used" class="last-used">
|
||||||
|
🕐 {{ formatDate(image.last_used) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-actions">
|
||||||
|
<button
|
||||||
|
@click.stop="handleDeleteImage(image.pk)"
|
||||||
|
class="btn-delete"
|
||||||
|
:disabled="deletingImages.has(image.pk)"
|
||||||
|
>
|
||||||
|
{{ deletingImages.has(image.pk) ? "..." : "🗑️" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de détails -->
|
||||||
|
<div v-if="selectedImage" class="modal" @click="selectedImage = null">
|
||||||
|
<div class="modal-content" @click.stop>
|
||||||
|
<button class="modal-close" @click="selectedImage = null">✕</button>
|
||||||
|
<img
|
||||||
|
:src="getImageUrl(selectedImage.pk)"
|
||||||
|
:alt="selectedImage.source_url"
|
||||||
|
class="modal-image"
|
||||||
|
/>
|
||||||
|
<div class="modal-info">
|
||||||
|
<h3>Image Details</h3>
|
||||||
|
<p><strong>PK:</strong> {{ selectedImage.pk }}</p>
|
||||||
|
<p><strong>Source URL:</strong> <a :href="selectedImage.source_url" target="_blank">{{ selectedImage.source_url }}</a></p>
|
||||||
|
<p><strong>Hits:</strong> {{ selectedImage.hits }}</p>
|
||||||
|
<p v-if="selectedImage.last_used"><strong>Last Used:</strong> {{ formatDate(selectedImage.last_used) }}</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="copyImageUrl(selectedImage.pk)" class="btn-secondary">
|
||||||
|
📋 Copy URL
|
||||||
|
</button>
|
||||||
|
<button @click="handleDeleteImage(selectedImage.pk); selectedImage = null" class="btn-danger">
|
||||||
|
🗑️ Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import type { CacheEntry } from "../services/coverCache";
|
||||||
|
import {
|
||||||
|
listImages,
|
||||||
|
addImage,
|
||||||
|
deleteImage,
|
||||||
|
purgeCache,
|
||||||
|
consolidateCache,
|
||||||
|
getImageUrl,
|
||||||
|
} from "../services/coverCache";
|
||||||
|
|
||||||
|
// --- États ---
|
||||||
|
const images = ref<CacheEntry[]>([]);
|
||||||
|
const selectedImage = ref<CacheEntry | null>(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const sortBy = ref<"hits" | "last_used" | "recent">("hits");
|
||||||
|
|
||||||
|
// Formulaire d'ajout
|
||||||
|
const newImageUrl = ref("");
|
||||||
|
const isAdding = ref(false);
|
||||||
|
const addError = ref("");
|
||||||
|
const addSuccess = ref("");
|
||||||
|
|
||||||
|
// Contrôles
|
||||||
|
const isConsolidating = ref(false);
|
||||||
|
const isPurging = ref(false);
|
||||||
|
const deletingImages = ref(new Set<string>());
|
||||||
|
|
||||||
|
// --- Computed ---
|
||||||
|
const totalHits = computed(() => images.value.reduce((sum, i) => sum + i.hits, 0));
|
||||||
|
|
||||||
|
const sortedImages = computed(() => {
|
||||||
|
const arr = [...images.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 refreshImages() {
|
||||||
|
isLoading.value = true;
|
||||||
|
try { images.value = await listImages(); }
|
||||||
|
finally { isLoading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddImage() {
|
||||||
|
if(!newImageUrl.value) return;
|
||||||
|
isAdding.value = true; addError.value=""; addSuccess.value="";
|
||||||
|
try {
|
||||||
|
const result = await addImage(newImageUrl.value);
|
||||||
|
addSuccess.value = `Image added! PK: ${result.pk}`;
|
||||||
|
newImageUrl.value = "";
|
||||||
|
await refreshImages();
|
||||||
|
} catch(e:any) { addError.value = e.message ?? "Failed to add image"; }
|
||||||
|
finally { isAdding.value=false; setTimeout(()=>addSuccess.value="",1500); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteImage(pk:string){
|
||||||
|
if(!confirm(`Delete image ${pk}?`)) return;
|
||||||
|
deletingImages.value.add(pk);
|
||||||
|
try{ await deleteImage(pk); await refreshImages(); }
|
||||||
|
finally{ deletingImages.value.delete(pk); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePurge(){
|
||||||
|
if(!confirm("⚠️ Delete ALL images?")) return;
|
||||||
|
isPurging.value = true;
|
||||||
|
try{ await purgeCache(); await refreshImages(); }
|
||||||
|
finally{ isPurging.value=false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConsolidate(){
|
||||||
|
if(!confirm("Consolidate cache?")) return;
|
||||||
|
isConsolidating.value=true;
|
||||||
|
try{ await consolidateCache(); await refreshImages(); }
|
||||||
|
finally{ isConsolidating.value=false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyImageUrl(pk:string){
|
||||||
|
navigator.clipboard.writeText(window.location.origin + getImageUrl(pk));
|
||||||
|
alert("✅ URL copied!");
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateUrl(url:string,maxLength=40){ return url.length<=maxLength?url:url.slice(0,maxLength-3)+"..."; }
|
||||||
|
function formatDate(dateString:string){
|
||||||
|
const d=new Date(dateString), diff=Date.now()-d.getTime(), 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();
|
||||||
|
}
|
||||||
|
function handleImageError(e:Event){(e.target as HTMLImageElement).src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Crect fill='%23333' width='256' height='256'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-size='20'%3EError%3C/text%3E%3C/svg%3E";}
|
||||||
|
|
||||||
|
onMounted(()=>refreshImages());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cover-cache-manager {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.form-group input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
background: #61dafb;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
button:not(.btn-danger):not(.btn-secondary):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;
|
||||||
|
}
|
||||||
|
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 d'images */
|
||||||
|
.image-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.image-card {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.image-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.image-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 100%; /* Ratio 1:1 */
|
||||||
|
background: #1a1a1a;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.image-wrapper img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.image-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.hits {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.image-info {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.pk {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #61dafb;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.url {
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
.image-actions {
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
.btn-delete {
|
||||||
|
width: 100%;
|
||||||
|
background: #555;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn-delete:hover:not(:disabled) {
|
||||||
|
background: #ff6b6b;
|
||||||
|
} /* 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: 800px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.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-image {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.modal-info {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.modal-info h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
.modal-info p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.modal-info a {
|
||||||
|
color: #61dafb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.modal-info a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -87,11 +87,41 @@ function formatTimestamp(timestamp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdown(text) {
|
function renderMarkdown(text) {
|
||||||
// Convertir markdown en HTML et nettoyer pour la sécurité
|
// ÉTAPE 1 : Pré-processing pour détecter et protéger le XML
|
||||||
const rawHtml = marked.parse(text, { async: false })
|
let processedText = text
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
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]*$/)
|
||||||
|
|
||||||
|
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
|
||||||
|
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]*/)
|
||||||
|
if (xmlMatch) {
|
||||||
|
const xmlContent = xmlMatch[0]
|
||||||
|
const beforeXml = text.substring(0, text.indexOf(xmlContent))
|
||||||
|
processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ÉTAPE 2 : Convertir markdown en HTML
|
||||||
|
const rawHtml = marked.parse(processedText, { async: false })
|
||||||
|
|
||||||
|
// ÉTAPE 3 : Nettoyer pour la sécurité
|
||||||
return DOMPurify.sanitize(rawHtml, {
|
return DOMPurify.sanitize(rawHtml, {
|
||||||
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br'],
|
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span'],
|
||||||
ALLOWED_ATTR: ['href', 'target']
|
ALLOWED_ATTR: ['href', 'target', 'class']
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,16 +455,46 @@ button.active {
|
|||||||
|
|
||||||
.markdown-content :deep(pre) {
|
.markdown-content :deep(pre) {
|
||||||
background: #2d2d30;
|
background: #2d2d30;
|
||||||
padding: 0.5rem;
|
padding: 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 0.25rem 0;
|
margin: 0.5rem 0;
|
||||||
|
border: 1px solid #3e3e42;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :deep(pre code) {
|
.markdown-content :deep(pre code) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: #d4d4d4;
|
color: #d4d4d4;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Coloration pour les blocs XML */
|
||||||
|
.markdown-content :deep(pre code.language-xml) {
|
||||||
|
color: #ce9178;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar pour les blocs de code longs */
|
||||||
|
.markdown-content :deep(pre)::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content :deep(pre)::-webkit-scrollbar-track {
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content :deep(pre)::-webkit-scrollbar-thumb {
|
||||||
|
background: #424242;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content :deep(pre)::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4e4e4e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :deep(strong) {
|
.markdown-content :deep(strong) {
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import HelloWorld from "../components/HelloWorld.vue";
|
import HelloWorld from "../components/HelloWorld.vue";
|
||||||
import LogView from "../components/LogView.vue";
|
import LogView from "../components/LogView.vue";
|
||||||
|
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/", name: "home", component: HelloWorld },
|
{ path: "/", name: "home", component: HelloWorld },
|
||||||
{ path: "/logs", name: "logs", component: LogView },
|
{ path: "/logs", name: "logs", component: LogView },
|
||||||
|
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
120
pmoapp/webapp/src/services/coverCache.ts
Normal file
120
pmoapp/webapp/src/services/coverCache.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Service API pour interagir avec le cache d'images de couvertures
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CacheEntry {
|
||||||
|
pk: string;
|
||||||
|
source_url: string;
|
||||||
|
hits: number;
|
||||||
|
last_used: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddImageRequest {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddImageResponse {
|
||||||
|
pk: string;
|
||||||
|
url: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste toutes les images en cache
|
||||||
|
*/
|
||||||
|
export async function listImages(): Promise<CacheEntry[]> {
|
||||||
|
const response = await fetch("/api/covers");
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: ApiError = await response.json();
|
||||||
|
throw new Error(error.message || "Failed to fetch images");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les informations d'une image spécifique
|
||||||
|
*/
|
||||||
|
export async function getImageInfo(pk: string): Promise<CacheEntry> {
|
||||||
|
const response = await fetch(`/api/covers/${pk}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: ApiError = await response.json();
|
||||||
|
throw new Error(error.message || "Failed to fetch image info");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute une nouvelle image au cache depuis une URL
|
||||||
|
*/
|
||||||
|
export async function addImage(url: string): Promise<AddImageResponse> {
|
||||||
|
const response = await fetch("/api/covers", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: ApiError = await response.json();
|
||||||
|
throw new Error(error.message || "Failed to add image");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une image du cache
|
||||||
|
*/
|
||||||
|
export async function deleteImage(pk: string): Promise<void> {
|
||||||
|
const response = await fetch(`/api/covers/${pk}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: ApiError = await response.json();
|
||||||
|
throw new Error(error.message || "Failed to delete image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge complètement le cache
|
||||||
|
*/
|
||||||
|
export async function purgeCache(): Promise<void> {
|
||||||
|
const response = await fetch("/api/covers", {
|
||||||
|
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 images manquantes)
|
||||||
|
*/
|
||||||
|
export async function consolidateCache(): Promise<void> {
|
||||||
|
const response = await fetch("/api/covers/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 afficher une image
|
||||||
|
*/
|
||||||
|
export function getImageUrl(pk: string, size?: number): string {
|
||||||
|
if (size) {
|
||||||
|
return `/covers/images/${pk}/${size}`;
|
||||||
|
}
|
||||||
|
return `/covers/images/${pk}`;
|
||||||
|
}
|
||||||
@@ -40,10 +40,13 @@ impl Clone for Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
|
||||||
pub fn load_config(filename: &str) -> Result<Self> {
|
pub fn load_config(filename: &str) -> Result<Self> {
|
||||||
let mut path = filename.to_string();
|
let mut path = filename.to_string();
|
||||||
let mut data: Option<Vec<u8>> = None;
|
let mut data: Option<Vec<u8>> = None;
|
||||||
|
|
||||||
|
let mut default_value: Value = serde_yaml::from_str(DEFAULT_CONFIG)?;
|
||||||
|
|
||||||
// Essayer de charger depuis différents emplacements
|
// Essayer de charger depuis différents emplacements
|
||||||
if !filename.is_empty() {
|
if !filename.is_empty() {
|
||||||
info!(config_file=%path, "Trying to load config");
|
info!(config_file=%path, "Trying to load config");
|
||||||
@@ -97,8 +100,11 @@ impl Config {
|
|||||||
DEFAULT_CONFIG.as_bytes().to_vec()
|
DEFAULT_CONFIG.as_bytes().to_vec()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut config_value: Value = serde_yaml::from_slice(&yaml_data)?;
|
|
||||||
config_value = Self::lower_keys_value(config_value);
|
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);
|
||||||
|
|
||||||
Self::apply_env_overrides(&mut config_value);
|
Self::apply_env_overrides(&mut config_value);
|
||||||
|
|
||||||
if path.is_empty() || !Self::is_writable(&path) {
|
if path.is_empty() || !Self::is_writable(&path) {
|
||||||
@@ -175,8 +181,10 @@ impl Config {
|
|||||||
fn get_value_internal(data: &Value, path: &[&str]) -> Result<Value> {
|
fn get_value_internal(data: &Value, path: &[&str]) -> Result<Value> {
|
||||||
let mut current = data;
|
let mut current = data;
|
||||||
for (i, key) in path.iter().enumerate() {
|
for (i, key) in path.iter().enumerate() {
|
||||||
|
|
||||||
if let Value::Mapping(map) = current {
|
if let Value::Mapping(map) = current {
|
||||||
let key = key.to_lowercase();
|
let key = key.to_lowercase();
|
||||||
|
|
||||||
if let Some(next) = map.get(&Value::String(key)) {
|
if let Some(next) = map.get(&Value::String(key)) {
|
||||||
current = next;
|
current = next;
|
||||||
} else {
|
} else {
|
||||||
@@ -317,3 +325,17 @@ impl Config {
|
|||||||
pub fn get_config() -> Arc<Config> {
|
pub fn get_config() -> Arc<Config> {
|
||||||
CONFIG.clone()
|
CONFIG.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_yaml(default: &mut Value, external: &Value) {
|
||||||
|
match (default, external) {
|
||||||
|
(Value::Mapping(dmap), Value::Mapping(emap)) => {
|
||||||
|
for (k, v) in emap {
|
||||||
|
match dmap.get_mut(k) {
|
||||||
|
Some(dv) => merge_yaml(dv, v),
|
||||||
|
None => { dmap.insert(k.clone(), v.clone()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(d, e) => *d = e.clone(), // pour les scalaires ou séquences, on remplace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
39
pmocovers/Cargo.toml
Normal file
39
pmocovers/Cargo.toml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
[package]
|
||||||
|
name = "pmocovers"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# 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
|
||||||
|
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"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["pmoserver"]
|
||||||
|
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa"]
|
||||||
311
pmocovers/src/api.rs
Normal file
311
pmocovers/src/api.rs
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
//! 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
145
pmocovers/src/cache.rs
Normal file
145
pmocovers/src/cache.rs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
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<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
dir: PathBuf::from(dir),
|
||||||
|
limit,
|
||||||
|
db,
|
||||||
|
mu: Arc::new(Mutex::new(())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
118
pmocovers/src/db.rs
Normal file
118
pmocovers/src/db.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
use rusqlite::{Connection, params};
|
||||||
|
use serde::Serialize;
|
||||||
|
use chrono::Utc;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
277
pmocovers/src/lib.rs
Normal file
277
pmocovers/src/lib.rs
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
//! # pmocovers - Service de cache d'images de couvertures pour PMOMusic
|
||||||
|
//!
|
||||||
|
//! 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
|
||||||
|
//!
|
||||||
|
//! `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
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! `pmocovers` suit le pattern d'extension des autres crates PMO :
|
||||||
|
//!
|
||||||
|
//! - `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
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Utilisation
|
||||||
|
//!
|
||||||
|
//! ### Exemple basique avec configuration automatique
|
||||||
|
//!
|
||||||
|
//! ```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 (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};
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub use openapi::ApiDoc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache 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`
|
||||||
|
pub trait CoverCacheExt {
|
||||||
|
/// Initialise le cache d'images et enregistre les routes HTTP.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `cache_dir` - Répertoire de stockage du cache
|
||||||
|
/// * `limit` - Limite de taille du cache (en nombre d'images)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Arc<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 /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>>;
|
||||||
|
|
||||||
|
/// 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>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implémentation du trait pour pmoserver::Server (feature-gated)
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
mod pmoserver_impl;
|
||||||
70
pmocovers/src/openapi.rs
Normal file
70
pmocovers/src/openapi.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
//! Documentation OpenAPI pour l'API REST du cache de couvertures
|
||||||
|
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(
|
||||||
|
crate::api::list_images,
|
||||||
|
crate::api::get_image_info,
|
||||||
|
crate::api::add_image,
|
||||||
|
crate::api::delete_image,
|
||||||
|
crate::api::purge_cache,
|
||||||
|
crate::api::consolidate_cache,
|
||||||
|
),
|
||||||
|
components(
|
||||||
|
schemas(
|
||||||
|
crate::db::CacheEntry,
|
||||||
|
crate::api::AddImageRequest,
|
||||||
|
crate::api::AddImageResponse,
|
||||||
|
crate::api::DeleteImageResponse,
|
||||||
|
crate::api::ErrorResponse,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
tags(
|
||||||
|
(name = "covers", description = "Gestion du cache d'images de couvertures")
|
||||||
|
),
|
||||||
|
info(
|
||||||
|
title = "PMOCovers API",
|
||||||
|
version = "0.1.0",
|
||||||
|
description = r#"
|
||||||
|
# API de gestion du cache d'images de couvertures
|
||||||
|
|
||||||
|
Cette API permet de gérer un cache d'images optimisé pour les couvertures d'albums.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- **Ajout d'images** : Téléchargement depuis une URL avec conversion automatique en WebP
|
||||||
|
- **Consultation** : Liste des images avec statistiques d'utilisation
|
||||||
|
- **Suppression** : Suppression individuelle ou purge complète
|
||||||
|
- **Maintenance** : Consolidation du cache pour réparer les incohérences
|
||||||
|
|
||||||
|
## Format des images
|
||||||
|
|
||||||
|
Les images sont stockées au format WebP avec :
|
||||||
|
- Une version originale (`{pk}.orig.webp`)
|
||||||
|
- Des variantes de tailles générées à la demande (`{pk}.{size}.webp`)
|
||||||
|
|
||||||
|
## Clés (pk)
|
||||||
|
|
||||||
|
Chaque image 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
|
||||||
|
"#,
|
||||||
|
contact(
|
||||||
|
name = "PMOMusic",
|
||||||
|
),
|
||||||
|
license(
|
||||||
|
name = "MIT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub struct ApiDoc;
|
||||||
175
pmocovers/src/pmoserver_impl.rs
Normal file
175
pmocovers/src/pmoserver_impl.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
//! Implémentation du trait CoverCacheExt pour le serveur pmoserver
|
||||||
|
//!
|
||||||
|
//! Ce module enrichit `pmoserver::Server` avec les fonctionnalités de cache d'images en
|
||||||
|
//! implémentant le trait [`CoverCacheExt`](crate::CoverCacheExt). Cette implémentation
|
||||||
|
//! permet d'initialiser facilement le cache et d'enregistrer les routes HTTP.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! `pmocovers` étend `pmoserver::Server` sans que `pmoserver` connaisse `pmocovers`.
|
||||||
|
//! C'est le pattern d'extension : `pmocovers` ajoute des fonctionnalités à un type
|
||||||
|
//! externe via un trait, similaire au pattern utilisé par `pmoapp` pour `WebAppExt`.
|
||||||
|
//!
|
||||||
|
//! ## Exemple d'utilisation
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmocovers::CoverCacheExt;
|
||||||
|
//! use pmoserver::ServerBuilder;
|
||||||
|
//!
|
||||||
|
//! # async fn example() -> anyhow::Result<()> {
|
||||||
|
//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build();
|
||||||
|
//!
|
||||||
|
//! // Le trait CoverCacheExt est automatiquement disponible
|
||||||
|
//! let cache = server.init_cover_cache("./cache", 1000).await?;
|
||||||
|
//!
|
||||||
|
//! server.start().await;
|
||||||
|
//! # Ok(())
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::{api, Cache, CoverCacheExt};
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::State,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use pmoserver::Server;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Handler pour GET /covers/images/{pk}
|
||||||
|
async fn get_cover_image(
|
||||||
|
State(cache): State<Arc<Cache>>,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Response {
|
||||||
|
// Extraire pk du path
|
||||||
|
let path = req.uri().path();
|
||||||
|
let parts: Vec<&str> = path.split('/').collect();
|
||||||
|
|
||||||
|
warn!("{:?}",parts);
|
||||||
|
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pk = parts[1];
|
||||||
|
|
||||||
|
match cache.get(pk).await {
|
||||||
|
Ok(file_path) => {
|
||||||
|
match tokio::fs::read(&file_path).await {
|
||||||
|
Ok(data) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[("content-type", "image/webp")],
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour GET /covers/images/{pk}/{size}
|
||||||
|
async fn get_cover_variant(
|
||||||
|
State(cache): State<Arc<Cache>>,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Response {
|
||||||
|
// Extraire pk et size du path
|
||||||
|
let path = req.uri().path();
|
||||||
|
let parts: Vec<&str> = path.split('/').collect();
|
||||||
|
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pk = parts[1];
|
||||||
|
let size = match parts[2].parse::<usize>() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid size").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match crate::webp::generate_variant(&cache, pk, size).await {
|
||||||
|
Ok(data) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[("content-type", "image/webp")],
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot generate variant").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour GET /covers/stats
|
||||||
|
async fn get_cover_stats(State(cache): State<Arc<Cache>>) -> Response {
|
||||||
|
match cache.db.get_all() {
|
||||||
|
Ok(entries) => Json(entries).into_response(),
|
||||||
|
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot retrieve stats").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoverCacheExt for Server {
|
||||||
|
async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result<Arc<Cache>> {
|
||||||
|
let cache = Arc::new(Cache::new(cache_dir, limit)?);
|
||||||
|
|
||||||
|
// Enregistrer les routes HTTP classiques pour servir les images
|
||||||
|
let image_router = Router::new()
|
||||||
|
.route("/{pk}", get(get_cover_image))
|
||||||
|
.route("/{pk}/{size}", get(get_cover_variant))
|
||||||
|
.with_state(cache.clone());
|
||||||
|
|
||||||
|
self.add_router("/covers/images", image_router).await;
|
||||||
|
self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await;
|
||||||
|
|
||||||
|
// Router API RESTful
|
||||||
|
// Router API RESTful qui sera nesté sous /api/covers par add_openapi
|
||||||
|
let api_router = Router::new()
|
||||||
|
// Liste et ajout
|
||||||
|
.route(
|
||||||
|
"/",
|
||||||
|
get(api::list_images) // GET /api/covers
|
||||||
|
.post(api::add_image) // POST /api/covers
|
||||||
|
.delete(api::purge_cache), // DELETE /api/covers
|
||||||
|
)
|
||||||
|
// Ressource unique
|
||||||
|
.route(
|
||||||
|
"/{pk}",
|
||||||
|
get(api::get_image_info) // GET /api/covers/{pk}
|
||||||
|
.delete(api::delete_image), // DELETE /api/covers/{pk}
|
||||||
|
)
|
||||||
|
// Action spécifique
|
||||||
|
.route(
|
||||||
|
"/consolidate",
|
||||||
|
post(api::consolidate_cache), // POST /api/covers/consolidate
|
||||||
|
)
|
||||||
|
.with_state(cache.clone());
|
||||||
|
|
||||||
|
// Documentation OpenAPI via Utoipa
|
||||||
|
let openapi = crate::ApiDoc::openapi();
|
||||||
|
|
||||||
|
// Enregistrer l'API avec Swagger UI
|
||||||
|
// Le router sera nesté automatiquement sous /api/covers par add_openapi
|
||||||
|
// Routes finales: /api/covers, /api/covers/{pk}, /api/covers/consolidate
|
||||||
|
// Swagger UI sera disponible à /swagger-ui/covers
|
||||||
|
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()?;
|
||||||
|
|
||||||
|
info!("cache directory {}, size {}",cache_dir,limit);
|
||||||
|
|
||||||
|
self.init_cover_cache(&cache_dir, limit).await
|
||||||
|
}
|
||||||
|
}
|
||||||
61
pmocovers/src/webp.rs
Normal file
61
pmocovers/src/webp.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use image::{DynamicImage, imageops::FilterType};
|
||||||
|
use webp::{Encoder, WebPMemory};
|
||||||
|
|
||||||
|
pub fn encode_webp(img: &DynamicImage) -> Result<Vec<u8>> {
|
||||||
|
let rgb_img = img.to_rgba8();
|
||||||
|
let encoder = Encoder::from_rgba(&rgb_img, rgb_img.width(), rgb_img.height());
|
||||||
|
let webp_data: WebPMemory = encoder.encode(85.0);
|
||||||
|
Ok(webp_data.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_square(img: &DynamicImage, size: u32) -> DynamicImage {
|
||||||
|
let (width, height) = (img.width(), img.height());
|
||||||
|
|
||||||
|
// Calculer le ratio de mise à l'échelle
|
||||||
|
let scale = if width > height {
|
||||||
|
size as f32 / width as f32
|
||||||
|
} else {
|
||||||
|
size as f32 / height as f32
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_width = (width as f32 * scale) as u32;
|
||||||
|
let new_height = (height as f32 * scale) as u32;
|
||||||
|
|
||||||
|
// Redimensionner l'image
|
||||||
|
let resized = img.resize(new_width, new_height, FilterType::Lanczos3);
|
||||||
|
|
||||||
|
// Créer une image carrée avec fond transparent
|
||||||
|
let mut square = DynamicImage::new_rgba8(size, size);
|
||||||
|
|
||||||
|
// Calculer la position pour centrer l'image redimensionnée
|
||||||
|
let x = (size - new_width) / 2;
|
||||||
|
let y = (size - new_height) / 2;
|
||||||
|
|
||||||
|
// Copier l'image redimensionnée au centre du carré
|
||||||
|
image::imageops::overlay(&mut square, &resized, x.into(), y.into());
|
||||||
|
|
||||||
|
square
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_variant(cache: &super::cache::Cache, pk: &str, size: usize) -> Result<Vec<u8>> {
|
||||||
|
let variant_path = cache.dir.join(format!("{}.{}.webp", pk, size));
|
||||||
|
|
||||||
|
if variant_path.exists() {
|
||||||
|
return Ok(tokio::fs::read(variant_path).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let orig_path = cache.dir.join(format!("{}.orig.webp", pk));
|
||||||
|
|
||||||
|
// Charger l'image de manière synchrone (image::open n'est pas async)
|
||||||
|
let img = tokio::task::spawn_blocking(move || {
|
||||||
|
image::open(orig_path)
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
let square = ensure_square(&img, size as u32);
|
||||||
|
let webp_data = encode_webp(&square)?;
|
||||||
|
|
||||||
|
tokio::fs::write(&variant_path, &webp_data).await?;
|
||||||
|
Ok(webp_data)
|
||||||
|
}
|
||||||
23
pmoserver/Cargo.toml
Normal file
23
pmoserver/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "pmoserver"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pmoconfig = { path = "../pmoconfig" }
|
||||||
|
|
||||||
|
axum = "0.8.4"
|
||||||
|
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
futures-util = "0.3"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||||
|
futures = "0.3"
|
||||||
|
async-stream = "0.3.6"
|
||||||
|
axum-server = "0.7.2"
|
||||||
|
axum-embed = "0.1.0"
|
||||||
|
rust-embed = "8.7.2"
|
||||||
|
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||||
|
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
||||||
76
pmoserver/src/lib.rs
Normal file
76
pmoserver/src/lib.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
//! # pmoserver - Serveur web haut niveau basé sur Axum
|
||||||
|
//!
|
||||||
|
//! Cette crate fournit une abstraction simple et ergonomique pour créer des serveurs HTTP
|
||||||
|
//! avec Axum, spécialement conçue pour les applications UPnP et les serveurs multimédia.
|
||||||
|
//!
|
||||||
|
//! ## Fonctionnalités
|
||||||
|
//!
|
||||||
|
//! - 🚀 **API de haut niveau** : Interface simple pour créer des serveurs HTTP avec Axum
|
||||||
|
//! - 🎯 **Support UPnP** : Implémentation du trait `UpnpServer` pour connecter des devices UPnP
|
||||||
|
//! - 📡 **Server-Sent Events (SSE)** : Support intégré pour les logs en temps réel via SSE
|
||||||
|
//! - ⚛️ **Applications SPA** : Support pour servir des applications Single Page (Vue.js, React, etc.)
|
||||||
|
//! - 📁 **Fichiers statiques** : Serve de fichiers statiques avec `RustEmbed`
|
||||||
|
//! - 🔀 **Redirections** : Support pour les redirections HTTP
|
||||||
|
//! - 📚 **Documentation OpenAPI** : Génération automatique de Swagger UI
|
||||||
|
//! - ⚡ **Arrêt gracieux** : Gestion propre de l'arrêt sur Ctrl+C
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! La crate est organisée en plusieurs modules :
|
||||||
|
//!
|
||||||
|
//! - [`server`] : Implémentation du serveur principal et du builder
|
||||||
|
//! - [`logs`] : Système de logs SSE pour monitoring en temps réel
|
||||||
|
//!
|
||||||
|
//! ## Exemple d'utilisation
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmoserver::{ServerBuilder, logs::{LogState, SseLayer}};
|
||||||
|
//! use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() {
|
||||||
|
//! // Configuration des logs avec SSE
|
||||||
|
//! let log_state = LogState::new();
|
||||||
|
//! tracing_subscriber::registry()
|
||||||
|
//! .with(SseLayer::new(log_state.clone()))
|
||||||
|
//! .init();
|
||||||
|
//!
|
||||||
|
//! // Création et démarrage du serveur
|
||||||
|
//! let mut server = ServerBuilder::new("MyServer")
|
||||||
|
//! .http_port(8080)
|
||||||
|
//! .build();
|
||||||
|
//!
|
||||||
|
//! // Ajout d'une route JSON
|
||||||
|
//! server.add_route("/api/status", || async {
|
||||||
|
//! serde_json::json!({"status": "ok"})
|
||||||
|
//! }).await;
|
||||||
|
//!
|
||||||
|
//! // Démarrage
|
||||||
|
//! server.start().await;
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Intégration UPnP
|
||||||
|
//!
|
||||||
|
//! Le serveur peut être étendu avec UPnP via le trait `pmoupnp::UpnpServer`.
|
||||||
|
//! L'implémentation est fournie par `pmoupnp` (feature `pmoserver`), permettant
|
||||||
|
//! de connecter des devices UPnP sans que `pmoserver` dépende de `pmoupnp` :
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmoupnp::{UpnpServer, mediarenderer::MEDIA_RENDERER};
|
||||||
|
//! use pmoserver::ServerBuilder;
|
||||||
|
//!
|
||||||
|
//! # async fn example() {
|
||||||
|
//! let mut server = ServerBuilder::new("MediaRenderer").build();
|
||||||
|
//! let device = MEDIA_RENDERER.create_instance();
|
||||||
|
//!
|
||||||
|
//! // Le trait UpnpServer est automatiquement disponible (implémenté dans pmoupnp)
|
||||||
|
//! device.register_urls(&mut server).await;
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod server;
|
||||||
|
pub mod logs;
|
||||||
|
|
||||||
|
pub use server::{Server, ServerBuilder, ServerInfo};
|
||||||
|
pub use logs::{LogState, SseLayer, log_sse, log_dump, init_logging, LoggingOptions};
|
||||||
@@ -19,6 +19,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
use tracing_subscriber::{Registry, layer::SubscriberExt};
|
||||||
|
|
||||||
/// Représente une entrée de log
|
/// Représente une entrée de log
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -157,3 +158,60 @@ fn filter_entry(entry: &LogEntry, q: &LogQuery) -> bool {
|
|||||||
|
|
||||||
allowed
|
allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Options d'initialisation du système de logging
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LoggingOptions {
|
||||||
|
/// Capacité du buffer circulaire (nombre d'entrées conservées)
|
||||||
|
pub buffer_capacity: usize,
|
||||||
|
/// Activer la sortie vers stderr/stdout
|
||||||
|
pub enable_console: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoggingOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
buffer_capacity: 1000,
|
||||||
|
enable_console: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialise le système de logging avec SSE et optionnellement la console
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `options` - Options de configuration du logging
|
||||||
|
///
|
||||||
|
/// # Retourne
|
||||||
|
/// Le `LogState` qui peut être utilisé pour ajouter les routes de logging au serveur
|
||||||
|
///
|
||||||
|
/// # Exemple
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pmoserver::logs::{init_logging, LoggingOptions};
|
||||||
|
///
|
||||||
|
/// let log_state = init_logging(LoggingOptions {
|
||||||
|
/// buffer_capacity: 1000,
|
||||||
|
/// enable_console: true,
|
||||||
|
/// });
|
||||||
|
/// ```
|
||||||
|
pub fn init_logging(options: LoggingOptions) -> LogState {
|
||||||
|
let log_state = LogState::new(options.buffer_capacity);
|
||||||
|
|
||||||
|
let subscriber = Registry::default().with(SseLayer::new(log_state.clone()));
|
||||||
|
|
||||||
|
if options.enable_console {
|
||||||
|
let subscriber = subscriber.with(
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
|
.with_target(true)
|
||||||
|
.with_level(true)
|
||||||
|
.with_ansi(true),
|
||||||
|
);
|
||||||
|
tracing::subscriber::set_global_default(subscriber)
|
||||||
|
.expect("Failed to set global default subscriber");
|
||||||
|
} else {
|
||||||
|
tracing::subscriber::set_global_default(subscriber)
|
||||||
|
.expect("Failed to set global default subscriber");
|
||||||
|
}
|
||||||
|
|
||||||
|
log_state
|
||||||
|
}
|
||||||
@@ -13,30 +13,27 @@
|
|||||||
//! - 📚 **Documentation API** : OpenAPI/Swagger automatique avec `add_openapi()`
|
//! - 📚 **Documentation API** : OpenAPI/Swagger automatique avec `add_openapi()`
|
||||||
//! - ⚡ **Gestion gracieuse** : Arrêt propre sur Ctrl+C
|
//! - ⚡ **Gestion gracieuse** : Arrêt propre sur Ctrl+C
|
||||||
|
|
||||||
pub mod logs;
|
use crate::logs::{LogState, LoggingOptions, init_logging, log_dump, log_sse};
|
||||||
|
|
||||||
use axum::handler::Handler;
|
use axum::handler::Handler;
|
||||||
use axum::response::Redirect;
|
use axum::response::Redirect;
|
||||||
use axum::routing::get;
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use axum_embed::ServeEmbed;
|
use axum_embed::ServeEmbed;
|
||||||
use pmoconfig::get_config;
|
use pmoconfig::get_config;
|
||||||
use rust_embed::RustEmbed;
|
use rust_embed::RustEmbed;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::{net::SocketAddr, sync::Arc};
|
use std::future::Future;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::{signal, sync::RwLock, task::JoinHandle};
|
use tokio::{signal, sync::RwLock, task::JoinHandle};
|
||||||
use tracing::{info, warn, debug, error};
|
use tracing::info;
|
||||||
use utoipa::OpenApi;
|
|
||||||
use utoipa_swagger_ui::SwaggerUi;
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
|
|
||||||
/// Info serveur sérialisable
|
/// Info serveur sérialisable
|
||||||
#[derive(Clone, Serialize, utoipa::ToSchema)]
|
#[derive(Clone, Serialize, utoipa::ToSchema)]
|
||||||
pub struct ServerInfo {
|
pub struct ServerInfo {
|
||||||
/// Nom du serveur
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// URL de base
|
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
/// Port HTTP
|
|
||||||
pub http_port: u16,
|
pub http_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +45,9 @@ pub struct Server {
|
|||||||
router: Arc<RwLock<Router>>,
|
router: Arc<RwLock<Router>>,
|
||||||
api_router: Arc<RwLock<Option<Router>>>,
|
api_router: Arc<RwLock<Option<Router>>>,
|
||||||
join_handle: Option<JoinHandle<()>>,
|
join_handle: Option<JoinHandle<()>>,
|
||||||
|
log_state: Option<LogState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(RustEmbed, Clone)]
|
|
||||||
#[folder = "webapp/dist"]
|
|
||||||
pub struct Webapp;
|
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
/// Crée une nouvelle instance de serveur
|
/// Crée une nouvelle instance de serveur
|
||||||
///
|
///
|
||||||
@@ -77,6 +71,7 @@ impl Server {
|
|||||||
router: Arc::new(RwLock::new(Router::new())),
|
router: Arc::new(RwLock::new(Router::new())),
|
||||||
api_router: Arc::new(RwLock::new(None)),
|
api_router: Arc::new(RwLock::new(None)),
|
||||||
join_handle: None,
|
join_handle: None,
|
||||||
|
log_state: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +79,7 @@ impl Server {
|
|||||||
let config = get_config();
|
let config = get_config();
|
||||||
let url = config.get_base_url();
|
let url = config.get_base_url();
|
||||||
let port = config.get_http_port();
|
let port = config.get_http_port();
|
||||||
|
Self::new("PMO-Music-Server", url, port)
|
||||||
return Self::new("PMO-Music-Server", url, port);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ajoute une route JSON dynamique
|
/// Ajoute une route JSON dynamique
|
||||||
@@ -116,11 +110,10 @@ impl Server {
|
|||||||
pub async fn add_route<F, Fut, T>(&mut self, path: &str, f: F)
|
pub async fn add_route<F, Fut, T>(&mut self, path: &str, f: F)
|
||||||
where
|
where
|
||||||
F: Fn() -> Fut + Send + Sync + 'static,
|
F: Fn() -> Fut + Send + Sync + 'static,
|
||||||
Fut: std::future::Future<Output = T> + Send + 'static,
|
Fut: Future<Output = T> + Send + 'static,
|
||||||
T: Serialize + Send + 'static,
|
T: Serialize + Send + 'static,
|
||||||
{
|
{
|
||||||
let f = Arc::new(f);
|
let f = Arc::new(f);
|
||||||
|
|
||||||
let handler = {
|
let handler = {
|
||||||
let f = f.clone();
|
let f = f.clone();
|
||||||
move || {
|
move || {
|
||||||
@@ -132,53 +125,81 @@ impl Server {
|
|||||||
let route = Router::new().route("/", get(handler));
|
let route = Router::new().route("/", get(handler));
|
||||||
|
|
||||||
let mut r = self.router.write().await;
|
let mut r = self.router.write().await;
|
||||||
*r = std::mem::take(&mut *r).nest(path, route);
|
*r = if path == "/" {
|
||||||
|
std::mem::take(&mut *r).merge(route)
|
||||||
|
} else {
|
||||||
|
std::mem::take(&mut *r).nest(path, route)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ajoute un répertoire de fichiers statiques
|
/// Ajoute un handler Axum standard
|
||||||
///
|
pub async fn add_handler<H, T>(&mut self, path: &str, handler: H)
|
||||||
/// Sert des fichiers embarqués via `RustEmbed`. Les fichiers sont compilés
|
where
|
||||||
/// dans le binaire à la compilation.
|
H: Handler<T, ()> + Clone + 'static,
|
||||||
///
|
T: 'static,
|
||||||
/// # Arguments
|
{
|
||||||
///
|
let route = Router::new().route("/", get(handler.clone()));
|
||||||
/// * `path` - Chemin où monter les fichiers statiques
|
|
||||||
///
|
let mut r = self.router.write().await;
|
||||||
/// # Type Parameter
|
*r = if path == "/" {
|
||||||
///
|
std::mem::take(&mut *r).merge(route)
|
||||||
/// * `E` - Type RustEmbed définissant le répertoire à servir
|
} else {
|
||||||
///
|
std::mem::take(&mut *r).nest(path, route)
|
||||||
/// # Exemple
|
};
|
||||||
///
|
}
|
||||||
/// ```ignore
|
|
||||||
/// use pmoupnp::server::Server;
|
/// Ajoute un handler POST avec état
|
||||||
/// use rust_embed::RustEmbed;
|
pub async fn add_post_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
||||||
///
|
where
|
||||||
/// #[derive(RustEmbed, Clone)]
|
H: Handler<T, S> + Clone + 'static,
|
||||||
/// #[folder = "static/"]
|
T: 'static,
|
||||||
/// struct Assets;
|
S: Clone + Send + Sync + 'static,
|
||||||
///
|
{
|
||||||
/// # #[tokio::main]
|
let route = Router::new()
|
||||||
/// # async fn main() {
|
.route("/", post(handler.clone()))
|
||||||
/// let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
.with_state(state.clone());
|
||||||
/// server.add_dir::<Assets>("/assets").await;
|
|
||||||
/// // Les fichiers de static/ sont accessibles via /assets/*
|
let mut r = self.router.write().await;
|
||||||
/// # }
|
*r = if path == "/" {
|
||||||
/// ```
|
std::mem::take(&mut *r).merge(route)
|
||||||
|
} else {
|
||||||
|
std::mem::take(&mut *r).nest(path, route)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un handler avec état
|
||||||
|
pub async fn add_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
||||||
|
where
|
||||||
|
H: Handler<T, S> + Clone + 'static,
|
||||||
|
T: 'static,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let route = Router::new()
|
||||||
|
.route("/", get(handler.clone()))
|
||||||
|
.with_state(state.clone());
|
||||||
|
|
||||||
|
let mut r = self.router.write().await;
|
||||||
|
*r = if path == "/" {
|
||||||
|
std::mem::take(&mut *r).merge(route)
|
||||||
|
} else {
|
||||||
|
std::mem::take(&mut *r).nest(path, route)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un répertoire statique
|
||||||
pub async fn add_dir<E>(&mut self, path: &str)
|
pub async fn add_dir<E>(&mut self, path: &str)
|
||||||
where
|
where
|
||||||
E: RustEmbed + Clone + Send + Sync + 'static,
|
E: RustEmbed + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let serve = ServeEmbed::<E>::new();
|
let serve = ServeEmbed::<E>::new();
|
||||||
|
|
||||||
let mut r = self.router.write().await;
|
let mut r = self.router.write().await;
|
||||||
|
|
||||||
if path == "/" {
|
let route = Router::new().fallback_service(serve);
|
||||||
*r = std::mem::take(&mut *r).fallback_service(serve);
|
*r = if path == "/" {
|
||||||
|
std::mem::take(&mut *r).merge(route)
|
||||||
} else {
|
} else {
|
||||||
let route = Router::new().fallback_service(serve);
|
std::mem::take(&mut *r).nest(path, route)
|
||||||
*r = std::mem::take(&mut *r).nest(path, route);
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ajoute une Single Page Application (SPA)
|
/// Ajoute une Single Page Application (SPA)
|
||||||
@@ -233,127 +254,12 @@ impl Server {
|
|||||||
|
|
||||||
let mut r = self.router.write().await;
|
let mut r = self.router.write().await;
|
||||||
|
|
||||||
if path == "/" {
|
let route = Router::new().fallback_service(serve);
|
||||||
*r = std::mem::take(&mut *r).fallback_service(serve);
|
*r = if path == "/" {
|
||||||
|
std::mem::take(&mut *r).merge(route)
|
||||||
} else {
|
} else {
|
||||||
let route = Router::new().fallback_service(serve);
|
std::mem::take(&mut *r).nest(path, route)
|
||||||
*r = std::mem::take(&mut *r).nest(path, route);
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ajoute un handler Axum personnalisé
|
|
||||||
///
|
|
||||||
/// Pour des cas d'usage avancés nécessitant un contrôle complet sur le handler.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `path` - Chemin de la route
|
|
||||||
/// * `handler` - Handler Axum
|
|
||||||
///
|
|
||||||
/// # Exemple
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// # use pmoupnp::server::Server;
|
|
||||||
/// # use axum::response::Html;
|
|
||||||
/// # #[tokio::main]
|
|
||||||
/// # async fn main() {
|
|
||||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
|
||||||
/// async fn custom_handler() -> Html<&'static str> {
|
|
||||||
/// Html("<h1>Custom Response</h1>")
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// server.add_handler("/custom", custom_handler).await;
|
|
||||||
/// # }
|
|
||||||
/// ```
|
|
||||||
pub async fn add_handler<H, T>(&mut self, path: &str, handler: H)
|
|
||||||
where
|
|
||||||
H: Handler<T, ()>,
|
|
||||||
T: 'static,
|
|
||||||
{
|
|
||||||
let route = Router::new().route("/", get(handler));
|
|
||||||
|
|
||||||
let mut r = self.router.write().await;
|
|
||||||
*r = std::mem::take(&mut *r).nest(path, route);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ajoute un handler avec state (pour SSE, extracteurs, etc.)
|
|
||||||
///
|
|
||||||
/// Permet d'utiliser des extracteurs Axum comme `State`, `Query`, etc.
|
|
||||||
/// Idéal pour Server-Sent Events (SSE), WebSockets ou tout handler nécessitant un état partagé.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `path` - Chemin de la route
|
|
||||||
/// * `handler` - Handler Axum avec extracteurs
|
|
||||||
/// * `state` - État partagé (doit être Clone + Send + Sync)
|
|
||||||
///
|
|
||||||
/// # Exemple avec SSE
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// use pmoupnp::server::Server;
|
|
||||||
/// use axum::extract::State;
|
|
||||||
/// use axum::response::sse::{Event, Sse, KeepAlive};
|
|
||||||
/// use tokio::sync::broadcast;
|
|
||||||
///
|
|
||||||
/// #[derive(Clone)]
|
|
||||||
/// struct LogState {
|
|
||||||
/// tx: broadcast::Sender<String>
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// impl LogState {
|
|
||||||
/// fn subscribe(&self) -> broadcast::Receiver<String> {
|
|
||||||
/// self.tx.subscribe()
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// async fn log_sse(State(state): State<LogState>) -> Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
|
||||||
/// let mut rx = state.subscribe();
|
|
||||||
/// let stream = async_stream::stream! {
|
|
||||||
/// while let Ok(msg) = rx.recv().await {
|
|
||||||
/// yield Ok(Event::default().data(msg));
|
|
||||||
/// }
|
|
||||||
/// };
|
|
||||||
/// Sse::new(stream).keep_alive(KeepAlive::default())
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// let log_state = LogState { tx: broadcast::channel(100).0 };
|
|
||||||
/// server.add_handler_with_state("/logs", log_sse, log_state).await;
|
|
||||||
/// ```
|
|
||||||
pub async fn add_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
|
||||||
where
|
|
||||||
H: Handler<T, S>,
|
|
||||||
T: 'static,
|
|
||||||
S: Clone + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
let route = Router::new()
|
|
||||||
.route("/", get(handler))
|
|
||||||
.with_state(state);
|
|
||||||
|
|
||||||
let mut r = self.router.write().await;
|
|
||||||
*r = std::mem::take(&mut *r).nest(path, route);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ajoute un handler POST avec state
|
|
||||||
///
|
|
||||||
/// Similaire à `add_handler_with_state` mais pour les requêtes POST.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `path` - Chemin de la route
|
|
||||||
/// * `handler` - Handler Axum pour POST
|
|
||||||
/// * `state` - État partagé
|
|
||||||
pub async fn add_post_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
|
||||||
where
|
|
||||||
H: Handler<T, S>,
|
|
||||||
T: 'static,
|
|
||||||
S: Clone + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
let route = Router::new()
|
|
||||||
.route("/", axum::routing::post(handler))
|
|
||||||
.with_state(state);
|
|
||||||
|
|
||||||
let mut r = self.router.write().await;
|
|
||||||
*r = std::mem::take(&mut *r).nest(path, route);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ajoute une redirection HTTP
|
/// Ajoute une redirection HTTP
|
||||||
@@ -376,33 +282,32 @@ impl Server {
|
|||||||
/// server.add_redirect("/", "/app").await;
|
/// server.add_redirect("/", "/app").await;
|
||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
pub async fn add_redirect(&mut self, from: &str, to: &str) {
|
pub async fn add_redirect(&mut self, from: &str, to: &str) {
|
||||||
let to = to.to_string();
|
let to = to.to_string();
|
||||||
let handler = move || {
|
let make_handler = || {
|
||||||
let to = to.clone();
|
let target = to.clone();
|
||||||
async move { Redirect::permanent(&to) }
|
get(move || async move { Redirect::permanent(&target) })
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut r = self.router.write().await;
|
let mut r = self.router.write().await;
|
||||||
|
*r = if from == "/" {
|
||||||
if from == "/" {
|
std::mem::take(&mut *r).merge(Router::new().route("/", make_handler()))
|
||||||
// Pour la racine, utiliser merge au lieu de nest
|
|
||||||
let route = Router::new().route("/", get(handler));
|
|
||||||
*r = std::mem::take(&mut *r).merge(route);
|
|
||||||
} else {
|
} else {
|
||||||
let route = Router::new().route("/", get(handler));
|
std::mem::take(&mut *r).nest(from, Router::new().route("/", make_handler()))
|
||||||
*r = std::mem::take(&mut *r).nest(from, route);
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ajoute une API documentée avec OpenAPI
|
/// Ajoute une API documentée avec OpenAPI et Swagger UI
|
||||||
///
|
///
|
||||||
/// Monte un routeur d'API sous `/api` et active Swagger UI sur `/swagger-ui`
|
/// Cette méthode fusionne le `api_router` fourni avec le router principal du serveur.
|
||||||
|
/// Chaque appel peut ajouter une nouvelle API distincte, avec sa propre documentation Swagger.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `api_router` - Router Axum contenant les routes API
|
/// * `api_router` - Router Axum contenant les routes API
|
||||||
/// * `openapi` - Spécification OpenAPI générée par utoipa
|
/// * `openapi` - Spécification OpenAPI générée par `utoipa`
|
||||||
|
/// * `name` - Nom unique pour cette API, utilisé pour différencier le chemin Swagger UI et le JSON OpenAPI
|
||||||
///
|
///
|
||||||
/// # Exemple
|
/// # Exemple
|
||||||
///
|
///
|
||||||
@@ -422,7 +327,7 @@ impl Server {
|
|||||||
/// paths(get_users),
|
/// paths(get_users),
|
||||||
/// components(schemas(User))
|
/// components(schemas(User))
|
||||||
/// )]
|
/// )]
|
||||||
/// struct ApiDoc;
|
/// struct ApiDoc1;
|
||||||
///
|
///
|
||||||
/// #[utoipa::path(
|
/// #[utoipa::path(
|
||||||
/// get,
|
/// get,
|
||||||
@@ -433,22 +338,76 @@ impl Server {
|
|||||||
/// Json(vec![])
|
/// Json(vec![])
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// let api_router = Router::new()
|
/// #[derive(utoipa::OpenApi)]
|
||||||
/// .route("/users", get(get_users));
|
/// #[openapi(
|
||||||
|
/// paths(get_products),
|
||||||
|
/// components(schemas(Product))
|
||||||
|
/// )]
|
||||||
|
/// struct ApiDoc2;
|
||||||
///
|
///
|
||||||
/// server.add_openapi(api_router, ApiDoc::openapi()).await;
|
/// #[utoipa::path(
|
||||||
|
/// get,
|
||||||
|
/// path = "/products",
|
||||||
|
/// responses((status = 200, description = "List products"))
|
||||||
|
/// )]
|
||||||
|
/// async fn get_products() -> Json<Vec<Product>> {
|
||||||
|
/// Json(vec![])
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// let api_router1 = Router::new().route("/users", get(get_users));
|
||||||
|
/// let api_router2 = Router::new().route("/products", get(get_products));
|
||||||
|
///
|
||||||
|
/// // Ajouter les deux API au serveur, chacune avec son nom unique
|
||||||
|
/// server.add_openapi(api_router1, ApiDoc1::openapi(), "api1").await;
|
||||||
|
/// server.add_openapi(api_router2, ApiDoc2::openapi(), "api2").await;
|
||||||
/// ```
|
/// ```
|
||||||
pub async fn add_openapi(&mut self, api_router: Router, openapi: utoipa::openapi::OpenApi) {
|
///
|
||||||
// Stocker le routeur API
|
/// Résultat :
|
||||||
|
///
|
||||||
|
/// - `/api/api1/users` et `/api/api2/products` sont accessibles via Axum.
|
||||||
|
/// - `/swagger-ui/api1` et `/swagger-ui/api2` affichent la documentation Swagger correspondante.
|
||||||
|
/// - `/api-docs/api1.json` et `/api-docs/api2.json` fournissent les spécifications OpenAPI respectives.
|
||||||
|
pub async fn add_openapi(
|
||||||
|
&mut self,
|
||||||
|
api_router: Router,
|
||||||
|
openapi: utoipa::openapi::OpenApi,
|
||||||
|
name: &str,
|
||||||
|
) {
|
||||||
let mut api_r = self.api_router.write().await;
|
let mut api_r = self.api_router.write().await;
|
||||||
*api_r = Some(api_router);
|
*api_r = Some(api_router.clone());
|
||||||
|
drop(api_r);
|
||||||
|
|
||||||
// Ajouter Swagger UI
|
let swagger_path = format!("/swagger-ui/{}", name);
|
||||||
let swagger = SwaggerUi::new("/swagger-ui")
|
let swagger_path_static: &'static str = Box::leak(swagger_path.into_boxed_str());
|
||||||
.url("/api-docs/openapi.json", openapi);
|
|
||||||
|
let openapi_json_path = format!("/api-docs/{}.json", name);
|
||||||
|
let openapi_json_path_static: &'static str = Box::leak(openapi_json_path.into_boxed_str());
|
||||||
|
|
||||||
|
let swagger = SwaggerUi::new(swagger_path_static).url(openapi_json_path_static, openapi);
|
||||||
|
|
||||||
|
let base_path = format!("/api/{}", name);
|
||||||
|
let nested_router = Router::new().nest(&base_path, api_router);
|
||||||
|
|
||||||
let mut r = self.router.write().await;
|
let mut r = self.router.write().await;
|
||||||
*r = std::mem::take(&mut *r).merge(swagger);
|
*r = std::mem::take(&mut *r).merge(nested_router).merge(swagger);
|
||||||
|
}
|
||||||
|
/// Ajoute un sous-router au serveur
|
||||||
|
///
|
||||||
|
/// - Si `path` est "/", merge directement au router principal
|
||||||
|
/// - Sinon, nest le router sous le chemin donné
|
||||||
|
pub async fn add_router(&mut self, path: &str, sub_router: Router) {
|
||||||
|
let mut r = self.router.write().await;
|
||||||
|
|
||||||
|
let combined = if path == "/" {
|
||||||
|
// Merge directement à la racine
|
||||||
|
r.clone().merge(sub_router)
|
||||||
|
} else {
|
||||||
|
// Sous-chemin => nest
|
||||||
|
let normalized = format!("/{}", path.trim_start_matches('/'));
|
||||||
|
r.clone().nest(&normalized, sub_router)
|
||||||
|
};
|
||||||
|
|
||||||
|
*r = combined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Démarre le serveur HTTP
|
/// Démarre le serveur HTTP
|
||||||
@@ -469,18 +428,12 @@ impl Server {
|
|||||||
/// ```
|
/// ```
|
||||||
pub async fn start(&mut self) {
|
pub async fn start(&mut self) {
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], self.http_port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], self.http_port));
|
||||||
info!("Server {} running at [http://{}:{}](http://{}:{})", self.name, self.base_url, self.http_port, self.base_url, self.http_port);
|
info!(
|
||||||
|
"Server {} running at [http://{}:{}](http://{}:{})",
|
||||||
// Merger le routeur API si présent
|
self.name, self.base_url, self.http_port, self.base_url, self.http_port
|
||||||
let api_router = self.api_router.read().await;
|
);
|
||||||
if let Some(api_r) = api_router.as_ref() {
|
|
||||||
let mut r = self.router.write().await;
|
|
||||||
*r = std::mem::take(&mut *r).nest("/api", api_r.clone());
|
|
||||||
}
|
|
||||||
drop(api_router);
|
|
||||||
|
|
||||||
let router = self.router.clone();
|
let router = self.router.clone();
|
||||||
|
|
||||||
let server_task = tokio::spawn(async move {
|
let server_task = tokio::spawn(async move {
|
||||||
let r = router.read().await.clone();
|
let r = router.read().await.clone();
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||||
@@ -515,6 +468,47 @@ impl Server {
|
|||||||
http_port: self.http_port,
|
http_port: self.http_port,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initialise le système de logging et enregistre les routes de logs
|
||||||
|
///
|
||||||
|
/// Cette méthode configure le système de tracing avec SSE et optionnellement la console,
|
||||||
|
/// puis enregistre automatiquement les routes `/log-sse` et `/log-dump`.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `options` - Options de configuration du logging
|
||||||
|
///
|
||||||
|
/// # Exemple
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// # use pmoserver::{ServerBuilder, logs::LoggingOptions};
|
||||||
|
/// # #[tokio::main]
|
||||||
|
/// # async fn main() {
|
||||||
|
/// let mut server = ServerBuilder::new_configured().build();
|
||||||
|
///
|
||||||
|
/// // Initialiser les logs avec console
|
||||||
|
/// server.init_logging(LoggingOptions::default()).await;
|
||||||
|
///
|
||||||
|
/// // Ou sans console
|
||||||
|
/// server.init_logging(LoggingOptions {
|
||||||
|
/// buffer_capacity: 1000,
|
||||||
|
/// enable_console: false,
|
||||||
|
/// }).await;
|
||||||
|
///
|
||||||
|
/// server.start().await;
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub async fn init_logging(&mut self, options: LoggingOptions) {
|
||||||
|
let log_state = init_logging(options);
|
||||||
|
|
||||||
|
// Enregistrer automatiquement les routes de logging
|
||||||
|
self.add_handler_with_state("/log-sse", log_sse, log_state.clone())
|
||||||
|
.await;
|
||||||
|
self.add_handler_with_state("/log-dump", log_dump, log_state.clone())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
self.log_state = Some(log_state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builder pattern
|
/// Builder pattern
|
||||||
@@ -545,7 +539,7 @@ impl ServerBuilder {
|
|||||||
Self {
|
Self {
|
||||||
name: "PMO-Music-Server".to_string(),
|
name: "PMO-Music-Server".to_string(),
|
||||||
base_url: config.get_base_url(),
|
base_url: config.get_base_url(),
|
||||||
http_port: config.get_http_port()
|
http_port: config.get_http_port(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
pmoconfig = { path = "../pmoconfig" }
|
pmoconfig = { path = "../pmoconfig" }
|
||||||
pmodidl = { path = "../pmodidl"}
|
pmodidl = { path = "../pmodidl"}
|
||||||
|
pmoutils = { path = "../pmoutils" }
|
||||||
|
pmoserver = { path = "../pmoserver" }
|
||||||
|
|
||||||
url = "2.5.7"
|
url = "2.5.7"
|
||||||
uuid = "1.18.1"
|
uuid = "1.18.1"
|
||||||
@@ -13,28 +15,14 @@ hex = "0.4.3"
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
thiserror = "2.0.16"
|
thiserror = "2.0.16"
|
||||||
xmltree = "0.11.0"
|
xmltree = "0.11.0"
|
||||||
get_if_addrs = "0.5.3"
|
|
||||||
axum = "0.8.4"
|
axum = "0.8.4"
|
||||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
|
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync"] }
|
||||||
tokio-stream = "0.1"
|
|
||||||
futures-util = "0.3"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
chrono = { version = "0.4.42", features = ["serde"] }
|
chrono = { version = "0.4.42", features = ["serde"] }
|
||||||
log = "0.4.28"
|
|
||||||
once_cell = "1.20"
|
once_cell = "1.20"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
|
||||||
futures = "0.3"
|
|
||||||
async-stream = "0.3.6"
|
|
||||||
axum-server = "0.7.2"
|
|
||||||
axum-embed = "0.1.0"
|
|
||||||
rust-embed = "8.7.2"
|
|
||||||
anyhow = "1.0"
|
|
||||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
|
||||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
|
||||||
validator = { version = "0.20.0", features = ["derive"] }
|
|
||||||
bevy_reflect = "0.17.1"
|
bevy_reflect = "0.17.1"
|
||||||
bevy_reflect_derive = "0.17.1"
|
bevy_reflect_derive = "0.17.1"
|
||||||
reqwest = "0.12.23"
|
reqwest = "0.12.23"
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ use std::sync::Arc;
|
|||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
use crate::actions::Action;
|
use crate::actions::Action;
|
||||||
use crate::actions::Argument;
|
|
||||||
use crate::actions::ArgumentSet;
|
|
||||||
use crate::actions::ArgInstanceSet;
|
use crate::actions::ArgInstanceSet;
|
||||||
use crate::actions::ActionInstance;
|
use crate::actions::ActionInstance;
|
||||||
use crate::UpnpInstance;
|
use crate::UpnpInstance;
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ impl UpnpInstance for DeviceInstance {
|
|||||||
format!("uuid:{}_{}", model.udn_prefix(), uuid::Uuid::new_v4())
|
format!("uuid:{}_{}", model.udn_prefix(), uuid::Uuid::new_v4())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Obtenir l'IP locale et le port depuis la configuration
|
||||||
|
let local_ip = pmoutils::guess_local_ip();
|
||||||
|
let port = pmoconfig::get_config().get_http_port();
|
||||||
|
let server_base_url = format!("http://{}:{}", local_ip, port);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
object: UpnpObjectType {
|
object: UpnpObjectType {
|
||||||
name: model.get_name().to_string(),
|
name: model.get_name().to_string(),
|
||||||
@@ -94,7 +99,7 @@ impl UpnpInstance for DeviceInstance {
|
|||||||
},
|
},
|
||||||
model: Arc::new(model.clone()),
|
model: Arc::new(model.clone()),
|
||||||
udn,
|
udn,
|
||||||
server_base_url: "http://localhost:8080".to_string(),
|
server_base_url,
|
||||||
services: RwLock::new(HashMap::new()),
|
services: RwLock::new(HashMap::new()),
|
||||||
devices: RwLock::new(HashMap::new()),
|
devices: RwLock::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
@@ -184,8 +189,9 @@ impl DeviceInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Retourne la route du device (chemin relatif).
|
/// Retourne la route du device (chemin relatif).
|
||||||
|
/// Utilise l'UDN pour garantir l'unicité si plusieurs devices du même type existent.
|
||||||
pub fn route(&self) -> String {
|
pub fn route(&self) -> String {
|
||||||
format!("/device/{}", self.get_name())
|
format!("/device/{}", self.udn())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retourne la route de description du device.
|
/// Retourne la route de description du device.
|
||||||
@@ -245,7 +251,7 @@ impl DeviceInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Enregistre toutes les URLs du device et de ses services dans le serveur.
|
/// Enregistre toutes les URLs du device et de ses services dans le serveur.
|
||||||
pub fn register_urls<'a>(&'a self, server: &'a mut crate::server::Server) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), DeviceError>> + 'a>> {
|
pub fn register_urls<'a>(&'a self, server: &'a mut pmoserver::Server) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), DeviceError>> + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
info!(
|
info!(
|
||||||
"✅ Device description for {} available at: {}{}",
|
"✅ Device description for {} available at: {}{}",
|
||||||
@@ -316,10 +322,7 @@ impl DeviceInstance {
|
|||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut xml = String::from_utf8_lossy(&xml_output).to_string();
|
let xml = String::from_utf8_lossy(&xml_output).to_string();
|
||||||
|
|
||||||
// Ajouter l'en-tête XML
|
|
||||||
xml.insert_str(0, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
|
||||||
|
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
@@ -327,4 +330,45 @@ impl DeviceInstance {
|
|||||||
xml,
|
xml,
|
||||||
).into_response()
|
).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Crée un SsdpDevice configuré pour ce device UPnP.
|
||||||
|
///
|
||||||
|
/// Cette méthode simplifie la création d'un device SSDP en configurant automatiquement :
|
||||||
|
/// - L'UDN du device
|
||||||
|
/// - Le type de device
|
||||||
|
/// - La location (URL de description)
|
||||||
|
/// - Le serveur (User-Agent avec OS/version détecté automatiquement)
|
||||||
|
/// - Les types de notification pour tous les services
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `app_name` - Nom de l'application (ex: "PMOMusic")
|
||||||
|
/// * `app_version` - Version de l'application (ex: "1.0")
|
||||||
|
///
|
||||||
|
/// # Exemple
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let renderer_instance = MEDIA_RENDERER.create_instance();
|
||||||
|
/// let ssdp_device = renderer_instance.to_ssdp_device("PMOMusic", "1.0");
|
||||||
|
/// ssdp_server.add_device(ssdp_device);
|
||||||
|
/// ```
|
||||||
|
pub fn to_ssdp_device(&self, app_name: &str, app_version: &str) -> crate::ssdp::SsdpDevice {
|
||||||
|
let location = format!("{}{}", self.base_url(), self.description_route());
|
||||||
|
let os_string = pmoutils::get_os_string();
|
||||||
|
let server_string = format!("{} UPnP/1.1 {}/{}", os_string, app_name, app_version);
|
||||||
|
|
||||||
|
let mut ssdp_device = crate::ssdp::SsdpDevice::new(
|
||||||
|
self.udn().to_string(),
|
||||||
|
self.model.device_type(),
|
||||||
|
location,
|
||||||
|
server_string,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ajouter les types de notification pour chaque service
|
||||||
|
for service in self.services() {
|
||||||
|
ssdp_device.add_notification_type(service.service_type());
|
||||||
|
}
|
||||||
|
|
||||||
|
ssdp_device
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
//! Implémentation des traits UPnP pour Device.
|
//! Implémentation des traits UPnP pour Device.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
devices::{Device, DeviceInstance},
|
devices::{Device, DeviceInstance},
|
||||||
UpnpObject, UpnpModel,
|
UpnpObject, UpnpModel, UpnpInstance,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl UpnpObject for Device {
|
impl UpnpObject for Device {
|
||||||
@@ -115,4 +116,19 @@ impl UpnpObject for Device {
|
|||||||
|
|
||||||
impl UpnpModel for Device {
|
impl UpnpModel for Device {
|
||||||
type Instance = DeviceInstance;
|
type Instance = DeviceInstance;
|
||||||
|
|
||||||
|
/// Crée une instance du device avec ses services déjà instanciés.
|
||||||
|
///
|
||||||
|
/// Les services sont créés dans DeviceInstance::new(), cette méthode
|
||||||
|
/// établit uniquement les liens bidirectionnels parent-enfant.
|
||||||
|
fn create_instance(&self) -> Arc<DeviceInstance> {
|
||||||
|
let instance = Arc::new(DeviceInstance::new(self));
|
||||||
|
|
||||||
|
// Établir le lien parent pour chaque service
|
||||||
|
for service in instance.services() {
|
||||||
|
service.set_device(Arc::clone(&instance));
|
||||||
|
}
|
||||||
|
|
||||||
|
instance
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
mod object_trait;
|
mod object_trait;
|
||||||
mod object_set;
|
mod object_set;
|
||||||
|
mod server;
|
||||||
|
|
||||||
pub mod actions;
|
pub mod actions;
|
||||||
pub mod devices;
|
pub mod devices;
|
||||||
pub mod mediarenderer;
|
pub mod mediarenderer;
|
||||||
pub mod server;
|
|
||||||
pub mod services;
|
pub mod services;
|
||||||
|
pub mod soap;
|
||||||
|
pub mod ssdp;
|
||||||
pub mod state_variables;
|
pub mod state_variables;
|
||||||
pub mod value_ranges;
|
pub mod value_ranges;
|
||||||
pub mod variable_types;
|
pub mod variable_types;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
|
||||||
pub use crate::object_trait::*;
|
pub use crate::object_trait::*;
|
||||||
|
pub use crate::server::UpnpServer;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct UpnpObjectType {
|
pub struct UpnpObjectType {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc};
|
|||||||
|
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
|
||||||
use crate::{UpnpDeepClone, UpnpObjectSet, UpnpObjectSetError, UpnpSet, UpnpTypedObject};
|
use crate::{UpnpDeepClone, UpnpObjectSet, UpnpObjectSetError, UpnpTypedObject};
|
||||||
|
|
||||||
/// Implémentation du clonage profond pour `UpnpObjectSet`.
|
/// Implémentation du clonage profond pour `UpnpObjectSet`.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -112,10 +112,7 @@ pub trait UpnpObject: Clone + Debug {
|
|||||||
elem.write_with_config(&mut buf, config)
|
elem.write_with_config(&mut buf, config)
|
||||||
.expect("Failed to write XML");
|
.expect("Failed to write XML");
|
||||||
|
|
||||||
let mut xml_string = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n".to_string();
|
String::from_utf8(buf).expect("Invalid UTF-8")
|
||||||
xml_string.push_str(&String::from_utf8(buf).expect("Invalid UTF-8"));
|
|
||||||
|
|
||||||
xml_string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convertit l'objet en représentation Markdown.
|
/// Convertit l'objet en représentation Markdown.
|
||||||
|
|||||||
22
pmoupnp/src/server.rs
Normal file
22
pmoupnp/src/server.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use pmoserver::Server;
|
||||||
|
|
||||||
|
use crate::devices::errors::DeviceError;
|
||||||
|
use crate::devices::{Device, DeviceInstance};
|
||||||
|
use crate::UpnpModel;
|
||||||
|
|
||||||
|
pub trait UpnpServer {
|
||||||
|
async fn register_device(&mut self, device: Arc<Device>) -> Result<Arc<DeviceInstance>,DeviceError> ;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpnpServer for Server {
|
||||||
|
async fn register_device(&mut self, device: Arc<Device>) -> Result<Arc<DeviceInstance>,DeviceError> {
|
||||||
|
let di = device.create_instance();
|
||||||
|
|
||||||
|
di.register_urls(self).await?;
|
||||||
|
|
||||||
|
Ok(di)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -447,10 +447,7 @@ impl Service {
|
|||||||
elem.write_with_config(&mut buf, config)
|
elem.write_with_config(&mut buf, config)
|
||||||
.expect("Failed to write XML");
|
.expect("Failed to write XML");
|
||||||
|
|
||||||
let mut xml_string = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n".to_string();
|
String::from_utf8(buf).expect("Invalid UTF-8")
|
||||||
xml_string.push_str(&String::from_utf8(buf).expect("Invalid UTF-8"));
|
|
||||||
|
|
||||||
xml_string
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use std::{
|
|||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::{Arc, Mutex, RwLock},
|
sync::{Arc, Mutex, RwLock},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
|
pin::Pin,
|
||||||
|
future::Future,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Request, State},
|
extract::{Request, State},
|
||||||
@@ -297,12 +299,12 @@ impl ServiceInstance {
|
|||||||
&self.actions
|
&self.actions
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enregistre les routes UPnP dans le serveur Axum.
|
/// Enregistre les routes UPnP dans le serveur.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Retourne une erreur si l'enregistrement des routes échoue.
|
/// Retourne une erreur si l'enregistrement des routes échoue.
|
||||||
pub async fn register_urls(&self, server: &mut crate::server::Server) -> Result<(), ServiceError> {
|
pub async fn register_urls(&self, server: &mut pmoserver::Server) -> Result<(), ServiceError> {
|
||||||
let device = self.device.read().unwrap();
|
let device = self.device.read().unwrap();
|
||||||
let device_name = device.as_ref().map(|d| d.get_name().clone()).unwrap_or_else(|| "unknown".to_string());
|
let device_name = device.as_ref().map(|d| d.get_name().clone()).unwrap_or_else(|| "unknown".to_string());
|
||||||
let server_url = device.as_ref().map(|d| d.base_url().to_string()).unwrap_or_default();
|
let server_url = device.as_ref().map(|d| d.base_url().to_string()).unwrap_or_default();
|
||||||
@@ -393,10 +395,7 @@ impl ServiceInstance {
|
|||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut xml = String::from_utf8_lossy(&xml_output).to_string();
|
let xml = String::from_utf8_lossy(&xml_output).to_string();
|
||||||
|
|
||||||
// Ajouter l'en-tête XML
|
|
||||||
xml.insert_str(0, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
|
||||||
|
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
@@ -566,11 +565,12 @@ impl ServiceInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handler Axum pour les événements (SUBSCRIBE/UNSUBSCRIBE).
|
/// Handler Axum pour les événements (SUBSCRIBE/UNSUBSCRIBE).
|
||||||
async fn event_sub_handler(
|
fn event_sub_handler(
|
||||||
State(instance): State<ServiceInstance>,
|
State(instance): State<ServiceInstance>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
) -> Response {
|
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
|
Box::pin(async move {
|
||||||
info!("📡 Event Subscription request for {}", instance.get_name());
|
info!("📡 Event Subscription request for {}", instance.get_name());
|
||||||
|
|
||||||
let method = req.method().as_str();
|
let method = req.method().as_str();
|
||||||
@@ -633,13 +633,15 @@ async fn event_sub_handler(
|
|||||||
StatusCode::METHOD_NOT_ALLOWED.into_response()
|
StatusCode::METHOD_NOT_ALLOWED.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler Axum pour le contrôle SOAP.
|
/// Handler Axum pour le contrôle SOAP.
|
||||||
async fn control_handler(
|
fn control_handler(
|
||||||
State(instance): State<ServiceInstance>,
|
State(instance): State<ServiceInstance>,
|
||||||
body: String,
|
_body: String,
|
||||||
) -> Response {
|
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
|
Box::pin(async move {
|
||||||
info!("📡 Control request for {}", instance.get_name());
|
info!("📡 Control request for {}", instance.get_name());
|
||||||
|
|
||||||
// TODO: Parser le SOAP et appeler l'action correspondante
|
// TODO: Parser le SOAP et appeler l'action correspondante
|
||||||
@@ -661,6 +663,7 @@ async fn control_handler(
|
|||||||
[(axum::http::header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")],
|
[(axum::http::header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")],
|
||||||
response_xml,
|
response_xml,
|
||||||
).into_response()
|
).into_response()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
101
pmoupnp/src/soap/builder.rs
Normal file
101
pmoupnp/src/soap/builder.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//! Construction de réponses SOAP
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
|
/// Construit une réponse SOAP UPnP
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `service_urn` - URN du service (ex: "urn:schemas-upnp-org:service:AVTransport:1")
|
||||||
|
/// * `action` - Nom de l'action (ex: "GetPositionInfo")
|
||||||
|
/// * `values` - Map des valeurs de retour
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// XML SOAP formaté en String
|
||||||
|
pub fn build_soap_response(
|
||||||
|
service_urn: &str,
|
||||||
|
action: &str,
|
||||||
|
values: HashMap<String, String>,
|
||||||
|
) -> Result<String, xmltree::Error> {
|
||||||
|
// Construire l'élément de réponse
|
||||||
|
// Format: <u:ActionResponse xmlns:u="service-urn">
|
||||||
|
let response_name = format!("{}Response", action);
|
||||||
|
let mut response_elem = Element::new(&response_name);
|
||||||
|
response_elem.namespace = Some(service_urn.to_string());
|
||||||
|
response_elem
|
||||||
|
.attributes
|
||||||
|
.insert("xmlns:u".to_string(), service_urn.to_string());
|
||||||
|
|
||||||
|
// Ajouter les valeurs de retour
|
||||||
|
for (key, value) in values {
|
||||||
|
let mut child = Element::new(&key);
|
||||||
|
child.children.push(XMLNode::Text(value));
|
||||||
|
response_elem.children.push(XMLNode::Element(child));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire le Body
|
||||||
|
let mut body = Element::new("s:Body");
|
||||||
|
body.children.push(XMLNode::Element(response_elem));
|
||||||
|
|
||||||
|
// Construire l'Envelope
|
||||||
|
let mut envelope = Element::new("s:Envelope");
|
||||||
|
envelope.attributes.insert(
|
||||||
|
"xmlns:s".to_string(),
|
||||||
|
"http://schemas.xmlsoap.org/soap/envelope/".to_string(),
|
||||||
|
);
|
||||||
|
envelope.attributes.insert(
|
||||||
|
"s:encodingStyle".to_string(),
|
||||||
|
"http://schemas.xmlsoap.org/soap/encoding/".to_string(),
|
||||||
|
);
|
||||||
|
envelope.children.push(XMLNode::Element(body));
|
||||||
|
|
||||||
|
// Sérialiser en XML
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let config = xmltree::EmitterConfig::new()
|
||||||
|
.perform_indent(true)
|
||||||
|
.indent_string(" ");
|
||||||
|
envelope.write_with_config(&mut buf, config)?;
|
||||||
|
|
||||||
|
Ok(String::from_utf8(buf).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_response() {
|
||||||
|
let mut values = HashMap::new();
|
||||||
|
values.insert("Track".to_string(), "5".to_string());
|
||||||
|
values.insert("TrackDuration".to_string(), "00:03:45".to_string());
|
||||||
|
|
||||||
|
let xml = build_soap_response(
|
||||||
|
"urn:schemas-upnp-org:service:AVTransport:1",
|
||||||
|
"GetPositionInfo",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(xml.contains("GetPositionInfoResponse"));
|
||||||
|
assert!(xml.contains("<Track>5</Track>"));
|
||||||
|
assert!(xml.contains("<TrackDuration>00:03:45</TrackDuration>"));
|
||||||
|
assert!(xml.contains("xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_empty_response() {
|
||||||
|
let values = HashMap::new();
|
||||||
|
|
||||||
|
let xml = build_soap_response(
|
||||||
|
"urn:schemas-upnp-org:service:AVTransport:1",
|
||||||
|
"Stop",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(xml.contains("StopResponse"));
|
||||||
|
assert!(xml.contains("xmlns:u=\"urn:schemas-upnp-org:service:AVTransport:1\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
45
pmoupnp/src/soap/envelope.rs
Normal file
45
pmoupnp/src/soap/envelope.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//! Structures de l'enveloppe SOAP
|
||||||
|
|
||||||
|
use xmltree::Element;
|
||||||
|
|
||||||
|
/// Enveloppe SOAP complète
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SoapEnvelope {
|
||||||
|
/// En-tête SOAP optionnel
|
||||||
|
pub header: Option<SoapHeader>,
|
||||||
|
|
||||||
|
/// Corps SOAP contenant l'action ou la réponse
|
||||||
|
pub body: SoapBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// En-tête SOAP
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SoapHeader {
|
||||||
|
/// Contenu XML brut de l'en-tête
|
||||||
|
pub content: Element,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corps SOAP
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SoapBody {
|
||||||
|
/// Contenu XML brut du corps
|
||||||
|
pub content: Element,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoapEnvelope {
|
||||||
|
/// Crée une nouvelle enveloppe SOAP
|
||||||
|
pub fn new(body: SoapBody) -> Self {
|
||||||
|
Self {
|
||||||
|
header: None,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une nouvelle enveloppe avec header
|
||||||
|
pub fn with_header(header: SoapHeader, body: SoapBody) -> Self {
|
||||||
|
Self {
|
||||||
|
header: Some(header),
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
pmoupnp/src/soap/fault.rs
Normal file
173
pmoupnp/src/soap/fault.rs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
//! SOAP Faults pour UPnP
|
||||||
|
|
||||||
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
|
/// Erreur SOAP (Fault)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SoapFault {
|
||||||
|
/// Code d'erreur (ex: "s:Client", "401")
|
||||||
|
pub fault_code: String,
|
||||||
|
|
||||||
|
/// Description de l'erreur
|
||||||
|
pub fault_string: String,
|
||||||
|
|
||||||
|
/// Détails UPnP optionnels
|
||||||
|
pub upnp_error: Option<UpnpError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erreur UPnP spécifique
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UpnpError {
|
||||||
|
/// Code d'erreur UPnP (ex: "401", "501")
|
||||||
|
pub error_code: String,
|
||||||
|
|
||||||
|
/// Description de l'erreur
|
||||||
|
pub error_description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoapFault {
|
||||||
|
/// Crée un fault SOAP simple
|
||||||
|
pub fn new(fault_code: String, fault_string: String) -> Self {
|
||||||
|
Self {
|
||||||
|
fault_code,
|
||||||
|
fault_string,
|
||||||
|
upnp_error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un fault SOAP avec erreur UPnP
|
||||||
|
pub fn with_upnp_error(
|
||||||
|
fault_code: String,
|
||||||
|
fault_string: String,
|
||||||
|
error_code: String,
|
||||||
|
error_description: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
fault_code,
|
||||||
|
fault_string,
|
||||||
|
upnp_error: Some(UpnpError {
|
||||||
|
error_code,
|
||||||
|
error_description,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit un SOAP Fault XML
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `fault_code` - Code du fault (ex: "s:Client")
|
||||||
|
/// * `fault_string` - Message d'erreur
|
||||||
|
/// * `upnp_error_code` - Code d'erreur UPnP optionnel (ex: "401")
|
||||||
|
/// * `upnp_error_desc` - Description d'erreur UPnP optionnelle
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// XML SOAP Fault formaté
|
||||||
|
pub fn build_soap_fault(
|
||||||
|
fault_code: &str,
|
||||||
|
fault_string: &str,
|
||||||
|
upnp_error_code: Option<&str>,
|
||||||
|
upnp_error_desc: Option<&str>,
|
||||||
|
) -> Result<String, xmltree::Error> {
|
||||||
|
// Construire l'élément Fault
|
||||||
|
let mut fault = Element::new("s:Fault");
|
||||||
|
|
||||||
|
// faultcode
|
||||||
|
let mut faultcode_elem = Element::new("faultcode");
|
||||||
|
faultcode_elem
|
||||||
|
.children
|
||||||
|
.push(XMLNode::Text(fault_code.to_string()));
|
||||||
|
fault.children.push(XMLNode::Element(faultcode_elem));
|
||||||
|
|
||||||
|
// faultstring
|
||||||
|
let mut faultstring_elem = Element::new("faultstring");
|
||||||
|
faultstring_elem
|
||||||
|
.children
|
||||||
|
.push(XMLNode::Text(fault_string.to_string()));
|
||||||
|
fault.children.push(XMLNode::Element(faultstring_elem));
|
||||||
|
|
||||||
|
// detail (si erreur UPnP)
|
||||||
|
if let (Some(code), Some(desc)) = (upnp_error_code, upnp_error_desc) {
|
||||||
|
let mut detail = Element::new("detail");
|
||||||
|
|
||||||
|
let mut upnp_error = Element::new("UPnPError");
|
||||||
|
upnp_error.attributes.insert(
|
||||||
|
"xmlns".to_string(),
|
||||||
|
"urn:schemas-upnp-org:control-1-0".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut error_code_elem = Element::new("errorCode");
|
||||||
|
error_code_elem
|
||||||
|
.children
|
||||||
|
.push(XMLNode::Text(code.to_string()));
|
||||||
|
upnp_error
|
||||||
|
.children
|
||||||
|
.push(XMLNode::Element(error_code_elem));
|
||||||
|
|
||||||
|
let mut error_desc_elem = Element::new("errorDescription");
|
||||||
|
error_desc_elem
|
||||||
|
.children
|
||||||
|
.push(XMLNode::Text(desc.to_string()));
|
||||||
|
upnp_error
|
||||||
|
.children
|
||||||
|
.push(XMLNode::Element(error_desc_elem));
|
||||||
|
|
||||||
|
detail.children.push(XMLNode::Element(upnp_error));
|
||||||
|
fault.children.push(XMLNode::Element(detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire le Body
|
||||||
|
let mut body = Element::new("s:Body");
|
||||||
|
body.children.push(XMLNode::Element(fault));
|
||||||
|
|
||||||
|
// Construire l'Envelope
|
||||||
|
let mut envelope = Element::new("s:Envelope");
|
||||||
|
envelope.attributes.insert(
|
||||||
|
"xmlns:s".to_string(),
|
||||||
|
"http://schemas.xmlsoap.org/soap/envelope/".to_string(),
|
||||||
|
);
|
||||||
|
envelope.children.push(XMLNode::Element(body));
|
||||||
|
|
||||||
|
// Sérialiser
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let config = xmltree::EmitterConfig::new()
|
||||||
|
.perform_indent(true)
|
||||||
|
.indent_string(" ");
|
||||||
|
envelope.write_with_config(&mut buf, config)?;
|
||||||
|
|
||||||
|
Ok(String::from_utf8(buf).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_simple_fault() {
|
||||||
|
let xml = build_soap_fault("s:Client", "Invalid Action", None, None).unwrap();
|
||||||
|
|
||||||
|
assert!(xml.contains("<s:Fault>"));
|
||||||
|
assert!(xml.contains("<faultcode>s:Client</faultcode>"));
|
||||||
|
assert!(xml.contains("<faultstring>Invalid Action</faultstring>"));
|
||||||
|
assert!(!xml.contains("UPnPError"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_upnp_fault() {
|
||||||
|
let xml = build_soap_fault(
|
||||||
|
"s:Client",
|
||||||
|
"UPnP Error",
|
||||||
|
Some("401"),
|
||||||
|
Some("Invalid Action"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(xml.contains("<s:Fault>"));
|
||||||
|
assert!(xml.contains("<detail>"));
|
||||||
|
assert!(xml.contains("<UPnPError"));
|
||||||
|
assert!(xml.contains("<errorCode>401</errorCode>"));
|
||||||
|
assert!(xml.contains("<errorDescription>Invalid Action</errorDescription>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
89
pmoupnp/src/soap/mod.rs
Normal file
89
pmoupnp/src/soap/mod.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//! # Module SOAP - Simple Object Access Protocol
|
||||||
|
//!
|
||||||
|
//! Ce module implémente le support SOAP pour UPnP, permettant l'invocation d'actions
|
||||||
|
//! et la gestion des réponses/erreurs.
|
||||||
|
//!
|
||||||
|
//! ## Fonctionnalités
|
||||||
|
//!
|
||||||
|
//! - ✅ Parsing d'enveloppes SOAP
|
||||||
|
//! - ✅ Extraction d'actions UPnP avec arguments
|
||||||
|
//! - ✅ Construction de réponses SOAP
|
||||||
|
//! - ✅ Gestion des SOAP Faults
|
||||||
|
//! - ✅ Support des namespaces UPnP
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! - [`SoapEnvelope`] : Enveloppe SOAP complète
|
||||||
|
//! - [`SoapAction`] : Action UPnP extraite
|
||||||
|
//! - [`SoapResponse`] : Réponse UPnP
|
||||||
|
//! - [`SoapFault`] : Erreur SOAP
|
||||||
|
//!
|
||||||
|
//! ## Example
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use pmoupnp::soap::{parse_soap_action, build_soap_response};
|
||||||
|
//!
|
||||||
|
//! // Parser une action SOAP
|
||||||
|
//! let body = r#"<?xml version="1.0"?>
|
||||||
|
//! <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
//! <s:Body>
|
||||||
|
//! <u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||||
|
//! <InstanceID>0</InstanceID>
|
||||||
|
//! <Speed>1</Speed>
|
||||||
|
//! </u:Play>
|
||||||
|
//! </s:Body>
|
||||||
|
//! </s:Envelope>"#;
|
||||||
|
//!
|
||||||
|
//! let action = parse_soap_action(body.as_bytes()).unwrap();
|
||||||
|
//! assert_eq!(action.name, "Play");
|
||||||
|
//! assert_eq!(action.args.get("InstanceID"), Some(&"0".to_string()));
|
||||||
|
//!
|
||||||
|
//! // Construire une réponse
|
||||||
|
//! let mut values = std::collections::HashMap::new();
|
||||||
|
//! values.insert("CurrentTrack".to_string(), "5".to_string());
|
||||||
|
//! let response = build_soap_response(
|
||||||
|
//! "urn:schemas-upnp-org:service:AVTransport:1",
|
||||||
|
//! "GetPositionInfo",
|
||||||
|
//! values
|
||||||
|
//! ).unwrap();
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
mod envelope;
|
||||||
|
mod parser;
|
||||||
|
mod builder;
|
||||||
|
mod fault;
|
||||||
|
|
||||||
|
pub use envelope::{SoapEnvelope, SoapHeader, SoapBody};
|
||||||
|
pub use parser::{parse_soap_action, SoapAction};
|
||||||
|
pub use builder::build_soap_response;
|
||||||
|
pub use fault::{SoapFault, build_soap_fault};
|
||||||
|
|
||||||
|
/// Codes d'erreur SOAP UPnP standards
|
||||||
|
pub mod error_codes {
|
||||||
|
/// Action invalide
|
||||||
|
pub const INVALID_ACTION: &str = "401";
|
||||||
|
|
||||||
|
/// Arguments invalides
|
||||||
|
pub const INVALID_ARGS: &str = "402";
|
||||||
|
|
||||||
|
/// Action échouée
|
||||||
|
pub const ACTION_FAILED: &str = "501";
|
||||||
|
|
||||||
|
/// Argument manquant
|
||||||
|
pub const ARGUMENT_VALUE_INVALID: &str = "600";
|
||||||
|
|
||||||
|
/// Argument hors limites
|
||||||
|
pub const ARGUMENT_VALUE_OUT_OF_RANGE: &str = "601";
|
||||||
|
|
||||||
|
/// Action optionnelle non implémentée
|
||||||
|
pub const OPTIONAL_ACTION_NOT_IMPLEMENTED: &str = "602";
|
||||||
|
|
||||||
|
/// Mémoire insuffisante
|
||||||
|
pub const OUT_OF_MEMORY: &str = "603";
|
||||||
|
|
||||||
|
/// Erreur humaine lisible
|
||||||
|
pub const HUMAN_INTERVENTION_REQUIRED: &str = "604";
|
||||||
|
|
||||||
|
/// Argument sous forme de chaîne trop long
|
||||||
|
pub const STRING_ARGUMENT_TOO_LONG: &str = "605";
|
||||||
|
}
|
||||||
149
pmoupnp/src/soap/parser.rs
Normal file
149
pmoupnp/src/soap/parser.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
//! Parser SOAP pour actions UPnP
|
||||||
|
|
||||||
|
use super::{SoapBody, SoapEnvelope, SoapHeader};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::BufReader;
|
||||||
|
use xmltree::Element;
|
||||||
|
|
||||||
|
/// Action UPnP extraite d'une enveloppe SOAP
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SoapAction {
|
||||||
|
/// Nom de l'action (ex: "Play", "SetAVTransportURI")
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Namespace de l'action (ex: "urn:schemas-upnp-org:service:AVTransport:1")
|
||||||
|
pub namespace: Option<String>,
|
||||||
|
|
||||||
|
/// Arguments de l'action
|
||||||
|
pub args: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erreur de parsing SOAP
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SoapParseError {
|
||||||
|
#[error("XML parse error: {0}")]
|
||||||
|
XmlError(#[from] xmltree::ParseError),
|
||||||
|
|
||||||
|
#[error("Missing SOAP Envelope")]
|
||||||
|
MissingEnvelope,
|
||||||
|
|
||||||
|
#[error("Missing SOAP Body")]
|
||||||
|
MissingBody,
|
||||||
|
|
||||||
|
#[error("No action found in SOAP Body")]
|
||||||
|
NoAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse une action SOAP à partir de bytes XML
|
||||||
|
pub fn parse_soap_action(xml: &[u8]) -> Result<SoapAction, SoapParseError> {
|
||||||
|
let envelope = parse_soap_envelope(xml)?;
|
||||||
|
extract_action_from_body(&envelope.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse une enveloppe SOAP complète
|
||||||
|
pub fn parse_soap_envelope(xml: &[u8]) -> Result<SoapEnvelope, SoapParseError> {
|
||||||
|
let reader = BufReader::new(xml);
|
||||||
|
let root = Element::parse(reader)?;
|
||||||
|
|
||||||
|
// Vérifier que c'est bien une Envelope
|
||||||
|
if !root.name.ends_with("Envelope") {
|
||||||
|
return Err(SoapParseError::MissingEnvelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire Header (optionnel)
|
||||||
|
let header = root
|
||||||
|
.get_child("Header")
|
||||||
|
.or_else(|| root.children.iter().find_map(|n| n.as_element()))
|
||||||
|
.filter(|e| e.name.ends_with("Header"))
|
||||||
|
.map(|e| SoapHeader {
|
||||||
|
content: e.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extraire Body (obligatoire)
|
||||||
|
let body_elem = root
|
||||||
|
.get_child("Body")
|
||||||
|
.or_else(|| root.children.iter().find_map(|n| {
|
||||||
|
n.as_element()
|
||||||
|
.filter(|e| e.name.ends_with("Body"))
|
||||||
|
}))
|
||||||
|
.ok_or(SoapParseError::MissingBody)?;
|
||||||
|
|
||||||
|
let body = SoapBody {
|
||||||
|
content: body_elem.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SoapEnvelope { header, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrait l'action UPnP du corps SOAP
|
||||||
|
fn extract_action_from_body(body: &SoapBody) -> Result<SoapAction, SoapParseError> {
|
||||||
|
// Le Body contient un élément enfant qui est l'action
|
||||||
|
// Format: <u:ActionName xmlns:u="service-urn">...</u:ActionName>
|
||||||
|
|
||||||
|
let action_elem = body
|
||||||
|
.content
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.find_map(|n| n.as_element())
|
||||||
|
.ok_or(SoapParseError::NoAction)?;
|
||||||
|
|
||||||
|
let name = action_elem.name.clone();
|
||||||
|
let namespace = action_elem.namespace.clone();
|
||||||
|
|
||||||
|
// Extraire les arguments (enfants directs de l'action)
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
for child in &action_elem.children {
|
||||||
|
if let Some(elem) = child.as_element() {
|
||||||
|
let arg_name = elem.name.clone();
|
||||||
|
let arg_value = elem.get_text().unwrap_or_default().to_string();
|
||||||
|
args.insert(arg_name, arg_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SoapAction {
|
||||||
|
name,
|
||||||
|
namespace,
|
||||||
|
args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_simple_action() {
|
||||||
|
let xml = r#"<?xml version="1.0"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<s:Body>
|
||||||
|
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||||
|
<InstanceID>0</InstanceID>
|
||||||
|
<Speed>1</Speed>
|
||||||
|
</u:Play>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>"#;
|
||||||
|
|
||||||
|
let action = parse_soap_action(xml.as_bytes()).unwrap();
|
||||||
|
assert_eq!(action.name, "Play");
|
||||||
|
assert_eq!(
|
||||||
|
action.namespace,
|
||||||
|
Some("urn:schemas-upnp-org:service:AVTransport:1".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(action.args.get("InstanceID"), Some(&"0".to_string()));
|
||||||
|
assert_eq!(action.args.get("Speed"), Some(&"1".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_action_no_args() {
|
||||||
|
let xml = r#"<?xml version="1.0"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<s:Body>
|
||||||
|
<u:Stop xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>"#;
|
||||||
|
|
||||||
|
let action = parse_soap_action(xml.as_bytes()).unwrap();
|
||||||
|
assert_eq!(action.name, "Stop");
|
||||||
|
assert!(action.args.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
58
pmoupnp/src/ssdp/device.rs
Normal file
58
pmoupnp/src/ssdp/device.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//! Représentation d'un device SSDP
|
||||||
|
|
||||||
|
/// Device SSDP avec ses métadonnées pour les annonces
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SsdpDevice {
|
||||||
|
/// UUID du device (sans le préfixe "uuid:")
|
||||||
|
pub uuid: String,
|
||||||
|
|
||||||
|
/// Type du device (ex: "urn:schemas-upnp-org:device:MediaRenderer:1")
|
||||||
|
pub device_type: String,
|
||||||
|
|
||||||
|
/// URL de la description du device
|
||||||
|
pub location: String,
|
||||||
|
|
||||||
|
/// Identifiant du serveur (ex: "Linux/5.0 UPnP/1.1 PMOMusic/1.0")
|
||||||
|
pub server: String,
|
||||||
|
|
||||||
|
/// Liste des types de notification (NT) à annoncer
|
||||||
|
/// Typiquement: [uuid:xxx, device_type, services...]
|
||||||
|
pub notification_types: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SsdpDevice {
|
||||||
|
/// Crée un nouveau device SSDP
|
||||||
|
pub fn new(
|
||||||
|
uuid: String,
|
||||||
|
device_type: String,
|
||||||
|
location: String,
|
||||||
|
server: String,
|
||||||
|
) -> Self {
|
||||||
|
// Construction automatique des NTs standards
|
||||||
|
let notification_types = vec![
|
||||||
|
format!("uuid:{}", uuid),
|
||||||
|
"upnp:rootdevice".to_string(),
|
||||||
|
device_type.clone(),
|
||||||
|
];
|
||||||
|
|
||||||
|
Self {
|
||||||
|
uuid,
|
||||||
|
device_type,
|
||||||
|
location,
|
||||||
|
server,
|
||||||
|
notification_types,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un type de notification (ex: pour un service)
|
||||||
|
pub fn add_notification_type(&mut self, nt: String) {
|
||||||
|
if !self.notification_types.contains(&nt) {
|
||||||
|
self.notification_types.push(nt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la liste des types de notification
|
||||||
|
pub fn get_notification_types(&self) -> &[String] {
|
||||||
|
&self.notification_types
|
||||||
|
}
|
||||||
|
}
|
||||||
38
pmoupnp/src/ssdp/mod.rs
Normal file
38
pmoupnp/src/ssdp/mod.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! # Module SSDP - Simple Service Discovery Protocol
|
||||||
|
//!
|
||||||
|
//! Ce module implémente le protocole SSDP (Simple Service Discovery Protocol) pour UPnP,
|
||||||
|
//! permettant la découverte automatique des devices sur le réseau.
|
||||||
|
//!
|
||||||
|
//! ## Fonctionnalités
|
||||||
|
//!
|
||||||
|
//! - ✅ Envoi de NOTIFY alive/byebye en multicast
|
||||||
|
//! - ✅ Réponse aux M-SEARCH en unicast
|
||||||
|
//! - ✅ Gestion multi-devices avec types de notification
|
||||||
|
//! - ✅ Annonces périodiques automatiques
|
||||||
|
//! - ✅ Arrêt propre avec byebye
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! - [`SsdpServer`] : Serveur SSDP principal gérant les devices
|
||||||
|
//! - [`SsdpDevice`] : Représentation d'un device pour SSDP
|
||||||
|
//!
|
||||||
|
//! ## Constants SSDP
|
||||||
|
//!
|
||||||
|
//! - **Multicast Address**: 239.255.255.250:1900
|
||||||
|
//! - **Max-Age**: 1800 secondes (30 minutes)
|
||||||
|
//! - **Announcement Period**: 900 secondes (15 minutes, Max-Age/2)
|
||||||
|
|
||||||
|
mod device;
|
||||||
|
mod server;
|
||||||
|
|
||||||
|
pub use device::SsdpDevice;
|
||||||
|
pub use server::SsdpServer;
|
||||||
|
|
||||||
|
/// Adresse multicast SSDP
|
||||||
|
pub const SSDP_MULTICAST_ADDR: &str = "239.255.255.250";
|
||||||
|
|
||||||
|
/// Port SSDP
|
||||||
|
pub const SSDP_PORT: u16 = 1900;
|
||||||
|
|
||||||
|
/// Durée de validité des annonces (en secondes)
|
||||||
|
pub const MAX_AGE: u32 = 1800;
|
||||||
304
pmoupnp/src/ssdp/server.rs
Normal file
304
pmoupnp/src/ssdp/server.rs
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
//! Serveur SSDP
|
||||||
|
|
||||||
|
use super::{SsdpDevice, SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::{SocketAddr, UdpSocket};
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// Serveur SSDP gérant les annonces et découvertes
|
||||||
|
pub struct SsdpServer {
|
||||||
|
/// Devices enregistrés (UUID -> Device)
|
||||||
|
devices: Arc<RwLock<HashMap<String, SsdpDevice>>>,
|
||||||
|
|
||||||
|
/// Socket UDP pour SSDP
|
||||||
|
socket: Option<Arc<UdpSocket>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SsdpServer {
|
||||||
|
/// Crée un nouveau serveur SSDP
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
devices: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
socket: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre le serveur SSDP
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// `Ok(())` si le démarrage a réussi, `Err` sinon
|
||||||
|
pub fn start(&mut self) -> std::io::Result<()> {
|
||||||
|
let addr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT);
|
||||||
|
let socket = UdpSocket::bind(("0.0.0.0", SSDP_PORT))?;
|
||||||
|
|
||||||
|
// Rejoindre le groupe multicast
|
||||||
|
socket.join_multicast_v4(
|
||||||
|
&SSDP_MULTICAST_ADDR.parse().unwrap(),
|
||||||
|
&"0.0.0.0".parse().unwrap(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
socket.set_read_timeout(Some(Duration::from_secs(1)))?;
|
||||||
|
socket.set_multicast_loop_v4(false)?;
|
||||||
|
|
||||||
|
let socket = Arc::new(socket);
|
||||||
|
self.socket = Some(socket.clone());
|
||||||
|
|
||||||
|
info!("✅ SSDP server started on {}", addr);
|
||||||
|
|
||||||
|
// Lancer les goroutines d'annonces périodiques et d'écoute M-SEARCH
|
||||||
|
self.start_periodic_announcements(socket.clone());
|
||||||
|
self.start_msearch_listener(socket.clone());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un device et envoie un alive initial
|
||||||
|
pub fn add_device(&self, device: SsdpDevice) {
|
||||||
|
let uuid = device.uuid.clone();
|
||||||
|
let mut devices = self.devices.write().unwrap();
|
||||||
|
devices.insert(uuid.clone(), device.clone());
|
||||||
|
drop(devices);
|
||||||
|
|
||||||
|
// Envoyer alive pour tous les NTs
|
||||||
|
if let Some(ref socket) = self.socket {
|
||||||
|
for nt in device.get_notification_types() {
|
||||||
|
self.send_alive(socket, &device, nt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un device et envoie un byebye
|
||||||
|
pub fn remove_device(&self, uuid: &str) {
|
||||||
|
let mut devices = self.devices.write().unwrap();
|
||||||
|
if let Some(device) = devices.remove(uuid) {
|
||||||
|
drop(devices);
|
||||||
|
|
||||||
|
// Envoyer byebye pour tous les NTs
|
||||||
|
if let Some(ref socket) = self.socket {
|
||||||
|
for nt in device.get_notification_types() {
|
||||||
|
self.send_byebye(socket, &device, nt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie un NOTIFY alive
|
||||||
|
fn send_alive(&self, socket: &UdpSocket, device: &SsdpDevice, nt: &str) {
|
||||||
|
let usn = if nt.starts_with("uuid:") {
|
||||||
|
format!("{}", nt)
|
||||||
|
} else {
|
||||||
|
format!("uuid:{}::{}", device.uuid, nt)
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = format!(
|
||||||
|
"NOTIFY * HTTP/1.1\r\n\
|
||||||
|
HOST: {}:{}\r\n\
|
||||||
|
CACHE-CONTROL: max-age={}\r\n\
|
||||||
|
LOCATION: {}\r\n\
|
||||||
|
NT: {}\r\n\
|
||||||
|
NTS: ssdp:alive\r\n\
|
||||||
|
SERVER: {}\r\n\
|
||||||
|
USN: {}\r\n\
|
||||||
|
\r\n",
|
||||||
|
SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE, device.location, nt, device.server, usn
|
||||||
|
);
|
||||||
|
|
||||||
|
let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT)
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match socket.send_to(msg.as_bytes(), addr) {
|
||||||
|
Ok(_) => info!("✅ NOTIFY alive: {} (NT={})", usn, nt),
|
||||||
|
Err(e) => warn!("❌ Failed to send NOTIFY alive for {}: {}", usn, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie un NOTIFY byebye
|
||||||
|
fn send_byebye(&self, socket: &UdpSocket, device: &SsdpDevice, nt: &str) {
|
||||||
|
let usn = if nt.starts_with("uuid:") {
|
||||||
|
format!("{}", nt)
|
||||||
|
} else {
|
||||||
|
format!("uuid:{}::{}", device.uuid, nt)
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = format!(
|
||||||
|
"NOTIFY * HTTP/1.1\r\n\
|
||||||
|
HOST: {}:{}\r\n\
|
||||||
|
NT: {}\r\n\
|
||||||
|
NTS: ssdp:byebye\r\n\
|
||||||
|
USN: {}\r\n\
|
||||||
|
\r\n",
|
||||||
|
SSDP_MULTICAST_ADDR, SSDP_PORT, nt, usn
|
||||||
|
);
|
||||||
|
|
||||||
|
let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT)
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match socket.send_to(msg.as_bytes(), addr) {
|
||||||
|
Ok(_) => info!("👋 NOTIFY byebye: {} (NT={})", usn, nt),
|
||||||
|
Err(e) => warn!("❌ Failed to send NOTIFY byebye for {}: {}", usn, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre les annonces périodiques (toutes les MAX_AGE/2 secondes)
|
||||||
|
fn start_periodic_announcements(&self, socket: Arc<UdpSocket>) {
|
||||||
|
let devices = Arc::clone(&self.devices);
|
||||||
|
let period = Duration::from_secs((MAX_AGE / 2) as u64);
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(period);
|
||||||
|
|
||||||
|
let devices = devices.read().unwrap();
|
||||||
|
for device in devices.values() {
|
||||||
|
for nt in device.get_notification_types() {
|
||||||
|
Self::send_alive_static(&socket, device, nt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version statique de send_alive pour les threads
|
||||||
|
fn send_alive_static(socket: &UdpSocket, device: &SsdpDevice, nt: &str) {
|
||||||
|
let usn = if nt.starts_with("uuid:") {
|
||||||
|
format!("{}", nt)
|
||||||
|
} else {
|
||||||
|
format!("uuid:{}::{}", device.uuid, nt)
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = format!(
|
||||||
|
"NOTIFY * HTTP/1.1\r\n\
|
||||||
|
HOST: {}:{}\r\n\
|
||||||
|
CACHE-CONTROL: max-age={}\r\n\
|
||||||
|
LOCATION: {}\r\n\
|
||||||
|
NT: {}\r\n\
|
||||||
|
NTS: ssdp:alive\r\n\
|
||||||
|
SERVER: {}\r\n\
|
||||||
|
USN: {}\r\n\
|
||||||
|
\r\n",
|
||||||
|
SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE, device.location, nt, device.server, usn
|
||||||
|
);
|
||||||
|
|
||||||
|
let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT)
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match socket.send_to(msg.as_bytes(), addr) {
|
||||||
|
Ok(_) => info!("✅ NOTIFY alive (periodic): {} (NT={})", usn, nt),
|
||||||
|
Err(e) => warn!("❌ Failed to send periodic NOTIFY alive for {}: {}", usn, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre l'écoute des M-SEARCH
|
||||||
|
fn start_msearch_listener(&self, socket: Arc<UdpSocket>) {
|
||||||
|
let devices = Arc::clone(&self.devices);
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
match socket.recv_from(&mut buf) {
|
||||||
|
Ok((n, src)) => {
|
||||||
|
let data = String::from_utf8_lossy(&buf[..n]);
|
||||||
|
if data.starts_with("M-SEARCH") {
|
||||||
|
if let Some(st) = Self::parse_st(&data) {
|
||||||
|
let devices = devices.read().unwrap();
|
||||||
|
for device in devices.values() {
|
||||||
|
Self::handle_msearch(&socket, &src, &st, device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
// Timeout, continuer
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("❌ SSDP read error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse le champ ST d'un M-SEARCH
|
||||||
|
fn parse_st(data: &str) -> Option<String> {
|
||||||
|
for line in data.lines() {
|
||||||
|
if line.to_uppercase().starts_with("ST:") {
|
||||||
|
let st = line[3..].trim().to_string();
|
||||||
|
info!("✅ M-SEARCH received with ST={}", st);
|
||||||
|
return Some(st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Répond à un M-SEARCH
|
||||||
|
fn handle_msearch(socket: &UdpSocket, src: &SocketAddr, st: &str, device: &SsdpDevice) {
|
||||||
|
let mut nts = Vec::new();
|
||||||
|
|
||||||
|
if st == "ssdp:all" {
|
||||||
|
nts.extend(device.get_notification_types().iter().cloned());
|
||||||
|
} else if device.get_notification_types().contains(&st.to_string()) {
|
||||||
|
nts.push(st.to_string());
|
||||||
|
} else {
|
||||||
|
return; // Pas de match
|
||||||
|
}
|
||||||
|
|
||||||
|
for nt in nts {
|
||||||
|
let usn = if nt.starts_with("uuid:") {
|
||||||
|
format!("{}", nt)
|
||||||
|
} else {
|
||||||
|
format!("uuid:{}::{}", device.uuid, nt)
|
||||||
|
};
|
||||||
|
|
||||||
|
let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT");
|
||||||
|
|
||||||
|
let resp = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\n\
|
||||||
|
CACHE-CONTROL: max-age={}\r\n\
|
||||||
|
DATE: {}\r\n\
|
||||||
|
EXT:\r\n\
|
||||||
|
LOCATION: {}\r\n\
|
||||||
|
SERVER: {}\r\n\
|
||||||
|
ST: {}\r\n\
|
||||||
|
USN: {}\r\n\
|
||||||
|
\r\n",
|
||||||
|
MAX_AGE, date, device.location, device.server, nt, usn
|
||||||
|
);
|
||||||
|
|
||||||
|
match socket.send_to(resp.as_bytes(), src) {
|
||||||
|
Ok(_) => info!(
|
||||||
|
"📡 M-SEARCH response sent to {} with ST={}\n<details>\n\n```\n{}\n```\n</details>\n",
|
||||||
|
src, nt, resp
|
||||||
|
),
|
||||||
|
Err(e) => warn!("❌ Failed to send M-SEARCH response to {}: {}", src, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SsdpServer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SsdpServer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Envoyer byebye pour tous les devices
|
||||||
|
if let Some(ref socket) = self.socket {
|
||||||
|
info!("✅ Shutting down SSDP server, sending byebye for all devices");
|
||||||
|
let devices = self.devices.read().unwrap();
|
||||||
|
for device in devices.values() {
|
||||||
|
for nt in device.get_notification_types() {
|
||||||
|
self.send_byebye(socket, device, nt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
pmoupnp/webapp/node_modules/.bin/esbuild
generated
vendored
1
pmoupnp/webapp/node_modules/.bin/esbuild
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../esbuild/bin/esbuild
|
|
||||||
1
pmoupnp/webapp/node_modules/.bin/marked
generated
vendored
1
pmoupnp/webapp/node_modules/.bin/marked
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../marked/bin/marked.js
|
|
||||||
1
pmoupnp/webapp/node_modules/.bin/nanoid
generated
vendored
1
pmoupnp/webapp/node_modules/.bin/nanoid
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../nanoid/bin/nanoid.cjs
|
|
||||||
1
pmoupnp/webapp/node_modules/.bin/parser
generated
vendored
1
pmoupnp/webapp/node_modules/.bin/parser
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../@babel/parser/bin/babel-parser.js
|
|
||||||
1
pmoupnp/webapp/node_modules/.bin/rollup
generated
vendored
1
pmoupnp/webapp/node_modules/.bin/rollup
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../rollup/dist/bin/rollup
|
|
||||||
1
pmoupnp/webapp/node_modules/.bin/tsc
generated
vendored
1
pmoupnp/webapp/node_modules/.bin/tsc
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../typescript/bin/tsc
|
|
||||||
1
pmoupnp/webapp/node_modules/.bin/tsserver
generated
vendored
1
pmoupnp/webapp/node_modules/.bin/tsserver
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../typescript/bin/tsserver
|
|
||||||
1
pmoupnp/webapp/node_modules/.bin/vite
generated
vendored
1
pmoupnp/webapp/node_modules/.bin/vite
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../vite/bin/vite.js
|
|
||||||
1
pmoupnp/webapp/node_modules/.bin/vue-tsc
generated
vendored
1
pmoupnp/webapp/node_modules/.bin/vue-tsc
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../vue-tsc/bin/vue-tsc.js
|
|
||||||
1514
pmoupnp/webapp/node_modules/.package-lock.json
generated
vendored
1514
pmoupnp/webapp/node_modules/.package-lock.json
generated
vendored
File diff suppressed because it is too large
Load Diff
1
pmoupnp/webapp/node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
1
pmoupnp/webapp/node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
@@ -1 +0,0 @@
|
|||||||
{"root":["../../src/main.ts","../../src/shims-vue.d.ts","../../src/router/index.ts","../../src/app.vue","../../src/components/helloworld.vue","../../src/components/logview.vue"],"version":"5.8.3"}
|
|
||||||
1
pmoupnp/webapp/node_modules/.tmp/tsconfig.node.tsbuildinfo
generated
vendored
1
pmoupnp/webapp/node_modules/.tmp/tsconfig.node.tsbuildinfo
generated
vendored
@@ -1 +0,0 @@
|
|||||||
{"root":["../../vite.config.ts"],"version":"5.8.3"}
|
|
||||||
15
pmoupnp/webapp/node_modules/.vite/deps/_metadata.json
generated
vendored
15
pmoupnp/webapp/node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"hash": "55ffb9fd",
|
|
||||||
"configHash": "38b44685",
|
|
||||||
"lockfileHash": "67a231c1",
|
|
||||||
"browserHash": "0b84b257",
|
|
||||||
"optimized": {
|
|
||||||
"vue": {
|
|
||||||
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
|
|
||||||
"file": "vue.js",
|
|
||||||
"fileHash": "6eecd6fc",
|
|
||||||
"needsInterop": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"chunks": {}
|
|
||||||
}
|
|
||||||
3
pmoupnp/webapp/node_modules/.vite/deps/package.json
generated
vendored
3
pmoupnp/webapp/node_modules/.vite/deps/package.json
generated
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "module"
|
|
||||||
}
|
|
||||||
12704
pmoupnp/webapp/node_modules/.vite/deps/vue.js
generated
vendored
12704
pmoupnp/webapp/node_modules/.vite/deps/vue.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
pmoupnp/webapp/node_modules/.vite/deps/vue.js.map
generated
vendored
7
pmoupnp/webapp/node_modules/.vite/deps/vue.js.map
generated
vendored
File diff suppressed because one or more lines are too long
137
pmoupnp/webapp/node_modules/.vue-global-types/vue_3.5_0.d.ts
generated
vendored
137
pmoupnp/webapp/node_modules/.vue-global-types/vue_3.5_0.d.ts
generated
vendored
@@ -1,137 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
export {};
|
|
||||||
|
|
||||||
; declare global {
|
|
||||||
const __VLS_directiveBindingRestFields: { instance: null, oldValue: null, modifiers: any, dir: any };
|
|
||||||
const __VLS_unref: typeof import('vue').unref;
|
|
||||||
const __VLS_placeholder: any;
|
|
||||||
|
|
||||||
type __VLS_NativeElements = __VLS_SpreadMerge<SVGElementTagNameMap, HTMLElementTagNameMap>;
|
|
||||||
type __VLS_IntrinsicElements = import('vue/jsx-runtime').JSX.IntrinsicElements;
|
|
||||||
type __VLS_Element = import('vue/jsx-runtime').JSX.Element;
|
|
||||||
type __VLS_GlobalComponents = import('vue').GlobalComponents;
|
|
||||||
type __VLS_GlobalDirectives = import('vue').GlobalDirectives;
|
|
||||||
type __VLS_IsAny<T> = 0 extends 1 & T ? true : false;
|
|
||||||
type __VLS_PickNotAny<A, B> = __VLS_IsAny<A> extends true ? B : A;
|
|
||||||
type __VLS_SpreadMerge<A, B> = Omit<A, keyof B> & B;
|
|
||||||
type __VLS_WithComponent<N0 extends string, LocalComponents, Self, N1 extends string, N2 extends string, N3 extends string> =
|
|
||||||
N1 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N1] } :
|
|
||||||
N2 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N2] } :
|
|
||||||
N3 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N3] } :
|
|
||||||
Self extends object ? { [K in N0]: Self } :
|
|
||||||
N1 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N1] } :
|
|
||||||
N2 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N2] } :
|
|
||||||
N3 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N3] } :
|
|
||||||
{};
|
|
||||||
type __VLS_FunctionalComponentCtx<T, K> = __VLS_PickNotAny<'__ctx' extends keyof __VLS_PickNotAny<K, {}>
|
|
||||||
? K extends { __ctx?: infer Ctx } ? NonNullable<Ctx> : never : any
|
|
||||||
, T extends (props: any, ctx: infer Ctx) => any ? Ctx : any
|
|
||||||
>;
|
|
||||||
type __VLS_FunctionalComponentProps<T, K> = '__ctx' extends keyof __VLS_PickNotAny<K, {}>
|
|
||||||
? K extends { __ctx?: { props?: infer P } } ? NonNullable<P> : never
|
|
||||||
: T extends (props: infer P, ...args: any) => any ? P
|
|
||||||
: {};
|
|
||||||
type __VLS_FunctionalComponent<T> = (props: (T extends { $props: infer Props } ? Props : {}) & Record<string, unknown>, ctx?: any) => __VLS_Element & {
|
|
||||||
__ctx?: {
|
|
||||||
attrs?: any;
|
|
||||||
slots?: T extends { $slots: infer Slots } ? Slots : Record<string, any>;
|
|
||||||
emit?: T extends { $emit: infer Emit } ? Emit : {};
|
|
||||||
props?: (T extends { $props: infer Props } ? Props : {}) & Record<string, unknown>;
|
|
||||||
expose?: (exposed: T) => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
type __VLS_IsFunction<T, K> = K extends keyof T
|
|
||||||
? __VLS_IsAny<T[K]> extends false
|
|
||||||
? unknown extends T[K]
|
|
||||||
? false
|
|
||||||
: true
|
|
||||||
: false
|
|
||||||
: false;
|
|
||||||
type __VLS_NormalizeComponentEvent<
|
|
||||||
Props,
|
|
||||||
Emits,
|
|
||||||
onEvent extends keyof Props,
|
|
||||||
Event extends keyof Emits,
|
|
||||||
CamelizedEvent extends keyof Emits,
|
|
||||||
> = __VLS_IsFunction<Props, onEvent> extends true
|
|
||||||
? Props
|
|
||||||
: __VLS_IsFunction<Emits, Event> extends true
|
|
||||||
? { [K in onEvent]?: Emits[Event] }
|
|
||||||
: __VLS_IsFunction<Emits, CamelizedEvent> extends true
|
|
||||||
? { [K in onEvent]?: Emits[CamelizedEvent] }
|
|
||||||
: Props;
|
|
||||||
// fix https://github.com/vuejs/language-tools/issues/926
|
|
||||||
type __VLS_UnionToIntersection<U> = (U extends unknown ? (arg: U) => unknown : never) extends ((arg: infer P) => unknown) ? P : never;
|
|
||||||
type __VLS_OverloadUnionInner<T, U = unknown> = U & T extends (...args: infer A) => infer R
|
|
||||||
? U extends T
|
|
||||||
? never
|
|
||||||
: __VLS_OverloadUnionInner<T, Pick<T, keyof T> & U & ((...args: A) => R)> | ((...args: A) => R)
|
|
||||||
: never;
|
|
||||||
type __VLS_OverloadUnion<T> = Exclude<
|
|
||||||
__VLS_OverloadUnionInner<(() => never) & T>,
|
|
||||||
T extends () => never ? never : () => never
|
|
||||||
>;
|
|
||||||
type __VLS_ConstructorOverloads<T> = __VLS_OverloadUnion<T> extends infer F
|
|
||||||
? F extends (event: infer E, ...args: infer A) => any
|
|
||||||
? { [K in E & string]: (...args: A) => void; }
|
|
||||||
: never
|
|
||||||
: never;
|
|
||||||
type __VLS_NormalizeEmits<T> = __VLS_PrettifyGlobal<
|
|
||||||
__VLS_UnionToIntersection<
|
|
||||||
__VLS_ConstructorOverloads<T> & {
|
|
||||||
[K in keyof T]: T[K] extends any[] ? { (...args: T[K]): void } : never
|
|
||||||
}
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
type __VLS_EmitsToProps<T> = __VLS_PrettifyGlobal<{
|
|
||||||
[K in string & keyof T as `on${Capitalize<K>}`]?:
|
|
||||||
(...args: T[K] extends (...args: infer P) => any ? P : T[K] extends null ? any[] : never) => any;
|
|
||||||
}>;
|
|
||||||
type __VLS_ResolveEmits<
|
|
||||||
Comp,
|
|
||||||
Emits,
|
|
||||||
TypeEmits = {},
|
|
||||||
NormalizedEmits = __VLS_NormalizeEmits<Emits> extends infer E ? string extends keyof E ? {} : E : never,
|
|
||||||
> = __VLS_SpreadMerge<NormalizedEmits, TypeEmits>;
|
|
||||||
type __VLS_ResolveDirectives<T> = {
|
|
||||||
[K in keyof T & string as `v${Capitalize<K>}`]: T[K];
|
|
||||||
};
|
|
||||||
type __VLS_PrettifyGlobal<T> = { [K in keyof T as K]: T[K]; } & {};
|
|
||||||
type __VLS_WithDefaultsGlobal<P, D> = {
|
|
||||||
[K in keyof P as K extends keyof D ? K : never]-?: P[K];
|
|
||||||
} & {
|
|
||||||
[K in keyof P as K extends keyof D ? never : K]: P[K];
|
|
||||||
};
|
|
||||||
type __VLS_UseTemplateRef<T> = Readonly<import('vue').ShallowRef<T | null>>;
|
|
||||||
type __VLS_ProxyRefs<T> = import('vue').ShallowUnwrapRef<T>;
|
|
||||||
|
|
||||||
function __VLS_getVForSourceType<T extends number | string | any[] | Iterable<any>>(source: T): [
|
|
||||||
item: T extends number ? number
|
|
||||||
: T extends string ? string
|
|
||||||
: T extends any[] ? T[number]
|
|
||||||
: T extends Iterable<infer T1> ? T1
|
|
||||||
: any,
|
|
||||||
index: number,
|
|
||||||
][];
|
|
||||||
function __VLS_getVForSourceType<T>(source: T): [
|
|
||||||
item: T[keyof T],
|
|
||||||
key: keyof T,
|
|
||||||
index: number,
|
|
||||||
][];
|
|
||||||
function __VLS_getSlotParameters<S, D extends S>(slot: S, decl?: D):
|
|
||||||
D extends (...args: infer P) => any ? P : any[];
|
|
||||||
function __VLS_asFunctionalDirective<T>(dir: T): T extends import('vue').ObjectDirective
|
|
||||||
? NonNullable<T['created' | 'beforeMount' | 'mounted' | 'beforeUpdate' | 'updated' | 'beforeUnmount' | 'unmounted']>
|
|
||||||
: T extends (...args: any) => any
|
|
||||||
? T
|
|
||||||
: (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown) => void;
|
|
||||||
function __VLS_asFunctionalComponent<T, K = T extends new (...args: any) => any ? InstanceType<T> : unknown>(t: T, instance?: K):
|
|
||||||
T extends new (...args: any) => any ? __VLS_FunctionalComponent<K>
|
|
||||||
: T extends () => any ? (props: {}, ctx?: any) => ReturnType<T>
|
|
||||||
: T extends (...args: any) => any ? T
|
|
||||||
: __VLS_FunctionalComponent<{}>;
|
|
||||||
function __VLS_functionalComponentArgsRest<T extends (...args: any) => any>(t: T): 2 extends Parameters<T>['length'] ? [any] : [];
|
|
||||||
function __VLS_asFunctionalElement<T>(tag: T, endTag?: T): (attrs: T & Record<string, unknown>) => void;
|
|
||||||
function __VLS_asFunctionalSlot<S>(slot: S): S extends () => infer R ? (props: {}) => R : NonNullable<S>;
|
|
||||||
function __VLS_tryAsConstant<const T>(t: T): T;
|
|
||||||
}
|
|
||||||
137
pmoupnp/webapp/node_modules/.vue-global-types/vue_99_0.d.ts
generated
vendored
137
pmoupnp/webapp/node_modules/.vue-global-types/vue_99_0.d.ts
generated
vendored
@@ -1,137 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
export {};
|
|
||||||
|
|
||||||
; declare global {
|
|
||||||
const __VLS_directiveBindingRestFields: { instance: null, oldValue: null, modifiers: any, dir: any };
|
|
||||||
const __VLS_unref: typeof import('vue').unref;
|
|
||||||
const __VLS_placeholder: any;
|
|
||||||
|
|
||||||
type __VLS_NativeElements = __VLS_SpreadMerge<SVGElementTagNameMap, HTMLElementTagNameMap>;
|
|
||||||
type __VLS_IntrinsicElements = import('vue/jsx-runtime').JSX.IntrinsicElements;
|
|
||||||
type __VLS_Element = import('vue/jsx-runtime').JSX.Element;
|
|
||||||
type __VLS_GlobalComponents = import('vue').GlobalComponents;
|
|
||||||
type __VLS_GlobalDirectives = import('vue').GlobalDirectives;
|
|
||||||
type __VLS_IsAny<T> = 0 extends 1 & T ? true : false;
|
|
||||||
type __VLS_PickNotAny<A, B> = __VLS_IsAny<A> extends true ? B : A;
|
|
||||||
type __VLS_SpreadMerge<A, B> = Omit<A, keyof B> & B;
|
|
||||||
type __VLS_WithComponent<N0 extends string, LocalComponents, Self, N1 extends string, N2 extends string, N3 extends string> =
|
|
||||||
N1 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N1] } :
|
|
||||||
N2 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N2] } :
|
|
||||||
N3 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N3] } :
|
|
||||||
Self extends object ? { [K in N0]: Self } :
|
|
||||||
N1 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N1] } :
|
|
||||||
N2 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N2] } :
|
|
||||||
N3 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N3] } :
|
|
||||||
{};
|
|
||||||
type __VLS_FunctionalComponentCtx<T, K> = __VLS_PickNotAny<'__ctx' extends keyof __VLS_PickNotAny<K, {}>
|
|
||||||
? K extends { __ctx?: infer Ctx } ? NonNullable<Ctx> : never : any
|
|
||||||
, T extends (props: any, ctx: infer Ctx) => any ? Ctx : any
|
|
||||||
>;
|
|
||||||
type __VLS_FunctionalComponentProps<T, K> = '__ctx' extends keyof __VLS_PickNotAny<K, {}>
|
|
||||||
? K extends { __ctx?: { props?: infer P } } ? NonNullable<P> : never
|
|
||||||
: T extends (props: infer P, ...args: any) => any ? P
|
|
||||||
: {};
|
|
||||||
type __VLS_FunctionalComponent<T> = (props: (T extends { $props: infer Props } ? Props : {}) & Record<string, unknown>, ctx?: any) => __VLS_Element & {
|
|
||||||
__ctx?: {
|
|
||||||
attrs?: any;
|
|
||||||
slots?: T extends { $slots: infer Slots } ? Slots : Record<string, any>;
|
|
||||||
emit?: T extends { $emit: infer Emit } ? Emit : {};
|
|
||||||
props?: (T extends { $props: infer Props } ? Props : {}) & Record<string, unknown>;
|
|
||||||
expose?: (exposed: T) => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
type __VLS_IsFunction<T, K> = K extends keyof T
|
|
||||||
? __VLS_IsAny<T[K]> extends false
|
|
||||||
? unknown extends T[K]
|
|
||||||
? false
|
|
||||||
: true
|
|
||||||
: false
|
|
||||||
: false;
|
|
||||||
type __VLS_NormalizeComponentEvent<
|
|
||||||
Props,
|
|
||||||
Emits,
|
|
||||||
onEvent extends keyof Props,
|
|
||||||
Event extends keyof Emits,
|
|
||||||
CamelizedEvent extends keyof Emits,
|
|
||||||
> = __VLS_IsFunction<Props, onEvent> extends true
|
|
||||||
? Props
|
|
||||||
: __VLS_IsFunction<Emits, Event> extends true
|
|
||||||
? { [K in onEvent]?: Emits[Event] }
|
|
||||||
: __VLS_IsFunction<Emits, CamelizedEvent> extends true
|
|
||||||
? { [K in onEvent]?: Emits[CamelizedEvent] }
|
|
||||||
: Props;
|
|
||||||
// fix https://github.com/vuejs/language-tools/issues/926
|
|
||||||
type __VLS_UnionToIntersection<U> = (U extends unknown ? (arg: U) => unknown : never) extends ((arg: infer P) => unknown) ? P : never;
|
|
||||||
type __VLS_OverloadUnionInner<T, U = unknown> = U & T extends (...args: infer A) => infer R
|
|
||||||
? U extends T
|
|
||||||
? never
|
|
||||||
: __VLS_OverloadUnionInner<T, Pick<T, keyof T> & U & ((...args: A) => R)> | ((...args: A) => R)
|
|
||||||
: never;
|
|
||||||
type __VLS_OverloadUnion<T> = Exclude<
|
|
||||||
__VLS_OverloadUnionInner<(() => never) & T>,
|
|
||||||
T extends () => never ? never : () => never
|
|
||||||
>;
|
|
||||||
type __VLS_ConstructorOverloads<T> = __VLS_OverloadUnion<T> extends infer F
|
|
||||||
? F extends (event: infer E, ...args: infer A) => any
|
|
||||||
? { [K in E & string]: (...args: A) => void; }
|
|
||||||
: never
|
|
||||||
: never;
|
|
||||||
type __VLS_NormalizeEmits<T> = __VLS_PrettifyGlobal<
|
|
||||||
__VLS_UnionToIntersection<
|
|
||||||
__VLS_ConstructorOverloads<T> & {
|
|
||||||
[K in keyof T]: T[K] extends any[] ? { (...args: T[K]): void } : never
|
|
||||||
}
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
type __VLS_EmitsToProps<T> = __VLS_PrettifyGlobal<{
|
|
||||||
[K in string & keyof T as `on${Capitalize<K>}`]?:
|
|
||||||
(...args: T[K] extends (...args: infer P) => any ? P : T[K] extends null ? any[] : never) => any;
|
|
||||||
}>;
|
|
||||||
type __VLS_ResolveEmits<
|
|
||||||
Comp,
|
|
||||||
Emits,
|
|
||||||
TypeEmits = Comp extends { __typeEmits?: infer T } ? unknown extends T ? {} : import('vue').ShortEmitsToObject<T> : {},
|
|
||||||
NormalizedEmits = __VLS_NormalizeEmits<Emits> extends infer E ? string extends keyof E ? {} : E : never,
|
|
||||||
> = __VLS_SpreadMerge<NormalizedEmits, TypeEmits>;
|
|
||||||
type __VLS_ResolveDirectives<T> = {
|
|
||||||
[K in keyof T & string as `v${Capitalize<K>}`]: T[K];
|
|
||||||
};
|
|
||||||
type __VLS_PrettifyGlobal<T> = { [K in keyof T as K]: T[K]; } & {};
|
|
||||||
type __VLS_WithDefaultsGlobal<P, D> = {
|
|
||||||
[K in keyof P as K extends keyof D ? K : never]-?: P[K];
|
|
||||||
} & {
|
|
||||||
[K in keyof P as K extends keyof D ? never : K]: P[K];
|
|
||||||
};
|
|
||||||
type __VLS_UseTemplateRef<T> = Readonly<import('vue').ShallowRef<T | null>>;
|
|
||||||
type __VLS_ProxyRefs<T> = import('vue').ShallowUnwrapRef<T>;
|
|
||||||
|
|
||||||
function __VLS_getVForSourceType<T extends number | string | any[] | Iterable<any>>(source: T): [
|
|
||||||
item: T extends number ? number
|
|
||||||
: T extends string ? string
|
|
||||||
: T extends any[] ? T[number]
|
|
||||||
: T extends Iterable<infer T1> ? T1
|
|
||||||
: any,
|
|
||||||
index: number,
|
|
||||||
][];
|
|
||||||
function __VLS_getVForSourceType<T>(source: T): [
|
|
||||||
item: T[keyof T],
|
|
||||||
key: keyof T,
|
|
||||||
index: number,
|
|
||||||
][];
|
|
||||||
function __VLS_getSlotParameters<S, D extends S>(slot: S, decl?: D):
|
|
||||||
D extends (...args: infer P) => any ? P : any[];
|
|
||||||
function __VLS_asFunctionalDirective<T>(dir: T): T extends import('vue').ObjectDirective
|
|
||||||
? NonNullable<T['created' | 'beforeMount' | 'mounted' | 'beforeUpdate' | 'updated' | 'beforeUnmount' | 'unmounted']>
|
|
||||||
: T extends (...args: any) => any
|
|
||||||
? T
|
|
||||||
: (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown) => void;
|
|
||||||
function __VLS_asFunctionalComponent<T, K = T extends new (...args: any) => any ? InstanceType<T> : unknown>(t: T, instance?: K):
|
|
||||||
T extends new (...args: any) => any ? __VLS_FunctionalComponent<K>
|
|
||||||
: T extends () => any ? (props: {}, ctx?: any) => ReturnType<T>
|
|
||||||
: T extends (...args: any) => any ? T
|
|
||||||
: __VLS_FunctionalComponent<{}>;
|
|
||||||
function __VLS_functionalComponentArgsRest<T extends (...args: any) => any>(t: T): 2 extends Parameters<T>['length'] ? [any] : [];
|
|
||||||
function __VLS_asFunctionalElement<T>(tag: T, endTag?: T): (attrs: T & Record<string, unknown>) => void;
|
|
||||||
function __VLS_asFunctionalSlot<S>(slot: S): S extends () => infer R ? (props: {}) => R : NonNullable<S>;
|
|
||||||
function __VLS_tryAsConstant<const T>(t: T): T;
|
|
||||||
}
|
|
||||||
22
pmoupnp/webapp/node_modules/@babel/helper-string-parser/LICENSE
generated
vendored
22
pmoupnp/webapp/node_modules/@babel/helper-string-parser/LICENSE
generated
vendored
@@ -1,22 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2014-present Sebastian McKenzie and other contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
"Software"), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
||||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
||||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
||||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
||||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
19
pmoupnp/webapp/node_modules/@babel/helper-string-parser/README.md
generated
vendored
19
pmoupnp/webapp/node_modules/@babel/helper-string-parser/README.md
generated
vendored
@@ -1,19 +0,0 @@
|
|||||||
# @babel/helper-string-parser
|
|
||||||
|
|
||||||
> A utility package to parse strings
|
|
||||||
|
|
||||||
See our website [@babel/helper-string-parser](https://babeljs.io/docs/babel-helper-string-parser) for more information.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Using npm:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install --save @babel/helper-string-parser
|
|
||||||
```
|
|
||||||
|
|
||||||
or using yarn:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn add @babel/helper-string-parser
|
|
||||||
```
|
|
||||||
295
pmoupnp/webapp/node_modules/@babel/helper-string-parser/lib/index.js
generated
vendored
295
pmoupnp/webapp/node_modules/@babel/helper-string-parser/lib/index.js
generated
vendored
@@ -1,295 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
Object.defineProperty(exports, "__esModule", {
|
|
||||||
value: true
|
|
||||||
});
|
|
||||||
exports.readCodePoint = readCodePoint;
|
|
||||||
exports.readInt = readInt;
|
|
||||||
exports.readStringContents = readStringContents;
|
|
||||||
var _isDigit = function isDigit(code) {
|
|
||||||
return code >= 48 && code <= 57;
|
|
||||||
};
|
|
||||||
const forbiddenNumericSeparatorSiblings = {
|
|
||||||
decBinOct: new Set([46, 66, 69, 79, 95, 98, 101, 111]),
|
|
||||||
hex: new Set([46, 88, 95, 120])
|
|
||||||
};
|
|
||||||
const isAllowedNumericSeparatorSibling = {
|
|
||||||
bin: ch => ch === 48 || ch === 49,
|
|
||||||
oct: ch => ch >= 48 && ch <= 55,
|
|
||||||
dec: ch => ch >= 48 && ch <= 57,
|
|
||||||
hex: ch => ch >= 48 && ch <= 57 || ch >= 65 && ch <= 70 || ch >= 97 && ch <= 102
|
|
||||||
};
|
|
||||||
function readStringContents(type, input, pos, lineStart, curLine, errors) {
|
|
||||||
const initialPos = pos;
|
|
||||||
const initialLineStart = lineStart;
|
|
||||||
const initialCurLine = curLine;
|
|
||||||
let out = "";
|
|
||||||
let firstInvalidLoc = null;
|
|
||||||
let chunkStart = pos;
|
|
||||||
const {
|
|
||||||
length
|
|
||||||
} = input;
|
|
||||||
for (;;) {
|
|
||||||
if (pos >= length) {
|
|
||||||
errors.unterminated(initialPos, initialLineStart, initialCurLine);
|
|
||||||
out += input.slice(chunkStart, pos);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const ch = input.charCodeAt(pos);
|
|
||||||
if (isStringEnd(type, ch, input, pos)) {
|
|
||||||
out += input.slice(chunkStart, pos);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (ch === 92) {
|
|
||||||
out += input.slice(chunkStart, pos);
|
|
||||||
const res = readEscapedChar(input, pos, lineStart, curLine, type === "template", errors);
|
|
||||||
if (res.ch === null && !firstInvalidLoc) {
|
|
||||||
firstInvalidLoc = {
|
|
||||||
pos,
|
|
||||||
lineStart,
|
|
||||||
curLine
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
out += res.ch;
|
|
||||||
}
|
|
||||||
({
|
|
||||||
pos,
|
|
||||||
lineStart,
|
|
||||||
curLine
|
|
||||||
} = res);
|
|
||||||
chunkStart = pos;
|
|
||||||
} else if (ch === 8232 || ch === 8233) {
|
|
||||||
++pos;
|
|
||||||
++curLine;
|
|
||||||
lineStart = pos;
|
|
||||||
} else if (ch === 10 || ch === 13) {
|
|
||||||
if (type === "template") {
|
|
||||||
out += input.slice(chunkStart, pos) + "\n";
|
|
||||||
++pos;
|
|
||||||
if (ch === 13 && input.charCodeAt(pos) === 10) {
|
|
||||||
++pos;
|
|
||||||
}
|
|
||||||
++curLine;
|
|
||||||
chunkStart = lineStart = pos;
|
|
||||||
} else {
|
|
||||||
errors.unterminated(initialPos, initialLineStart, initialCurLine);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
++pos;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
pos,
|
|
||||||
str: out,
|
|
||||||
firstInvalidLoc,
|
|
||||||
lineStart,
|
|
||||||
curLine,
|
|
||||||
containsInvalid: !!firstInvalidLoc
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function isStringEnd(type, ch, input, pos) {
|
|
||||||
if (type === "template") {
|
|
||||||
return ch === 96 || ch === 36 && input.charCodeAt(pos + 1) === 123;
|
|
||||||
}
|
|
||||||
return ch === (type === "double" ? 34 : 39);
|
|
||||||
}
|
|
||||||
function readEscapedChar(input, pos, lineStart, curLine, inTemplate, errors) {
|
|
||||||
const throwOnInvalid = !inTemplate;
|
|
||||||
pos++;
|
|
||||||
const res = ch => ({
|
|
||||||
pos,
|
|
||||||
ch,
|
|
||||||
lineStart,
|
|
||||||
curLine
|
|
||||||
});
|
|
||||||
const ch = input.charCodeAt(pos++);
|
|
||||||
switch (ch) {
|
|
||||||
case 110:
|
|
||||||
return res("\n");
|
|
||||||
case 114:
|
|
||||||
return res("\r");
|
|
||||||
case 120:
|
|
||||||
{
|
|
||||||
let code;
|
|
||||||
({
|
|
||||||
code,
|
|
||||||
pos
|
|
||||||
} = readHexChar(input, pos, lineStart, curLine, 2, false, throwOnInvalid, errors));
|
|
||||||
return res(code === null ? null : String.fromCharCode(code));
|
|
||||||
}
|
|
||||||
case 117:
|
|
||||||
{
|
|
||||||
let code;
|
|
||||||
({
|
|
||||||
code,
|
|
||||||
pos
|
|
||||||
} = readCodePoint(input, pos, lineStart, curLine, throwOnInvalid, errors));
|
|
||||||
return res(code === null ? null : String.fromCodePoint(code));
|
|
||||||
}
|
|
||||||
case 116:
|
|
||||||
return res("\t");
|
|
||||||
case 98:
|
|
||||||
return res("\b");
|
|
||||||
case 118:
|
|
||||||
return res("\u000b");
|
|
||||||
case 102:
|
|
||||||
return res("\f");
|
|
||||||
case 13:
|
|
||||||
if (input.charCodeAt(pos) === 10) {
|
|
||||||
++pos;
|
|
||||||
}
|
|
||||||
case 10:
|
|
||||||
lineStart = pos;
|
|
||||||
++curLine;
|
|
||||||
case 8232:
|
|
||||||
case 8233:
|
|
||||||
return res("");
|
|
||||||
case 56:
|
|
||||||
case 57:
|
|
||||||
if (inTemplate) {
|
|
||||||
return res(null);
|
|
||||||
} else {
|
|
||||||
errors.strictNumericEscape(pos - 1, lineStart, curLine);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if (ch >= 48 && ch <= 55) {
|
|
||||||
const startPos = pos - 1;
|
|
||||||
const match = /^[0-7]+/.exec(input.slice(startPos, pos + 2));
|
|
||||||
let octalStr = match[0];
|
|
||||||
let octal = parseInt(octalStr, 8);
|
|
||||||
if (octal > 255) {
|
|
||||||
octalStr = octalStr.slice(0, -1);
|
|
||||||
octal = parseInt(octalStr, 8);
|
|
||||||
}
|
|
||||||
pos += octalStr.length - 1;
|
|
||||||
const next = input.charCodeAt(pos);
|
|
||||||
if (octalStr !== "0" || next === 56 || next === 57) {
|
|
||||||
if (inTemplate) {
|
|
||||||
return res(null);
|
|
||||||
} else {
|
|
||||||
errors.strictNumericEscape(startPos, lineStart, curLine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res(String.fromCharCode(octal));
|
|
||||||
}
|
|
||||||
return res(String.fromCharCode(ch));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function readHexChar(input, pos, lineStart, curLine, len, forceLen, throwOnInvalid, errors) {
|
|
||||||
const initialPos = pos;
|
|
||||||
let n;
|
|
||||||
({
|
|
||||||
n,
|
|
||||||
pos
|
|
||||||
} = readInt(input, pos, lineStart, curLine, 16, len, forceLen, false, errors, !throwOnInvalid));
|
|
||||||
if (n === null) {
|
|
||||||
if (throwOnInvalid) {
|
|
||||||
errors.invalidEscapeSequence(initialPos, lineStart, curLine);
|
|
||||||
} else {
|
|
||||||
pos = initialPos - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
code: n,
|
|
||||||
pos
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function readInt(input, pos, lineStart, curLine, radix, len, forceLen, allowNumSeparator, errors, bailOnError) {
|
|
||||||
const start = pos;
|
|
||||||
const forbiddenSiblings = radix === 16 ? forbiddenNumericSeparatorSiblings.hex : forbiddenNumericSeparatorSiblings.decBinOct;
|
|
||||||
const isAllowedSibling = radix === 16 ? isAllowedNumericSeparatorSibling.hex : radix === 10 ? isAllowedNumericSeparatorSibling.dec : radix === 8 ? isAllowedNumericSeparatorSibling.oct : isAllowedNumericSeparatorSibling.bin;
|
|
||||||
let invalid = false;
|
|
||||||
let total = 0;
|
|
||||||
for (let i = 0, e = len == null ? Infinity : len; i < e; ++i) {
|
|
||||||
const code = input.charCodeAt(pos);
|
|
||||||
let val;
|
|
||||||
if (code === 95 && allowNumSeparator !== "bail") {
|
|
||||||
const prev = input.charCodeAt(pos - 1);
|
|
||||||
const next = input.charCodeAt(pos + 1);
|
|
||||||
if (!allowNumSeparator) {
|
|
||||||
if (bailOnError) return {
|
|
||||||
n: null,
|
|
||||||
pos
|
|
||||||
};
|
|
||||||
errors.numericSeparatorInEscapeSequence(pos, lineStart, curLine);
|
|
||||||
} else if (Number.isNaN(next) || !isAllowedSibling(next) || forbiddenSiblings.has(prev) || forbiddenSiblings.has(next)) {
|
|
||||||
if (bailOnError) return {
|
|
||||||
n: null,
|
|
||||||
pos
|
|
||||||
};
|
|
||||||
errors.unexpectedNumericSeparator(pos, lineStart, curLine);
|
|
||||||
}
|
|
||||||
++pos;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (code >= 97) {
|
|
||||||
val = code - 97 + 10;
|
|
||||||
} else if (code >= 65) {
|
|
||||||
val = code - 65 + 10;
|
|
||||||
} else if (_isDigit(code)) {
|
|
||||||
val = code - 48;
|
|
||||||
} else {
|
|
||||||
val = Infinity;
|
|
||||||
}
|
|
||||||
if (val >= radix) {
|
|
||||||
if (val <= 9 && bailOnError) {
|
|
||||||
return {
|
|
||||||
n: null,
|
|
||||||
pos
|
|
||||||
};
|
|
||||||
} else if (val <= 9 && errors.invalidDigit(pos, lineStart, curLine, radix)) {
|
|
||||||
val = 0;
|
|
||||||
} else if (forceLen) {
|
|
||||||
val = 0;
|
|
||||||
invalid = true;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
++pos;
|
|
||||||
total = total * radix + val;
|
|
||||||
}
|
|
||||||
if (pos === start || len != null && pos - start !== len || invalid) {
|
|
||||||
return {
|
|
||||||
n: null,
|
|
||||||
pos
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
n: total,
|
|
||||||
pos
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function readCodePoint(input, pos, lineStart, curLine, throwOnInvalid, errors) {
|
|
||||||
const ch = input.charCodeAt(pos);
|
|
||||||
let code;
|
|
||||||
if (ch === 123) {
|
|
||||||
++pos;
|
|
||||||
({
|
|
||||||
code,
|
|
||||||
pos
|
|
||||||
} = readHexChar(input, pos, lineStart, curLine, input.indexOf("}", pos) - pos, true, throwOnInvalid, errors));
|
|
||||||
++pos;
|
|
||||||
if (code !== null && code > 0x10ffff) {
|
|
||||||
if (throwOnInvalid) {
|
|
||||||
errors.invalidCodePoint(pos, lineStart, curLine);
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
code: null,
|
|
||||||
pos
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
({
|
|
||||||
code,
|
|
||||||
pos
|
|
||||||
} = readHexChar(input, pos, lineStart, curLine, 4, false, throwOnInvalid, errors));
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
pos
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//# sourceMappingURL=index.js.map
|
|
||||||
1
pmoupnp/webapp/node_modules/@babel/helper-string-parser/lib/index.js.map
generated
vendored
1
pmoupnp/webapp/node_modules/@babel/helper-string-parser/lib/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
31
pmoupnp/webapp/node_modules/@babel/helper-string-parser/package.json
generated
vendored
31
pmoupnp/webapp/node_modules/@babel/helper-string-parser/package.json
generated
vendored
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@babel/helper-string-parser",
|
|
||||||
"version": "7.27.1",
|
|
||||||
"description": "A utility package to parse strings",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/babel/babel.git",
|
|
||||||
"directory": "packages/babel-helper-string-parser"
|
|
||||||
},
|
|
||||||
"homepage": "https://babel.dev/docs/en/next/babel-helper-string-parser",
|
|
||||||
"license": "MIT",
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
},
|
|
||||||
"main": "./lib/index.js",
|
|
||||||
"devDependencies": {
|
|
||||||
"charcodes": "^0.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
},
|
|
||||||
"author": "The Babel Team (https://babel.dev/team)",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./lib/index.d.ts",
|
|
||||||
"default": "./lib/index.js"
|
|
||||||
},
|
|
||||||
"./package.json": "./package.json"
|
|
||||||
},
|
|
||||||
"type": "commonjs"
|
|
||||||
}
|
|
||||||
22
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/LICENSE
generated
vendored
22
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/LICENSE
generated
vendored
@@ -1,22 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2014-present Sebastian McKenzie and other contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
"Software"), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
||||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
||||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
||||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
||||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
19
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/README.md
generated
vendored
19
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/README.md
generated
vendored
@@ -1,19 +0,0 @@
|
|||||||
# @babel/helper-validator-identifier
|
|
||||||
|
|
||||||
> Validate identifier/keywords name
|
|
||||||
|
|
||||||
See our website [@babel/helper-validator-identifier](https://babeljs.io/docs/babel-helper-validator-identifier) for more information.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Using npm:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install --save @babel/helper-validator-identifier
|
|
||||||
```
|
|
||||||
|
|
||||||
or using yarn:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn add @babel/helper-validator-identifier
|
|
||||||
```
|
|
||||||
70
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/identifier.js
generated
vendored
70
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/identifier.js
generated
vendored
@@ -1,70 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
Object.defineProperty(exports, "__esModule", {
|
|
||||||
value: true
|
|
||||||
});
|
|
||||||
exports.isIdentifierChar = isIdentifierChar;
|
|
||||||
exports.isIdentifierName = isIdentifierName;
|
|
||||||
exports.isIdentifierStart = isIdentifierStart;
|
|
||||||
let nonASCIIidentifierStartChars = "\xaa\xb5\xba\xc0-\xd6\xd8-\xf6\xf8-\u02c1\u02c6-\u02d1\u02e0-\u02e4\u02ec\u02ee\u0370-\u0374\u0376\u0377\u037a-\u037d\u037f\u0386\u0388-\u038a\u038c\u038e-\u03a1\u03a3-\u03f5\u03f7-\u0481\u048a-\u052f\u0531-\u0556\u0559\u0560-\u0588\u05d0-\u05ea\u05ef-\u05f2\u0620-\u064a\u066e\u066f\u0671-\u06d3\u06d5\u06e5\u06e6\u06ee\u06ef\u06fa-\u06fc\u06ff\u0710\u0712-\u072f\u074d-\u07a5\u07b1\u07ca-\u07ea\u07f4\u07f5\u07fa\u0800-\u0815\u081a\u0824\u0828\u0840-\u0858\u0860-\u086a\u0870-\u0887\u0889-\u088e\u08a0-\u08c9\u0904-\u0939\u093d\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098c\u098f\u0990\u0993-\u09a8\u09aa-\u09b0\u09b2\u09b6-\u09b9\u09bd\u09ce\u09dc\u09dd\u09df-\u09e1\u09f0\u09f1\u09fc\u0a05-\u0a0a\u0a0f\u0a10\u0a13-\u0a28\u0a2a-\u0a30\u0a32\u0a33\u0a35\u0a36\u0a38\u0a39\u0a59-\u0a5c\u0a5e\u0a72-\u0a74\u0a85-\u0a8d\u0a8f-\u0a91\u0a93-\u0aa8\u0aaa-\u0ab0\u0ab2\u0ab3\u0ab5-\u0ab9\u0abd\u0ad0\u0ae0\u0ae1\u0af9\u0b05-\u0b0c\u0b0f\u0b10\u0b13-\u0b28\u0b2a-\u0b30\u0b32\u0b33\u0b35-\u0b39\u0b3d\u0b5c\u0b5d\u0b5f-\u0b61\u0b71\u0b83\u0b85-\u0b8a\u0b8e-\u0b90\u0b92-\u0b95\u0b99\u0b9a\u0b9c\u0b9e\u0b9f\u0ba3\u0ba4\u0ba8-\u0baa\u0bae-\u0bb9\u0bd0\u0c05-\u0c0c\u0c0e-\u0c10\u0c12-\u0c28\u0c2a-\u0c39\u0c3d\u0c58-\u0c5a\u0c5d\u0c60\u0c61\u0c80\u0c85-\u0c8c\u0c8e-\u0c90\u0c92-\u0ca8\u0caa-\u0cb3\u0cb5-\u0cb9\u0cbd\u0cdd\u0cde\u0ce0\u0ce1\u0cf1\u0cf2\u0d04-\u0d0c\u0d0e-\u0d10\u0d12-\u0d3a\u0d3d\u0d4e\u0d54-\u0d56\u0d5f-\u0d61\u0d7a-\u0d7f\u0d85-\u0d96\u0d9a-\u0db1\u0db3-\u0dbb\u0dbd\u0dc0-\u0dc6\u0e01-\u0e30\u0e32\u0e33\u0e40-\u0e46\u0e81\u0e82\u0e84\u0e86-\u0e8a\u0e8c-\u0ea3\u0ea5\u0ea7-\u0eb0\u0eb2\u0eb3\u0ebd\u0ec0-\u0ec4\u0ec6\u0edc-\u0edf\u0f00\u0f40-\u0f47\u0f49-\u0f6c\u0f88-\u0f8c\u1000-\u102a\u103f\u1050-\u1055\u105a-\u105d\u1061\u1065\u1066\u106e-\u1070\u1075-\u1081\u108e\u10a0-\u10c5\u10c7\u10cd\u10d0-\u10fa\u10fc-\u1248\u124a-\u124d\u1250-\u1256\u1258\u125a-\u125d\u1260-\u1288\u128a-\u128d\u1290-\u12b0\u12b2-\u12b5\u12b8-\u12be\u12c0\u12c2-\u12c5\u12c8-\u12d6\u12d8-\u1310\u1312-\u1315\u1318-\u135a\u1380-\u138f\u13a0-\u13f5\u13f8-\u13fd\u1401-\u166c\u166f-\u167f\u1681-\u169a\u16a0-\u16ea\u16ee-\u16f8\u1700-\u1711\u171f-\u1731\u1740-\u1751\u1760-\u176c\u176e-\u1770\u1780-\u17b3\u17d7\u17dc\u1820-\u1878\u1880-\u18a8\u18aa\u18b0-\u18f5\u1900-\u191e\u1950-\u196d\u1970-\u1974\u1980-\u19ab\u19b0-\u19c9\u1a00-\u1a16\u1a20-\u1a54\u1aa7\u1b05-\u1b33\u1b45-\u1b4c\u1b83-\u1ba0\u1bae\u1baf\u1bba-\u1be5\u1c00-\u1c23\u1c4d-\u1c4f\u1c5a-\u1c7d\u1c80-\u1c8a\u1c90-\u1cba\u1cbd-\u1cbf\u1ce9-\u1cec\u1cee-\u1cf3\u1cf5\u1cf6\u1cfa\u1d00-\u1dbf\u1e00-\u1f15\u1f18-\u1f1d\u1f20-\u1f45\u1f48-\u1f4d\u1f50-\u1f57\u1f59\u1f5b\u1f5d\u1f5f-\u1f7d\u1f80-\u1fb4\u1fb6-\u1fbc\u1fbe\u1fc2-\u1fc4\u1fc6-\u1fcc\u1fd0-\u1fd3\u1fd6-\u1fdb\u1fe0-\u1fec\u1ff2-\u1ff4\u1ff6-\u1ffc\u2071\u207f\u2090-\u209c\u2102\u2107\u210a-\u2113\u2115\u2118-\u211d\u2124\u2126\u2128\u212a-\u2139\u213c-\u213f\u2145-\u2149\u214e\u2160-\u2188\u2c00-\u2ce4\u2ceb-\u2cee\u2cf2\u2cf3\u2d00-\u2d25\u2d27\u2d2d\u2d30-\u2d67\u2d6f\u2d80-\u2d96\u2da0-\u2da6\u2da8-\u2dae\u2db0-\u2db6\u2db8-\u2dbe\u2dc0-\u2dc6\u2dc8-\u2dce\u2dd0-\u2dd6\u2dd8-\u2dde\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303c\u3041-\u3096\u309b-\u309f\u30a1-\u30fa\u30fc-\u30ff\u3105-\u312f\u3131-\u318e\u31a0-\u31bf\u31f0-\u31ff\u3400-\u4dbf\u4e00-\ua48c\ua4d0-\ua4fd\ua500-\ua60c\ua610-\ua61f\ua62a\ua62b\ua640-\ua66e\ua67f-\ua69d\ua6a0-\ua6ef\ua717-\ua71f\ua722-\ua788\ua78b-\ua7cd\ua7d0\ua7d1\ua7d3\ua7d5-\ua7dc\ua7f2-\ua801\ua803-\ua805\ua807-\ua80a\ua80c-\ua822\ua840-\ua873\ua882-\ua8b3\ua8f2-\ua8f7\ua8fb\ua8fd\ua8fe\ua90a-\ua925\ua930-\ua946\ua960-\ua97c\ua984-\ua9b2\ua9cf\ua9e0-\ua9e4\ua9e6-\ua9ef\ua9fa-\ua9fe\uaa00-\uaa28\uaa40-\uaa42\uaa44-\uaa4b\uaa60-\uaa76\uaa7a\uaa7e-\uaaaf\uaab1\uaab5\uaab6\uaab9-\uaabd\uaac0\uaac2\uaadb-\uaadd\uaae0-\uaaea\uaaf2-\uaaf4\uab01-\uab06\uab09-\uab0e\uab11-\uab16\uab20-\uab26\uab28-\uab2e\uab30-\uab5a\uab5c-\uab69\uab70-\uabe2\uac00-\ud7a3\ud7b0-\ud7c6\ud7cb-\ud7fb\uf900-\ufa6d\ufa70-\ufad9\ufb00-\ufb06\ufb13-\ufb17\ufb1d\ufb1f-\ufb28\ufb2a-\ufb36\ufb38-\ufb3c\ufb3e\ufb40\ufb41\ufb43\ufb44\ufb46-\ufbb1\ufbd3-\ufd3d\ufd50-\ufd8f\ufd92-\ufdc7\ufdf0-\ufdfb\ufe70-\ufe74\ufe76-\ufefc\uff21-\uff3a\uff41-\uff5a\uff66-\uffbe\uffc2-\uffc7\uffca-\uffcf\uffd2-\uffd7\uffda-\uffdc";
|
|
||||||
let nonASCIIidentifierChars = "\xb7\u0300-\u036f\u0387\u0483-\u0487\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u0669\u0670\u06d6-\u06dc\u06df-\u06e4\u06e7\u06e8\u06ea-\u06ed\u06f0-\u06f9\u0711\u0730-\u074a\u07a6-\u07b0\u07c0-\u07c9\u07eb-\u07f3\u07fd\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0859-\u085b\u0897-\u089f\u08ca-\u08e1\u08e3-\u0903\u093a-\u093c\u093e-\u094f\u0951-\u0957\u0962\u0963\u0966-\u096f\u0981-\u0983\u09bc\u09be-\u09c4\u09c7\u09c8\u09cb-\u09cd\u09d7\u09e2\u09e3\u09e6-\u09ef\u09fe\u0a01-\u0a03\u0a3c\u0a3e-\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a66-\u0a71\u0a75\u0a81-\u0a83\u0abc\u0abe-\u0ac5\u0ac7-\u0ac9\u0acb-\u0acd\u0ae2\u0ae3\u0ae6-\u0aef\u0afa-\u0aff\u0b01-\u0b03\u0b3c\u0b3e-\u0b44\u0b47\u0b48\u0b4b-\u0b4d\u0b55-\u0b57\u0b62\u0b63\u0b66-\u0b6f\u0b82\u0bbe-\u0bc2\u0bc6-\u0bc8\u0bca-\u0bcd\u0bd7\u0be6-\u0bef\u0c00-\u0c04\u0c3c\u0c3e-\u0c44\u0c46-\u0c48\u0c4a-\u0c4d\u0c55\u0c56\u0c62\u0c63\u0c66-\u0c6f\u0c81-\u0c83\u0cbc\u0cbe-\u0cc4\u0cc6-\u0cc8\u0cca-\u0ccd\u0cd5\u0cd6\u0ce2\u0ce3\u0ce6-\u0cef\u0cf3\u0d00-\u0d03\u0d3b\u0d3c\u0d3e-\u0d44\u0d46-\u0d48\u0d4a-\u0d4d\u0d57\u0d62\u0d63\u0d66-\u0d6f\u0d81-\u0d83\u0dca\u0dcf-\u0dd4\u0dd6\u0dd8-\u0ddf\u0de6-\u0def\u0df2\u0df3\u0e31\u0e34-\u0e3a\u0e47-\u0e4e\u0e50-\u0e59\u0eb1\u0eb4-\u0ebc\u0ec8-\u0ece\u0ed0-\u0ed9\u0f18\u0f19\u0f20-\u0f29\u0f35\u0f37\u0f39\u0f3e\u0f3f\u0f71-\u0f84\u0f86\u0f87\u0f8d-\u0f97\u0f99-\u0fbc\u0fc6\u102b-\u103e\u1040-\u1049\u1056-\u1059\u105e-\u1060\u1062-\u1064\u1067-\u106d\u1071-\u1074\u1082-\u108d\u108f-\u109d\u135d-\u135f\u1369-\u1371\u1712-\u1715\u1732-\u1734\u1752\u1753\u1772\u1773\u17b4-\u17d3\u17dd\u17e0-\u17e9\u180b-\u180d\u180f-\u1819\u18a9\u1920-\u192b\u1930-\u193b\u1946-\u194f\u19d0-\u19da\u1a17-\u1a1b\u1a55-\u1a5e\u1a60-\u1a7c\u1a7f-\u1a89\u1a90-\u1a99\u1ab0-\u1abd\u1abf-\u1ace\u1b00-\u1b04\u1b34-\u1b44\u1b50-\u1b59\u1b6b-\u1b73\u1b80-\u1b82\u1ba1-\u1bad\u1bb0-\u1bb9\u1be6-\u1bf3\u1c24-\u1c37\u1c40-\u1c49\u1c50-\u1c59\u1cd0-\u1cd2\u1cd4-\u1ce8\u1ced\u1cf4\u1cf7-\u1cf9\u1dc0-\u1dff\u200c\u200d\u203f\u2040\u2054\u20d0-\u20dc\u20e1\u20e5-\u20f0\u2cef-\u2cf1\u2d7f\u2de0-\u2dff\u302a-\u302f\u3099\u309a\u30fb\ua620-\ua629\ua66f\ua674-\ua67d\ua69e\ua69f\ua6f0\ua6f1\ua802\ua806\ua80b\ua823-\ua827\ua82c\ua880\ua881\ua8b4-\ua8c5\ua8d0-\ua8d9\ua8e0-\ua8f1\ua8ff-\ua909\ua926-\ua92d\ua947-\ua953\ua980-\ua983\ua9b3-\ua9c0\ua9d0-\ua9d9\ua9e5\ua9f0-\ua9f9\uaa29-\uaa36\uaa43\uaa4c\uaa4d\uaa50-\uaa59\uaa7b-\uaa7d\uaab0\uaab2-\uaab4\uaab7\uaab8\uaabe\uaabf\uaac1\uaaeb-\uaaef\uaaf5\uaaf6\uabe3-\uabea\uabec\uabed\uabf0-\uabf9\ufb1e\ufe00-\ufe0f\ufe20-\ufe2f\ufe33\ufe34\ufe4d-\ufe4f\uff10-\uff19\uff3f\uff65";
|
|
||||||
const nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]");
|
|
||||||
const nonASCIIidentifier = new RegExp("[" + nonASCIIidentifierStartChars + nonASCIIidentifierChars + "]");
|
|
||||||
nonASCIIidentifierStartChars = nonASCIIidentifierChars = null;
|
|
||||||
const astralIdentifierStartCodes = [0, 11, 2, 25, 2, 18, 2, 1, 2, 14, 3, 13, 35, 122, 70, 52, 268, 28, 4, 48, 48, 31, 14, 29, 6, 37, 11, 29, 3, 35, 5, 7, 2, 4, 43, 157, 19, 35, 5, 35, 5, 39, 9, 51, 13, 10, 2, 14, 2, 6, 2, 1, 2, 10, 2, 14, 2, 6, 2, 1, 4, 51, 13, 310, 10, 21, 11, 7, 25, 5, 2, 41, 2, 8, 70, 5, 3, 0, 2, 43, 2, 1, 4, 0, 3, 22, 11, 22, 10, 30, 66, 18, 2, 1, 11, 21, 11, 25, 71, 55, 7, 1, 65, 0, 16, 3, 2, 2, 2, 28, 43, 28, 4, 28, 36, 7, 2, 27, 28, 53, 11, 21, 11, 18, 14, 17, 111, 72, 56, 50, 14, 50, 14, 35, 39, 27, 10, 22, 251, 41, 7, 1, 17, 2, 60, 28, 11, 0, 9, 21, 43, 17, 47, 20, 28, 22, 13, 52, 58, 1, 3, 0, 14, 44, 33, 24, 27, 35, 30, 0, 3, 0, 9, 34, 4, 0, 13, 47, 15, 3, 22, 0, 2, 0, 36, 17, 2, 24, 20, 1, 64, 6, 2, 0, 2, 3, 2, 14, 2, 9, 8, 46, 39, 7, 3, 1, 3, 21, 2, 6, 2, 1, 2, 4, 4, 0, 19, 0, 13, 4, 31, 9, 2, 0, 3, 0, 2, 37, 2, 0, 26, 0, 2, 0, 45, 52, 19, 3, 21, 2, 31, 47, 21, 1, 2, 0, 185, 46, 42, 3, 37, 47, 21, 0, 60, 42, 14, 0, 72, 26, 38, 6, 186, 43, 117, 63, 32, 7, 3, 0, 3, 7, 2, 1, 2, 23, 16, 0, 2, 0, 95, 7, 3, 38, 17, 0, 2, 0, 29, 0, 11, 39, 8, 0, 22, 0, 12, 45, 20, 0, 19, 72, 200, 32, 32, 8, 2, 36, 18, 0, 50, 29, 113, 6, 2, 1, 2, 37, 22, 0, 26, 5, 2, 1, 2, 31, 15, 0, 328, 18, 16, 0, 2, 12, 2, 33, 125, 0, 80, 921, 103, 110, 18, 195, 2637, 96, 16, 1071, 18, 5, 26, 3994, 6, 582, 6842, 29, 1763, 568, 8, 30, 18, 78, 18, 29, 19, 47, 17, 3, 32, 20, 6, 18, 433, 44, 212, 63, 129, 74, 6, 0, 67, 12, 65, 1, 2, 0, 29, 6135, 9, 1237, 42, 9, 8936, 3, 2, 6, 2, 1, 2, 290, 16, 0, 30, 2, 3, 0, 15, 3, 9, 395, 2309, 106, 6, 12, 4, 8, 8, 9, 5991, 84, 2, 70, 2, 1, 3, 0, 3, 1, 3, 3, 2, 11, 2, 0, 2, 6, 2, 64, 2, 3, 3, 7, 2, 6, 2, 27, 2, 3, 2, 4, 2, 0, 4, 6, 2, 339, 3, 24, 2, 24, 2, 30, 2, 24, 2, 30, 2, 24, 2, 30, 2, 24, 2, 30, 2, 24, 2, 7, 1845, 30, 7, 5, 262, 61, 147, 44, 11, 6, 17, 0, 322, 29, 19, 43, 485, 27, 229, 29, 3, 0, 496, 6, 2, 3, 2, 1, 2, 14, 2, 196, 60, 67, 8, 0, 1205, 3, 2, 26, 2, 1, 2, 0, 3, 0, 2, 9, 2, 3, 2, 0, 2, 0, 7, 0, 5, 0, 2, 0, 2, 0, 2, 2, 2, 1, 2, 0, 3, 0, 2, 0, 2, 0, 2, 0, 2, 0, 2, 1, 2, 0, 3, 3, 2, 6, 2, 3, 2, 3, 2, 0, 2, 9, 2, 16, 6, 2, 2, 4, 2, 16, 4421, 42719, 33, 4153, 7, 221, 3, 5761, 15, 7472, 16, 621, 2467, 541, 1507, 4938, 6, 4191];
|
|
||||||
const astralIdentifierCodes = [509, 0, 227, 0, 150, 4, 294, 9, 1368, 2, 2, 1, 6, 3, 41, 2, 5, 0, 166, 1, 574, 3, 9, 9, 7, 9, 32, 4, 318, 1, 80, 3, 71, 10, 50, 3, 123, 2, 54, 14, 32, 10, 3, 1, 11, 3, 46, 10, 8, 0, 46, 9, 7, 2, 37, 13, 2, 9, 6, 1, 45, 0, 13, 2, 49, 13, 9, 3, 2, 11, 83, 11, 7, 0, 3, 0, 158, 11, 6, 9, 7, 3, 56, 1, 2, 6, 3, 1, 3, 2, 10, 0, 11, 1, 3, 6, 4, 4, 68, 8, 2, 0, 3, 0, 2, 3, 2, 4, 2, 0, 15, 1, 83, 17, 10, 9, 5, 0, 82, 19, 13, 9, 214, 6, 3, 8, 28, 1, 83, 16, 16, 9, 82, 12, 9, 9, 7, 19, 58, 14, 5, 9, 243, 14, 166, 9, 71, 5, 2, 1, 3, 3, 2, 0, 2, 1, 13, 9, 120, 6, 3, 6, 4, 0, 29, 9, 41, 6, 2, 3, 9, 0, 10, 10, 47, 15, 343, 9, 54, 7, 2, 7, 17, 9, 57, 21, 2, 13, 123, 5, 4, 0, 2, 1, 2, 6, 2, 0, 9, 9, 49, 4, 2, 1, 2, 4, 9, 9, 330, 3, 10, 1, 2, 0, 49, 6, 4, 4, 14, 10, 5350, 0, 7, 14, 11465, 27, 2343, 9, 87, 9, 39, 4, 60, 6, 26, 9, 535, 9, 470, 0, 2, 54, 8, 3, 82, 0, 12, 1, 19628, 1, 4178, 9, 519, 45, 3, 22, 543, 4, 4, 5, 9, 7, 3, 6, 31, 3, 149, 2, 1418, 49, 513, 54, 5, 49, 9, 0, 15, 0, 23, 4, 2, 14, 1361, 6, 2, 16, 3, 6, 2, 1, 2, 4, 101, 0, 161, 6, 10, 9, 357, 0, 62, 13, 499, 13, 245, 1, 2, 9, 726, 6, 110, 6, 6, 9, 4759, 9, 787719, 239];
|
|
||||||
function isInAstralSet(code, set) {
|
|
||||||
let pos = 0x10000;
|
|
||||||
for (let i = 0, length = set.length; i < length; i += 2) {
|
|
||||||
pos += set[i];
|
|
||||||
if (pos > code) return false;
|
|
||||||
pos += set[i + 1];
|
|
||||||
if (pos >= code) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
function isIdentifierStart(code) {
|
|
||||||
if (code < 65) return code === 36;
|
|
||||||
if (code <= 90) return true;
|
|
||||||
if (code < 97) return code === 95;
|
|
||||||
if (code <= 122) return true;
|
|
||||||
if (code <= 0xffff) {
|
|
||||||
return code >= 0xaa && nonASCIIidentifierStart.test(String.fromCharCode(code));
|
|
||||||
}
|
|
||||||
return isInAstralSet(code, astralIdentifierStartCodes);
|
|
||||||
}
|
|
||||||
function isIdentifierChar(code) {
|
|
||||||
if (code < 48) return code === 36;
|
|
||||||
if (code < 58) return true;
|
|
||||||
if (code < 65) return false;
|
|
||||||
if (code <= 90) return true;
|
|
||||||
if (code < 97) return code === 95;
|
|
||||||
if (code <= 122) return true;
|
|
||||||
if (code <= 0xffff) {
|
|
||||||
return code >= 0xaa && nonASCIIidentifier.test(String.fromCharCode(code));
|
|
||||||
}
|
|
||||||
return isInAstralSet(code, astralIdentifierStartCodes) || isInAstralSet(code, astralIdentifierCodes);
|
|
||||||
}
|
|
||||||
function isIdentifierName(name) {
|
|
||||||
let isFirst = true;
|
|
||||||
for (let i = 0; i < name.length; i++) {
|
|
||||||
let cp = name.charCodeAt(i);
|
|
||||||
if ((cp & 0xfc00) === 0xd800 && i + 1 < name.length) {
|
|
||||||
const trail = name.charCodeAt(++i);
|
|
||||||
if ((trail & 0xfc00) === 0xdc00) {
|
|
||||||
cp = 0x10000 + ((cp & 0x3ff) << 10) + (trail & 0x3ff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isFirst) {
|
|
||||||
isFirst = false;
|
|
||||||
if (!isIdentifierStart(cp)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else if (!isIdentifierChar(cp)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return !isFirst;
|
|
||||||
}
|
|
||||||
|
|
||||||
//# sourceMappingURL=identifier.js.map
|
|
||||||
1
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/identifier.js.map
generated
vendored
1
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/identifier.js.map
generated
vendored
File diff suppressed because one or more lines are too long
57
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/index.js
generated
vendored
57
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/index.js
generated
vendored
@@ -1,57 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
Object.defineProperty(exports, "__esModule", {
|
|
||||||
value: true
|
|
||||||
});
|
|
||||||
Object.defineProperty(exports, "isIdentifierChar", {
|
|
||||||
enumerable: true,
|
|
||||||
get: function () {
|
|
||||||
return _identifier.isIdentifierChar;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Object.defineProperty(exports, "isIdentifierName", {
|
|
||||||
enumerable: true,
|
|
||||||
get: function () {
|
|
||||||
return _identifier.isIdentifierName;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Object.defineProperty(exports, "isIdentifierStart", {
|
|
||||||
enumerable: true,
|
|
||||||
get: function () {
|
|
||||||
return _identifier.isIdentifierStart;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Object.defineProperty(exports, "isKeyword", {
|
|
||||||
enumerable: true,
|
|
||||||
get: function () {
|
|
||||||
return _keyword.isKeyword;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Object.defineProperty(exports, "isReservedWord", {
|
|
||||||
enumerable: true,
|
|
||||||
get: function () {
|
|
||||||
return _keyword.isReservedWord;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Object.defineProperty(exports, "isStrictBindOnlyReservedWord", {
|
|
||||||
enumerable: true,
|
|
||||||
get: function () {
|
|
||||||
return _keyword.isStrictBindOnlyReservedWord;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Object.defineProperty(exports, "isStrictBindReservedWord", {
|
|
||||||
enumerable: true,
|
|
||||||
get: function () {
|
|
||||||
return _keyword.isStrictBindReservedWord;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Object.defineProperty(exports, "isStrictReservedWord", {
|
|
||||||
enumerable: true,
|
|
||||||
get: function () {
|
|
||||||
return _keyword.isStrictReservedWord;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
var _identifier = require("./identifier.js");
|
|
||||||
var _keyword = require("./keyword.js");
|
|
||||||
|
|
||||||
//# sourceMappingURL=index.js.map
|
|
||||||
1
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/index.js.map
generated
vendored
1
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/index.js.map
generated
vendored
@@ -1 +0,0 @@
|
|||||||
{"version":3,"names":["_identifier","require","_keyword"],"sources":["../src/index.ts"],"sourcesContent":["export {\n isIdentifierName,\n isIdentifierChar,\n isIdentifierStart,\n} from \"./identifier.ts\";\nexport {\n isReservedWord,\n isStrictBindOnlyReservedWord,\n isStrictBindReservedWord,\n isStrictReservedWord,\n isKeyword,\n} from \"./keyword.ts\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,IAAAA,WAAA,GAAAC,OAAA;AAKA,IAAAC,QAAA,GAAAD,OAAA","ignoreList":[]}
|
|
||||||
35
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/keyword.js
generated
vendored
35
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/keyword.js
generated
vendored
@@ -1,35 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
Object.defineProperty(exports, "__esModule", {
|
|
||||||
value: true
|
|
||||||
});
|
|
||||||
exports.isKeyword = isKeyword;
|
|
||||||
exports.isReservedWord = isReservedWord;
|
|
||||||
exports.isStrictBindOnlyReservedWord = isStrictBindOnlyReservedWord;
|
|
||||||
exports.isStrictBindReservedWord = isStrictBindReservedWord;
|
|
||||||
exports.isStrictReservedWord = isStrictReservedWord;
|
|
||||||
const reservedWords = {
|
|
||||||
keyword: ["break", "case", "catch", "continue", "debugger", "default", "do", "else", "finally", "for", "function", "if", "return", "switch", "throw", "try", "var", "const", "while", "with", "new", "this", "super", "class", "extends", "export", "import", "null", "true", "false", "in", "instanceof", "typeof", "void", "delete"],
|
|
||||||
strict: ["implements", "interface", "let", "package", "private", "protected", "public", "static", "yield"],
|
|
||||||
strictBind: ["eval", "arguments"]
|
|
||||||
};
|
|
||||||
const keywords = new Set(reservedWords.keyword);
|
|
||||||
const reservedWordsStrictSet = new Set(reservedWords.strict);
|
|
||||||
const reservedWordsStrictBindSet = new Set(reservedWords.strictBind);
|
|
||||||
function isReservedWord(word, inModule) {
|
|
||||||
return inModule && word === "await" || word === "enum";
|
|
||||||
}
|
|
||||||
function isStrictReservedWord(word, inModule) {
|
|
||||||
return isReservedWord(word, inModule) || reservedWordsStrictSet.has(word);
|
|
||||||
}
|
|
||||||
function isStrictBindOnlyReservedWord(word) {
|
|
||||||
return reservedWordsStrictBindSet.has(word);
|
|
||||||
}
|
|
||||||
function isStrictBindReservedWord(word, inModule) {
|
|
||||||
return isStrictReservedWord(word, inModule) || isStrictBindOnlyReservedWord(word);
|
|
||||||
}
|
|
||||||
function isKeyword(word) {
|
|
||||||
return keywords.has(word);
|
|
||||||
}
|
|
||||||
|
|
||||||
//# sourceMappingURL=keyword.js.map
|
|
||||||
1
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/keyword.js.map
generated
vendored
1
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/lib/keyword.js.map
generated
vendored
@@ -1 +0,0 @@
|
|||||||
{"version":3,"names":["reservedWords","keyword","strict","strictBind","keywords","Set","reservedWordsStrictSet","reservedWordsStrictBindSet","isReservedWord","word","inModule","isStrictReservedWord","has","isStrictBindOnlyReservedWord","isStrictBindReservedWord","isKeyword"],"sources":["../src/keyword.ts"],"sourcesContent":["const reservedWords = {\n keyword: [\n \"break\",\n \"case\",\n \"catch\",\n \"continue\",\n \"debugger\",\n \"default\",\n \"do\",\n \"else\",\n \"finally\",\n \"for\",\n \"function\",\n \"if\",\n \"return\",\n \"switch\",\n \"throw\",\n \"try\",\n \"var\",\n \"const\",\n \"while\",\n \"with\",\n \"new\",\n \"this\",\n \"super\",\n \"class\",\n \"extends\",\n \"export\",\n \"import\",\n \"null\",\n \"true\",\n \"false\",\n \"in\",\n \"instanceof\",\n \"typeof\",\n \"void\",\n \"delete\",\n ],\n strict: [\n \"implements\",\n \"interface\",\n \"let\",\n \"package\",\n \"private\",\n \"protected\",\n \"public\",\n \"static\",\n \"yield\",\n ],\n strictBind: [\"eval\", \"arguments\"],\n};\nconst keywords = new Set(reservedWords.keyword);\nconst reservedWordsStrictSet = new Set(reservedWords.strict);\nconst reservedWordsStrictBindSet = new Set(reservedWords.strictBind);\n\n/**\n * Checks if word is a reserved word in non-strict mode\n */\nexport function isReservedWord(word: string, inModule: boolean): boolean {\n return (inModule && word === \"await\") || word === \"enum\";\n}\n\n/**\n * Checks if word is a reserved word in non-binding strict mode\n *\n * Includes non-strict reserved words\n */\nexport function isStrictReservedWord(word: string, inModule: boolean): boolean {\n return isReservedWord(word, inModule) || reservedWordsStrictSet.has(word);\n}\n\n/**\n * Checks if word is a reserved word in binding strict mode, but it is allowed as\n * a normal identifier.\n */\nexport function isStrictBindOnlyReservedWord(word: string): boolean {\n return reservedWordsStrictBindSet.has(word);\n}\n\n/**\n * Checks if word is a reserved word in binding strict mode\n *\n * Includes non-strict reserved words and non-binding strict reserved words\n */\nexport function isStrictBindReservedWord(\n word: string,\n inModule: boolean,\n): boolean {\n return (\n isStrictReservedWord(word, inModule) || isStrictBindOnlyReservedWord(word)\n );\n}\n\nexport function isKeyword(word: string): boolean {\n return keywords.has(word);\n}\n"],"mappings":";;;;;;;;;;AAAA,MAAMA,aAAa,GAAG;EACpBC,OAAO,EAAE,CACP,OAAO,EACP,MAAM,EACN,OAAO,EACP,UAAU,EACV,UAAU,EACV,SAAS,EACT,IAAI,EACJ,MAAM,EACN,SAAS,EACT,KAAK,EACL,UAAU,EACV,IAAI,EACJ,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,KAAK,EACL,KAAK,EACL,OAAO,EACP,OAAO,EACP,MAAM,EACN,KAAK,EACL,MAAM,EACN,OAAO,EACP,OAAO,EACP,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,MAAM,EACN,OAAO,EACP,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,MAAM,EACN,QAAQ,CACT;EACDC,MAAM,EAAE,CACN,YAAY,EACZ,WAAW,EACX,KAAK,EACL,SAAS,EACT,SAAS,EACT,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,OAAO,CACR;EACDC,UAAU,EAAE,CAAC,MAAM,EAAE,WAAW;AAClC,CAAC;AACD,MAAMC,QAAQ,GAAG,IAAIC,GAAG,CAACL,aAAa,CAACC,OAAO,CAAC;AAC/C,MAAMK,sBAAsB,GAAG,IAAID,GAAG,CAACL,aAAa,CAACE,MAAM,CAAC;AAC5D,MAAMK,0BAA0B,GAAG,IAAIF,GAAG,CAACL,aAAa,CAACG,UAAU,CAAC;AAK7D,SAASK,cAAcA,CAACC,IAAY,EAAEC,QAAiB,EAAW;EACvE,OAAQA,QAAQ,IAAID,IAAI,KAAK,OAAO,IAAKA,IAAI,KAAK,MAAM;AAC1D;AAOO,SAASE,oBAAoBA,CAACF,IAAY,EAAEC,QAAiB,EAAW;EAC7E,OAAOF,cAAc,CAACC,IAAI,EAAEC,QAAQ,CAAC,IAAIJ,sBAAsB,CAACM,GAAG,CAACH,IAAI,CAAC;AAC3E;AAMO,SAASI,4BAA4BA,CAACJ,IAAY,EAAW;EAClE,OAAOF,0BAA0B,CAACK,GAAG,CAACH,IAAI,CAAC;AAC7C;AAOO,SAASK,wBAAwBA,CACtCL,IAAY,EACZC,QAAiB,EACR;EACT,OACEC,oBAAoB,CAACF,IAAI,EAAEC,QAAQ,CAAC,IAAIG,4BAA4B,CAACJ,IAAI,CAAC;AAE9E;AAEO,SAASM,SAASA,CAACN,IAAY,EAAW;EAC/C,OAAOL,QAAQ,CAACQ,GAAG,CAACH,IAAI,CAAC;AAC3B","ignoreList":[]}
|
|
||||||
31
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/package.json
generated
vendored
31
pmoupnp/webapp/node_modules/@babel/helper-validator-identifier/package.json
generated
vendored
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@babel/helper-validator-identifier",
|
|
||||||
"version": "7.27.1",
|
|
||||||
"description": "Validate identifier/keywords name",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/babel/babel.git",
|
|
||||||
"directory": "packages/babel-helper-validator-identifier"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
},
|
|
||||||
"main": "./lib/index.js",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./lib/index.d.ts",
|
|
||||||
"default": "./lib/index.js"
|
|
||||||
},
|
|
||||||
"./package.json": "./package.json"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@unicode/unicode-16.0.0": "^1.0.0",
|
|
||||||
"charcodes": "^0.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
},
|
|
||||||
"author": "The Babel Team (https://babel.dev/team)",
|
|
||||||
"type": "commonjs"
|
|
||||||
}
|
|
||||||
1073
pmoupnp/webapp/node_modules/@babel/parser/CHANGELOG.md
generated
vendored
1073
pmoupnp/webapp/node_modules/@babel/parser/CHANGELOG.md
generated
vendored
File diff suppressed because it is too large
Load Diff
19
pmoupnp/webapp/node_modules/@babel/parser/LICENSE
generated
vendored
19
pmoupnp/webapp/node_modules/@babel/parser/LICENSE
generated
vendored
@@ -1,19 +0,0 @@
|
|||||||
Copyright (C) 2012-2014 by various contributors (see AUTHORS)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
19
pmoupnp/webapp/node_modules/@babel/parser/README.md
generated
vendored
19
pmoupnp/webapp/node_modules/@babel/parser/README.md
generated
vendored
@@ -1,19 +0,0 @@
|
|||||||
# @babel/parser
|
|
||||||
|
|
||||||
> A JavaScript parser
|
|
||||||
|
|
||||||
See our website [@babel/parser](https://babeljs.io/docs/babel-parser) for more information or the [issues](https://github.com/babel/babel/issues?utf8=%E2%9C%93&q=is%3Aissue+label%3A%22pkg%3A%20parser%22+is%3Aopen) associated with this package.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Using npm:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install --save-dev @babel/parser
|
|
||||||
```
|
|
||||||
|
|
||||||
or using yarn:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yarn add @babel/parser --dev
|
|
||||||
```
|
|
||||||
15
pmoupnp/webapp/node_modules/@babel/parser/bin/babel-parser.js
generated
vendored
15
pmoupnp/webapp/node_modules/@babel/parser/bin/babel-parser.js
generated
vendored
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/* eslint-disable no-var, unicorn/prefer-node-protocol */
|
|
||||||
|
|
||||||
var parser = require("..");
|
|
||||||
var fs = require("fs");
|
|
||||||
|
|
||||||
var filename = process.argv[2];
|
|
||||||
if (!filename) {
|
|
||||||
console.error("no filename specified");
|
|
||||||
} else {
|
|
||||||
var file = fs.readFileSync(filename, "utf8");
|
|
||||||
var ast = parser.parse(file);
|
|
||||||
|
|
||||||
console.log(JSON.stringify(ast, null, " "));
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user