Version fonctionnelle du cache #15
17
.gitignore
vendored
17
.gitignore
vendored
@@ -3,6 +3,21 @@
|
||||
/vendor/
|
||||
**/*.log
|
||||
**/*.old
|
||||
**/*.o
|
||||
**/*.o.d
|
||||
**/*.a
|
||||
xxx
|
||||
/dcai/
|
||||
***/.pmomusic.yml
|
||||
**/.pmomusic.yml
|
||||
**/.pmomusic_covers/**
|
||||
.DS_Store
|
||||
/target/
|
||||
.pmomusic_covers
|
||||
C/src/soxr-0.1.3/Release/tests
|
||||
**/Release/
|
||||
**/Debug/
|
||||
OLD-GO-CODE/
|
||||
xxx
|
||||
xx
|
||||
all.txt
|
||||
pmo_src.txt
|
||||
|
||||
15
.pmomusic.yml
Normal file
15
.pmomusic.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
host:
|
||||
http_port: '8080'
|
||||
cover_cache:
|
||||
directory: ./.pmomusic_covers
|
||||
size: 2000
|
||||
devices:
|
||||
mediarenderer:
|
||||
mpd_renderer: null
|
||||
fakerenderer:
|
||||
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
|
||||
mediarenderer:
|
||||
udn: f9ef6c21-0ed3-470c-9846-bc1ae85fea62
|
||||
mediaserver:
|
||||
qobuz:
|
||||
udn: 28963b75-4c5f-4da7-b10e-ffafd
|
||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"makefile.configureOnOpen": false,
|
||||
"git.enabled": false,
|
||||
"claude-code.environmentVariables": [
|
||||
|
||||
]
|
||||
}
|
||||
3924
Cargo.lock
generated
Normal file
3924
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocovers"]
|
||||
192
Makefile
192
Makefile
@@ -1,12 +1,190 @@
|
||||
APP_NAME=pmomusic
|
||||
# Makefile pour projet Rust + Vue.js
|
||||
# Variables de configuration
|
||||
CARGO = cargo
|
||||
NPM = npm
|
||||
WEBAPP_DIR = pmoapp/webapp
|
||||
DIST_DIR = $(WEBAPP_DIR)/dist
|
||||
RUST_TARGET = target/release
|
||||
DOC_DIR = target/doc
|
||||
BINARY_NAME = PMOMusic
|
||||
|
||||
build:
|
||||
go build -o bin/$(APP_NAME) ./cmd/$(APP_NAME)
|
||||
# Couleurs pour l'affichage
|
||||
GREEN = \033[0;32m
|
||||
YELLOW = \033[1;33m
|
||||
RED = \033[0;31m
|
||||
NC = \033[0m # No Color
|
||||
|
||||
run:
|
||||
go run ./cmd/$(APP_NAME)
|
||||
.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch
|
||||
|
||||
# Cible par défaut
|
||||
all: build
|
||||
|
||||
## help: Affiche cette aide
|
||||
help:
|
||||
@echo "$(GREEN)Commandes disponibles :$(NC)"
|
||||
@sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /'
|
||||
|
||||
## build: Compile tout (Rust + Vue.js)
|
||||
build: webapp release
|
||||
@echo "$(GREEN)✓ Build complet terminé$(NC)"
|
||||
|
||||
## release: Compile le binaire Rust en mode release
|
||||
release: webapp
|
||||
@echo "$(YELLOW)→ Compilation Rust (release)...$(NC)"
|
||||
$(CARGO) build --release
|
||||
@echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)"
|
||||
|
||||
## debug: Compile le binaire Rust en mode debug
|
||||
debug: webapp
|
||||
@echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)"
|
||||
$(CARGO) build
|
||||
@echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)"
|
||||
|
||||
## test: Exécute tous les tests Rust
|
||||
test:
|
||||
@echo "$(YELLOW)→ Exécution des tests Rust...$(NC)"
|
||||
$(CARGO) test --all
|
||||
@echo "$(GREEN)✓ Tests terminés$(NC)"
|
||||
|
||||
## test-doc: Teste les exemples dans la documentation
|
||||
test-doc:
|
||||
@echo "$(YELLOW)→ Test des exemples de documentation...$(NC)"
|
||||
$(CARGO) test --doc
|
||||
@echo "$(GREEN)✓ Tests de documentation terminés$(NC)"
|
||||
|
||||
## doc: Génère et ouvre la documentation Rust
|
||||
doc:
|
||||
@echo "$(YELLOW)→ Génération de la documentation...$(NC)"
|
||||
$(CARGO) doc --no-deps --document-private-items --open
|
||||
@echo "$(GREEN)✓ Documentation générée dans $(DOC_DIR)$(NC)"
|
||||
|
||||
## doc-build: Génère la documentation sans l'ouvrir
|
||||
doc-build:
|
||||
@echo "$(YELLOW)→ Génération de la documentation...$(NC)"
|
||||
$(CARGO) doc --no-deps --document-private-items
|
||||
@echo "$(GREEN)✓ Documentation générée dans $(DOC_DIR)$(NC)"
|
||||
|
||||
## webapp: Compile l'application Vue.js
|
||||
webapp: webapp-install
|
||||
@echo "$(YELLOW)→ Build Vue.js...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) run build
|
||||
@echo "$(GREEN)✓ Application Vue.js compilée dans $(DIST_DIR)$(NC)"
|
||||
|
||||
## webapp-install: Installe les dépendances npm
|
||||
webapp-install:
|
||||
@echo "$(YELLOW)→ Installation des dépendances npm...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) install
|
||||
@echo "$(GREEN)✓ Dépendances npm installées$(NC)"
|
||||
|
||||
## webapp-dev: Lance le serveur de développement Vue.js
|
||||
webapp-dev:
|
||||
@echo "$(YELLOW)→ Démarrage du serveur de dev Vue.js...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) run dev
|
||||
|
||||
## clean: Nettoie les fichiers de build
|
||||
clean:
|
||||
rm -rf bin/
|
||||
@echo "$(YELLOW)→ Nettoyage...$(NC)"
|
||||
$(CARGO) clean
|
||||
rm -rf $(DIST_DIR)
|
||||
rm -rf $(WEBAPP_DIR)/node_modules
|
||||
@echo "$(GREEN)✓ Nettoyage terminé$(NC)"
|
||||
|
||||
all: build
|
||||
## clean-rust: Nettoie uniquement les builds Rust
|
||||
clean-rust:
|
||||
@echo "$(YELLOW)→ Nettoyage Rust...$(NC)"
|
||||
$(CARGO) clean
|
||||
@echo "$(GREEN)✓ Nettoyage Rust terminé$(NC)"
|
||||
|
||||
## clean-webapp: Nettoie uniquement le build Vue.js
|
||||
clean-webapp:
|
||||
@echo "$(YELLOW)→ Nettoyage Vue.js...$(NC)"
|
||||
rm -rf $(DIST_DIR)
|
||||
@echo "$(GREEN)✓ Nettoyage Vue.js terminé$(NC)"
|
||||
|
||||
## install: Installe le binaire dans ~/.cargo/bin
|
||||
install: release
|
||||
@echo "$(YELLOW)→ Installation du binaire...$(NC)"
|
||||
$(CARGO) install --path .
|
||||
@echo "$(GREEN)✓ $(BINARY_NAME) installé$(NC)"
|
||||
|
||||
## dev: Lance le serveur en mode debug (recompile à chaque changement)
|
||||
dev:
|
||||
@echo "$(YELLOW)→ Démarrage en mode développement...$(NC)"
|
||||
$(CARGO) watch -x run
|
||||
|
||||
## check: Vérifie que le code compile sans générer de binaire
|
||||
check:
|
||||
@echo "$(YELLOW)→ Vérification du code...$(NC)"
|
||||
$(CARGO) check --all
|
||||
@echo "$(GREEN)✓ Code valide$(NC)"
|
||||
|
||||
## fmt: Formate le code Rust
|
||||
fmt:
|
||||
@echo "$(YELLOW)→ Formatage du code...$(NC)"
|
||||
$(CARGO) fmt --all
|
||||
@echo "$(GREEN)✓ Code formaté$(NC)"
|
||||
|
||||
## fmt-check: Vérifie le formatage sans modifier
|
||||
fmt-check:
|
||||
@echo "$(YELLOW)→ Vérification du formatage...$(NC)"
|
||||
$(CARGO) fmt --all -- --check
|
||||
|
||||
## clippy: Exécute clippy (linter Rust)
|
||||
clippy:
|
||||
@echo "$(YELLOW)→ Analyse avec clippy...$(NC)"
|
||||
$(CARGO) clippy --all-targets --all-features -- -D warnings
|
||||
@echo "$(GREEN)✓ Analyse clippy terminée$(NC)"
|
||||
|
||||
## watch: Recompile automatiquement à chaque changement
|
||||
watch:
|
||||
@echo "$(YELLOW)→ Mode watch activé...$(NC)"
|
||||
$(CARGO) watch -x check
|
||||
|
||||
## ci: Exécute toutes les vérifications CI
|
||||
ci: fmt-check clippy test doc-build webapp
|
||||
@echo "$(GREEN)✓ Toutes les vérifications CI passées$(NC)"
|
||||
|
||||
## run: Lance le binaire en mode debug
|
||||
run: debug
|
||||
@echo "$(YELLOW)→ Lancement de l'application...$(NC)"
|
||||
./target/debug/$(BINARY_NAME)
|
||||
|
||||
## run-release: Lance le binaire en mode release
|
||||
run-release: release
|
||||
@echo "$(YELLOW)→ Lancement de l'application (release)...$(NC)"
|
||||
./$(RUST_TARGET)/$(BINARY_NAME)
|
||||
|
||||
## size: Affiche la taille du binaire
|
||||
size:
|
||||
@echo "$(YELLOW)Taille des binaires :$(NC)"
|
||||
@if [ -f "target/debug/$(BINARY_NAME)" ]; then \
|
||||
echo " Debug: $$(du -h target/debug/$(BINARY_NAME) | cut -f1)"; \
|
||||
fi
|
||||
@if [ -f "$(RUST_TARGET)/$(BINARY_NAME)" ]; then \
|
||||
echo " Release: $$(du -h $(RUST_TARGET)/$(BINARY_NAME) | cut -f1)"; \
|
||||
fi
|
||||
|
||||
## deps: Liste les dépendances obsolètes
|
||||
deps:
|
||||
@echo "$(YELLOW)→ Vérification des dépendances...$(NC)"
|
||||
$(CARGO) outdated
|
||||
|
||||
## update: Met à jour les dépendances
|
||||
update:
|
||||
@echo "$(YELLOW)→ Mise à jour des dépendances Rust...$(NC)"
|
||||
$(CARGO) update
|
||||
@echo "$(YELLOW)→ Mise à jour des dépendances npm...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) update
|
||||
@echo "$(GREEN)✓ Dépendances mises à jour$(NC)"
|
||||
|
||||
## bench: Exécute les benchmarks
|
||||
bench:
|
||||
@echo "$(YELLOW)→ Exécution des benchmarks...$(NC)"
|
||||
$(CARGO) bench
|
||||
|
||||
## coverage: Génère un rapport de couverture de code
|
||||
coverage:
|
||||
@echo "$(YELLOW)→ Génération du rapport de couverture...$(NC)"
|
||||
$(CARGO) tarpaulin --out Html --output-dir target/coverage
|
||||
@echo "$(GREEN)✓ Rapport disponible dans target/coverage/index.html$(NC)"
|
||||
|
||||
18
PMOMusic/Cargo.toml
Normal file
18
PMOMusic/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
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"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.20"
|
||||
axum = "0.8.4"
|
||||
serde_json = "1.0.145"
|
||||
70
PMOMusic/src/main.rs
Normal file
70
PMOMusic/src/main.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use pmoupnp::{
|
||||
mediarenderer::MEDIA_RENDERER,
|
||||
ssdp::SsdpServer,
|
||||
UpnpServer,
|
||||
UpnpModel,
|
||||
};
|
||||
use pmoserver::{
|
||||
logs::LoggingOptions,
|
||||
ServerBuilder
|
||||
};
|
||||
use pmoapp::{Webapp, WebAppExt};
|
||||
use pmocovers::CoverCacheExt;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Créer le serveur
|
||||
let mut server = ServerBuilder::new_configured().build();
|
||||
|
||||
// Initialiser le logging et enregistrer les routes de logs
|
||||
server.init_logging(LoggingOptions::default()).await;
|
||||
|
||||
|
||||
info!("📡 Registering the cover cache...");
|
||||
let cache = server.init_cover_cache_configured()
|
||||
.await
|
||||
.expect("Cannot initialise the image cache");
|
||||
|
||||
info!("✅ Cover cache ready at {}",
|
||||
cache.cache_dir(),
|
||||
);
|
||||
|
||||
|
||||
|
||||
// Routes de base
|
||||
server
|
||||
.add_route("/info", || async {
|
||||
serde_json::json!({"version": "1.0.0"})
|
||||
})
|
||||
.await;
|
||||
|
||||
|
||||
// Ajouter la webapp via le trait WebAppExt
|
||||
info!("📡 Registering Web application...");
|
||||
server.add_webapp_with_redirect::<Webapp>("/app").await;
|
||||
|
||||
info!("📡 Registering MediaRenderer...");
|
||||
let renderer_instance = server.register_device(MEDIA_RENDERER.clone())
|
||||
.await
|
||||
.expect("Failed to register MediaRenderer routes");
|
||||
|
||||
info!("✅ MediaRenderer ready at {}{}",
|
||||
renderer_instance.base_url(),
|
||||
renderer_instance.description_route()
|
||||
);
|
||||
|
||||
// Créer et démarrer le serveur SSDP
|
||||
info!("📡 Starting SSDP discovery...");
|
||||
let mut ssdp_server = SsdpServer::new();
|
||||
ssdp_server.start().expect("Failed to start SSDP server");
|
||||
|
||||
// Créer et enregistrer le device SSDP pour le MediaRenderer
|
||||
let ssdp_device = renderer_instance
|
||||
.to_ssdp_device("PMOMusic", "1.0");
|
||||
ssdp_server.add_device(ssdp_device);
|
||||
info!("✅ SSDP announcements sent for MediaRenderer");
|
||||
|
||||
server.start().await;
|
||||
server.wait().await;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
|
||||
## Installing documentation system Doc-Gen
|
||||
|
||||
https://github.com/fynnfluegge/doc-comments-ai
|
||||
|
||||
|
||||
for f in upnp/*.go ; do
|
||||
dcai/bin/aicomment --ollama-model deepseek-coder:33b-instruct $f
|
||||
done
|
||||
100
Readme.md
Normal file
100
Readme.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Développement de l'application PMOMusic en RUST
|
||||
|
||||
## Création de la structure
|
||||
|
||||
```bash
|
||||
mkdir pizzicato
|
||||
cd pizzicato
|
||||
jj git init
|
||||
touch Readme.md
|
||||
```
|
||||
|
||||
Maintenant on peu créer l'application PMOMusic
|
||||
|
||||
```bash
|
||||
cargo new PMOMusic
|
||||
```
|
||||
|
||||
On ajoute un fichier `Cargo.toml` décrivant le workspace pizzicato qui ne contient que notre nouvelle application
|
||||
|
||||
```
|
||||
[workspace]
|
||||
members = ["PMOMusic"]
|
||||
```
|
||||
|
||||
On crée le package `pmoupnp`
|
||||
|
||||
```bash
|
||||
cargo new pmoupnp --lib
|
||||
```
|
||||
|
||||
Cela modifie automatiquement le fichier `Cargo.toml` créé juste avant.
|
||||
Dans ce package un sous module `statevariable`
|
||||
|
||||
```bash
|
||||
cd pmoupnp/src
|
||||
mkdir statevariable
|
||||
```
|
||||
|
||||
|
||||
# Petite expériences jujutsu
|
||||
|
||||
- Je veux voir l'historique
|
||||
|
||||
```bash
|
||||
jj log
|
||||
````
|
||||
```
|
||||
@ lkmlpmnk eric@coissac.eu 2025-09-12 09:48:38 a27b6a9a
|
||||
│ On commence les states variables
|
||||
○ mzvokmpk eric@coissac.eu 2025-09-12 09:32:27 926827f3
|
||||
│ Retire le répertoir target du suivi
|
||||
○ skknrvut eric@coissac.eu 2025-09-12 09:05:27 cbad5c34
|
||||
│ Initialisation des l'arborescence de répertoires
|
||||
◆ zzzzzzzz root() 00000000
|
||||
```
|
||||
|
||||
- Je veux me mettre dans un commit:
|
||||
|
||||
```bash
|
||||
jj edit mzvokmpk
|
||||
```
|
||||
|
||||
je veux créer un nouveau commit à la suite d'un autre et m'y placer
|
||||
|
||||
```bash
|
||||
jj new mzvokmpk
|
||||
```
|
||||
|
||||
`mzvokmpk` peut être `@` pour dans le commit courant ou `@-` pour dans le parent
|
||||
|
||||
- je veux arreter de suivre
|
||||
- un fichier
|
||||
|
||||
```bash
|
||||
jj file untrack <filename>
|
||||
```
|
||||
|
||||
- un répertoire
|
||||
|
||||
```bash
|
||||
find dirname -type f -exec jj file untrack {} \;
|
||||
```
|
||||
|
||||
Dans tous les cas ne pas oublier d'inscrire le fichier ou le repertoire dans le `.gitignore`
|
||||
|
||||
- je veux déplacer un commit comme un sous commit d'un aute
|
||||
|
||||
```bash
|
||||
jj rebase -d destination -r source
|
||||
```
|
||||
|
||||
eventuellement faire un
|
||||
|
||||
```bash
|
||||
jj resolve --all
|
||||
jj rebase --continue
|
||||
```
|
||||
|
||||
pour résoudre les conflits
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/mediarenderer"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
ctx, stop := signal.NotifyContext(
|
||||
context.Background(),
|
||||
syscall.SIGINT,
|
||||
syscall.SIGTERM,
|
||||
)
|
||||
defer stop()
|
||||
|
||||
// Crée le serveur avec baseURL auto-déduite depuis l’IP locale
|
||||
server := upnp.NewServer("pmomusic")
|
||||
|
||||
server.RegisterDevice("", mediarenderer.FakeRenderer)
|
||||
|
||||
if err := server.Run(ctx); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
}
|
||||
BIN
db/chroma.sqlite3
Normal file
BIN
db/chroma.sqlite3
Normal file
Binary file not shown.
3168
deps.svg
Normal file
3168
deps.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 182 KiB |
129
didl/markdown.go
129
didl/markdown.go
@@ -1,129 +0,0 @@
|
||||
package didl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (d *DIDLLite) ToMarkdown() string {
|
||||
var buf strings.Builder
|
||||
buf.WriteString("# DIDL-Lite Document\n\n")
|
||||
|
||||
if len(d.Containers) > 0 {
|
||||
buf.WriteString("## Containers\n\n")
|
||||
for _, c := range d.Containers {
|
||||
c.markdown(&buf, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if len(d.Items) > 0 {
|
||||
buf.WriteString("## Items\n\n")
|
||||
for _, i := range d.Items {
|
||||
i.markdown(&buf, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (c *Container) markdown(buf *strings.Builder, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
|
||||
buf.WriteString(fmt.Sprintf("%s- **Container**: %s\n", indent, c.Title))
|
||||
buf.WriteString(fmt.Sprintf("%s - ID: `%s`\n", indent, c.ID))
|
||||
buf.WriteString(fmt.Sprintf("%s - ParentID: `%s`\n", indent, c.ParentID))
|
||||
buf.WriteString(fmt.Sprintf("%s - Class: `%s`\n", indent, c.Class))
|
||||
|
||||
if c.Restricted != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Restricted: `%s`\n", indent, c.Restricted))
|
||||
}
|
||||
if c.ChildCount != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - ChildCount: `%s`\n", indent, c.ChildCount))
|
||||
}
|
||||
|
||||
// Sous-conteneurs
|
||||
if len(c.Containers) > 0 {
|
||||
buf.WriteString(fmt.Sprintf("%s - Subcontainers:\n", indent))
|
||||
for _, sub := range c.Containers {
|
||||
sub.markdown(buf, depth+2)
|
||||
}
|
||||
}
|
||||
|
||||
// Items du conteneur
|
||||
if len(c.Items) > 0 {
|
||||
buf.WriteString(fmt.Sprintf("%s - Items:\n", indent))
|
||||
for _, item := range c.Items {
|
||||
item.markdown(buf, depth+2)
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
func (i *Item) markdown(buf *strings.Builder, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
|
||||
buf.WriteString(fmt.Sprintf("%s- **Item**: %s\n", indent, i.Title))
|
||||
buf.WriteString(fmt.Sprintf("%s - ID: `%s`\n", indent, i.ID))
|
||||
buf.WriteString(fmt.Sprintf("%s - ParentID: `%s`\n", indent, i.ParentID))
|
||||
buf.WriteString(fmt.Sprintf("%s - Class: `%s`\n", indent, i.Class))
|
||||
|
||||
if i.Creator != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Creator: %s\n", indent, i.Creator))
|
||||
}
|
||||
if i.Artist != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Artist: %s\n", indent, i.Artist))
|
||||
}
|
||||
if i.Album != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Album: %s\n", indent, i.Album))
|
||||
}
|
||||
if i.Genre != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Genre: %s\n", indent, i.Genre))
|
||||
}
|
||||
if i.AlbumArt != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Album Art: \n", indent, i.AlbumArt))
|
||||
}
|
||||
if i.Date != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Date: %s\n", indent, i.Date))
|
||||
}
|
||||
if i.OriginalTrackNumber != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Track: %s\n", indent, i.OriginalTrackNumber))
|
||||
}
|
||||
|
||||
// Ressources
|
||||
if len(i.Ress) > 0 {
|
||||
buf.WriteString(fmt.Sprintf("%s - Resources:\n", indent))
|
||||
for _, res := range i.Ress {
|
||||
buf.WriteString(fmt.Sprintf("%s - URL: %s\n", indent, res.URL))
|
||||
buf.WriteString(fmt.Sprintf("%s - Protocol: `%s`\n", indent, res.ProtocolInfo))
|
||||
if res.Duration != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Duration: `%s`\n", indent, res.Duration))
|
||||
}
|
||||
if res.BitsPerSample != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - BitsPerSample: `%s`\n", indent, res.BitsPerSample))
|
||||
}
|
||||
if res.SampleFrequency != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - SampleFrequency: `%s`\n", indent, res.SampleFrequency))
|
||||
}
|
||||
if res.NrAudioChannels != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Channels: `%s`\n", indent, res.NrAudioChannels))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Descriptions
|
||||
if len(i.Descs) > 0 {
|
||||
buf.WriteString(fmt.Sprintf("%s - Descriptions:\n", indent))
|
||||
for _, desc := range i.Descs {
|
||||
buf.WriteString(fmt.Sprintf("%s - Namespace: `%s`\n", indent, desc.NameSpace))
|
||||
if desc.TrackGain != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Track Gain: `%s`\n", indent, desc.TrackGain))
|
||||
}
|
||||
if desc.TrackPeak != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s - Track Peak: `%s`\n", indent, desc.TrackPeak))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package didl
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// DIDLLite représente la racine <DIDL-Lite>
|
||||
type DIDLLite struct {
|
||||
XMLName xml.Name `xml:"DIDL-Lite"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
XmlnsUpnp string `xml:"xmlns:upnp,attr,omitempty"`
|
||||
XmlnsDc string `xml:"xmlns:dc,attr,omitempty"`
|
||||
XmlnsDlna string `xml:"xmlns:dlna,attr,omitempty"`
|
||||
XmlnsSec string `xml:"xmlns:sec,attr,omitempty"`
|
||||
XmlnsPv string `xml:"xmlns:pv,attr,omitempty"`
|
||||
Containers []Container `xml:"container"`
|
||||
Items []Item `xml:"item"`
|
||||
}
|
||||
|
||||
// Container peut contenir d'autres containers ou des items audio
|
||||
type Container struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ParentID string `xml:"parentID,attr"`
|
||||
Restricted string `xml:"restricted,attr,omitempty"`
|
||||
ChildCount string `xml:"childCount,attr,omitempty"`
|
||||
Title string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||
Class string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ class"`
|
||||
Containers []Container `xml:"container"`
|
||||
Items []Item `xml:"item"`
|
||||
}
|
||||
|
||||
// Item représente un objet audio
|
||||
type Item struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ParentID string `xml:"parentID,attr"`
|
||||
Restricted string `xml:"restricted,attr,omitempty"`
|
||||
|
||||
Title string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||
Creator string `xml:"http://purl.org/dc/elements/1.1/ creator,omitempty"`
|
||||
Class string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ class"`
|
||||
Artist string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ artist,omitempty"`
|
||||
Album string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ album,omitempty"`
|
||||
Genre string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ genre,omitempty"`
|
||||
AlbumArt string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ albumArtURI,omitempty"`
|
||||
Date string `xml:"http://purl.org/dc/elements/1.1/ date,omitempty"`
|
||||
OriginalTrackNumber string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ originalTrackNumber,omitempty"`
|
||||
|
||||
Ress []Res `xml:"res"`
|
||||
Descs []Desc `xml:"desc"`
|
||||
}
|
||||
|
||||
// Res correspond aux fichiers média
|
||||
type Res struct {
|
||||
ProtocolInfo string `xml:"protocolInfo,attr"`
|
||||
BitsPerSample string `xml:"bitsPerSample,attr,omitempty"`
|
||||
SampleFrequency string `xml:"sampleFrequency,attr,omitempty"`
|
||||
NrAudioChannels string `xml:"nrAudioChannels,attr,omitempty"`
|
||||
Duration string `xml:"duration,attr,omitempty"`
|
||||
URL string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// Desc correspond aux métadonnées optionnelles comme replaygain
|
||||
type Desc struct {
|
||||
ID string `xml:"id,attr,omitempty"`
|
||||
NameSpace string `xml:"nameSpace,attr,omitempty"`
|
||||
TrackGain string `xml:"track_gain,omitempty"`
|
||||
TrackPeak string `xml:"track_peak,omitempty"`
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package didl
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func Parse(metadata string) (*DIDLLite, error) {
|
||||
var didl DIDLLite
|
||||
err := xml.Unmarshal([]byte(metadata), &didl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse DIDL-Lite: %v", err)
|
||||
|
||||
}
|
||||
|
||||
return &didl, nil
|
||||
}
|
||||
46
doc/traits_object.md
Normal file
46
doc/traits_object.md
Normal file
@@ -0,0 +1,46 @@
|
||||
```mermaid
|
||||
graph TB
|
||||
Clone[Clone<br/><i>std trait</i>]:::stdTrait
|
||||
Debug[Debug<br/><i>std trait</i>]:::stdTrait
|
||||
|
||||
UpnpDeepClone[UpnpDeepClone<br/>deep_clone]:::baseTrait
|
||||
|
||||
UpnpObject[UpnpObject<br/>to_xml_element<br/>to_xml<br/>to_markdown]:::baseTrait
|
||||
|
||||
UpnpModel[UpnpModel<br/>create_instance]:::derived1
|
||||
UpnpInstance[UpnpInstance<br/>new]:::derived1
|
||||
UpnpTyped[UpnpTyped<br/>get_name<br/>get_object_type]:::derived1
|
||||
UpnpSet[UpnpSet<br/>is_set]:::derived1
|
||||
|
||||
UpnpTypedObject[UpnpTypedObject<br/><i>marker</i>]:::derived2
|
||||
|
||||
UpnpTypedInstance[UpnpTypedInstance<br/><i>marker</i>]:::derived3
|
||||
UpnpModelSet[UpnpModelSet<br/><i>marker</i>]:::derived3
|
||||
UpnInstanceSet[UpnInstanceSet<br/><i>marker</i>]:::derived3
|
||||
|
||||
Clone --> UpnpObject
|
||||
Debug --> UpnpObject
|
||||
|
||||
UpnpObject --> UpnpModel
|
||||
UpnpObject --> UpnpInstance
|
||||
UpnpObject --> UpnpTyped
|
||||
UpnpObject --> UpnpSet
|
||||
|
||||
UpnpObject --> UpnpTypedObject
|
||||
UpnpTyped --> UpnpTypedObject
|
||||
|
||||
UpnpTypedObject --> UpnpTypedInstance
|
||||
UpnpInstance --> UpnpTypedInstance
|
||||
|
||||
UpnpSet --> UpnpModelSet
|
||||
UpnpModel --> UpnpModelSet
|
||||
|
||||
UpnpSet --> UpnInstanceSet
|
||||
UpnpInstance --> UpnInstanceSet
|
||||
|
||||
classDef stdTrait fill:#e1f5ff,stroke:#01579b,stroke-width:2px
|
||||
classDef baseTrait fill:#fff3e0,stroke:#e65100,stroke-width:2px
|
||||
classDef derived1 fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
|
||||
classDef derived2 fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px
|
||||
classDef derived3 fill:#fce4ec,stroke:#880e4f,stroke-width:2px
|
||||
```
|
||||
103
doc/webapp.md
Normal file
103
doc/webapp.md
Normal file
@@ -0,0 +1,103 @@
|
||||
Oui exactement, tu peux clairement séparer ton **backend Go** (PMO logger, serveur UPnP, etc.) de la **web app** dans un nouveau package, par exemple `pmoapp`, et l’embarquer via `embed`. C’est d’ailleurs la meilleure pratique si tu envisages une **application web complète et évolutive**, avec plusieurs pages, routes, et fonctionnalités.
|
||||
|
||||
Voici comment je te conseille de procéder et pourquoi :
|
||||
|
||||
---
|
||||
|
||||
## **1️⃣ Création du package web indépendant**
|
||||
|
||||
```bash
|
||||
mkdir pmoapp
|
||||
cd pmoapp
|
||||
npm create vite@latest web
|
||||
```
|
||||
|
||||
* **Nom du projet** : `web`
|
||||
* **Framework** : React (on garde ton choix initial, populaire et bien documenté pour des interfaces complexes)
|
||||
|
||||
### **Pourquoi React** :
|
||||
|
||||
* Large écosystème (bibliothèques UI, state management, routing)
|
||||
* Réactivité naturelle pour les flux temps réel (comme SSE)
|
||||
* Compatible avec TypeScript, ce qui est pratique si ton projet Go devient complexe et que tu veux des typings fiables.
|
||||
|
||||
### **Choix du langage**
|
||||
|
||||
Les options que tu as :
|
||||
|
||||
| Option | Avantages | Quand la choisir |
|
||||
| --------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| **TypeScript** (recommandé) | Typage statique, moins d’erreurs runtime, meilleur pour projet long terme | Quand tu commences un projet sérieux et que tu veux scalabilité et sécurité |
|
||||
| TypeScript + SWC | SWC compile plus vite que Vite/TS, utile pour gros projets | Si tu vises build ultra-rapide et dev rapide |
|
||||
| JavaScript | Simple, pas de compilation typée | Pour prototypage rapide ou projet temporaire |
|
||||
| JavaScript + SWC | JS mais compilation rapide | Peu utilisé, peu d’intérêt si tu ne fais pas du JS lourd |
|
||||
|
||||
**✅ Recommandation : TypeScript classique**
|
||||
|
||||
* Tu as un projet qui va évoluer et peut devenir complexe
|
||||
* Les types aident à maintenir la cohérence avec le backend Go (ex : typage des logs, props des composants, etc.)
|
||||
* Tu n’as pas besoin de SWC pour l’instant, Vite est déjà très rapide pour dev et build
|
||||
|
||||
---
|
||||
|
||||
## **2️⃣ Ajouter un build statique pour embed**
|
||||
|
||||
Dans ton Go package `pmoapp`, crée un fichier `web.go` :
|
||||
|
||||
```go
|
||||
package pmoapp
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed web/dist/*
|
||||
var AppRoot embed.FS
|
||||
|
||||
// Expose static files
|
||||
func Handler(mux *http.ServeMux) {
|
||||
fsys, _ := fs.Sub(AppRoot, "web/dist")
|
||||
mux.Handle("/app/", http.StripPrefix("/app/", http.FileServer(http.FS(fsys))))
|
||||
mux.HandleFunc("/app", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "web/dist/index.html")
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Pourquoi :**
|
||||
|
||||
* `/app` → sert l’entrée de l’application (`index.html`)
|
||||
* `/app/` → sert tous les assets statiques générés par Vite (`dist/`)
|
||||
* Avec `embed.FS`, ton build React est complètement **embarqué dans le binaire Go**, tu n’as pas besoin de Node en production.
|
||||
|
||||
---
|
||||
|
||||
## **3️⃣ Workflow futur**
|
||||
|
||||
1. Développer le frontend dans `pmoapp/web` avec React/TypeScript.
|
||||
2. À chaque modification :
|
||||
|
||||
```bash
|
||||
cd pmoapp/web
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. Ton backend Go peut **réutiliser la même variable `AppRoot`** pour servir les pages.
|
||||
|
||||
Tu pourras ensuite :
|
||||
|
||||
* Ajouter des routes React (`react-router-dom`)
|
||||
* Ajouter des pages dynamiques
|
||||
* Utiliser des composants pour logs, dashboard, configuration, etc.
|
||||
|
||||
---
|
||||
|
||||
Si tu veux, je peux te préparer **l’arborescence complète recommandée pour ton projet Go + React**, avec :
|
||||
|
||||
* `pmolog` pour le logger SSE
|
||||
* `pmoapp` pour la web app React
|
||||
* Embedding automatique du build Vite dans le binaire Go
|
||||
|
||||
Veux‑tu que je fasse ça ?
|
||||
@@ -1,27 +0,0 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func IsWriteable(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err == nil {
|
||||
// File exists, check owner write permission
|
||||
return info.Mode().Perm()&0200 != 0
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
// File does not exist, check if parent directory is writable
|
||||
dir := filepath.Dir(path)
|
||||
if dir == "" {
|
||||
dir = "." // fallback
|
||||
}
|
||||
dirInfo, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return dirInfo.IsDir() && dirInfo.Mode().Perm()&0200 != 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
15
go.mod
15
go.mod
@@ -1,15 +0,0 @@
|
||||
module gargoton.petite-maison-orange.fr/eric/pmomusic
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/beevik/etree v1.5.1
|
||||
github.com/globusdigital/soap v1.4.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
24
go.sum
24
go.sum
@@ -1,24 +0,0 @@
|
||||
github.com/beevik/etree v1.5.1 h1:TC3zyxYp+81wAmbsi8SWUpZCurbxa6S8RITYRSkNRwo=
|
||||
github.com/beevik/etree v1.5.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/globusdigital/soap v1.4.0 h1:nQDpWelZr3zKg6Oe1e5SqWd4PfiUvsl5/44oWUXy3II=
|
||||
github.com/globusdigital/soap v1.4.0/go.mod h1:p8hjOZ4FmK0jXBTcIZ6e5M2QBfcsxBUKWBYsHM2eZRw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,17 +0,0 @@
|
||||
package netutils
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
// Fonction helper (remplace votre netutils.GuessLocalIP)
|
||||
func GuessLocalIP() (string, error) {
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err != nil {
|
||||
return "127.0.0.1", nil
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
localAddr := conn.LocalAddr().(*net.UDPAddr)
|
||||
return localAddr.IP.String(), nil
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package netutils
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
// ListAllIPs returns a map of interface names to their associated IPv4 addresses.
|
||||
func ListAllIPs() map[string][]string {
|
||||
result := make(map[string][]string)
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
result["error"] = []string{err.Error()}
|
||||
return result
|
||||
}
|
||||
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue // Ignore down interfaces
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var ips []string
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
|
||||
if ip == nil || ip.To4() == nil || ip.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
ips = append(ips, ip.String())
|
||||
}
|
||||
|
||||
if len(ips) > 0 {
|
||||
result[iface.Name] = ips
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
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;
|
||||
})
|
||||
}
|
||||
}
|
||||
23
pmoapp/webapp/.gitignore
vendored
Normal file
23
pmoapp/webapp/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
pmoapp/webapp/.vscode/extensions.json
vendored
Normal file
3
pmoapp/webapp/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
pmoapp/webapp/README.md
Normal file
5
pmoapp/webapp/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
13
pmoapp/webapp/index.html
Normal file
13
pmoapp/webapp/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webapp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1486
pmoapp/webapp/package-lock.json
generated
Normal file
1486
pmoapp/webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
pmoapp/webapp/package.json
Normal file
25
pmoapp/webapp/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "webapp",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.7",
|
||||
"marked": "^16.3.0",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.7",
|
||||
"vue-tsc": "^3.0.7"
|
||||
}
|
||||
}
|
||||
1
pmoapp/webapp/public/vite.svg
Normal file
1
pmoapp/webapp/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
30
pmoapp/webapp/src/App.vue
Normal file
30
pmoapp/webapp/src/App.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav>
|
||||
<router-link to="/">Accueil</router-link> |
|
||||
<router-link to="/logs">Logs</router-link> |
|
||||
<router-link to="/covers-cache">Cover Cache</router-link>
|
||||
</nav>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// rien à importer
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
nav {
|
||||
background: #333;
|
||||
width: 100vw;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
a {
|
||||
color: #eee;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
a.router-link-active {
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
1
pmoapp/webapp/src/assets/vue.svg
Normal file
1
pmoapp/webapp/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
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>
|
||||
41
pmoapp/webapp/src/components/HelloWorld.vue
Normal file
41
pmoapp/webapp/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
641
pmoapp/webapp/src/components/LogView.vue
Normal file
641
pmoapp/webapp/src/components/LogView.vue
Normal file
@@ -0,0 +1,641 @@
|
||||
<template>
|
||||
<div class="log-viewer">
|
||||
<div class="header">
|
||||
<h2>📋 System Logs</h2>
|
||||
<div class="controls">
|
||||
<button @click="toggleAutoScroll" :class="{ active: autoScroll }">
|
||||
{{ autoScroll ? '📌 Auto-scroll ON' : '📌 Auto-scroll OFF' }}
|
||||
</button>
|
||||
<button @click="clearLogs">🗑️ Clear</button>
|
||||
<select v-model="levelFilter" class="filter">
|
||||
<option value="ALL">All Levels</option>
|
||||
<option value="TRACE">TRACE</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARN">WARN</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-container" ref="logContainer">
|
||||
<div
|
||||
v-for="(log, index) in filteredLogs"
|
||||
:key="index"
|
||||
:class="['log-entry', `level-${log.level.toLowerCase()}`, { 'is-history': log.isHistory }]"
|
||||
>
|
||||
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
|
||||
<span class="level">{{ log.level }}</span>
|
||||
<span class="target">{{ log.target }}</span>
|
||||
<span class="message markdown-content" v-html="renderMarkdown(log.message)"></span>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingHistory" class="loading-state">
|
||||
⏳ Loading history...
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredLogs.length === 0" class="empty-state">
|
||||
{{ isConnected ? 'Waiting for logs...' : 'Connecting to log stream...' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span :class="['status', { connected: isConnected }]">
|
||||
{{ isConnected ? '🟢 Connected' : '🔴 Disconnected' }}
|
||||
</span>
|
||||
<span class="count">{{ filteredLogs.length }} logs</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
// Configurer marked pour un rendu inline simple
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
const logs = ref([])
|
||||
const autoScroll = ref(true)
|
||||
const isConnected = ref(false)
|
||||
const isLoadingHistory = ref(true)
|
||||
const levelFilter = ref('ALL')
|
||||
const logContainer = ref(null)
|
||||
let eventSource = null
|
||||
let historyLoaded = false
|
||||
const seenLogIds = new Set() // Pour détecter les duplicatas
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
if (levelFilter.value === 'ALL') {
|
||||
return logs.value
|
||||
}
|
||||
return logs.value.filter(log => log.level === levelFilter.value)
|
||||
})
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp.secs_since_epoch * 1000)
|
||||
return date.toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3
|
||||
})
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
// ÉTAPE 1 : Pré-processing pour détecter et protéger le XML
|
||||
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, {
|
||||
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'class']
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll.value = !autoScroll.value
|
||||
if (autoScroll.value) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = []
|
||||
seenLogIds.clear()
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!logContainer.value || !autoScroll.value) return
|
||||
nextTick(() => {
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
// Ajuste l'URL selon ton setup
|
||||
const baseUrl = window.location.origin
|
||||
eventSource = new EventSource(`${baseUrl}/log-sse`)
|
||||
|
||||
eventSource.onopen = () => {
|
||||
isConnected.value = true
|
||||
console.log('SSE connection opened')
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const logEntry = JSON.parse(event.data)
|
||||
|
||||
// Créer un ID unique basé sur timestamp + message + target
|
||||
const logId = `${logEntry.timestamp.secs_since_epoch}-${logEntry.timestamp.nanos_since_epoch}-${logEntry.message}-${logEntry.target}`
|
||||
|
||||
// Ignorer les duplicatas
|
||||
if (seenLogIds.has(logId)) {
|
||||
return
|
||||
}
|
||||
seenLogIds.add(logId)
|
||||
|
||||
// Marquer les logs historiques
|
||||
if (!historyLoaded) {
|
||||
logEntry.isHistory = true
|
||||
}
|
||||
|
||||
logs.value.push(logEntry)
|
||||
|
||||
// Limiter à 1000 logs en mémoire
|
||||
if (logs.value.length > 1000) {
|
||||
const removed = logs.value.shift()
|
||||
// Nettoyer aussi le Set pour éviter qu'il grandisse indéfiniment
|
||||
const removedId = `${removed.timestamp.secs_since_epoch}-${removed.timestamp.nanos_since_epoch}-${removed.message}-${removed.target}`
|
||||
seenLogIds.delete(removedId)
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
} catch (error) {
|
||||
console.error('Failed to parse log entry:', error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
isConnected.value = false
|
||||
isLoadingHistory.value = false
|
||||
console.error('SSE connection error')
|
||||
|
||||
// Reconnexion automatique après 3 secondes
|
||||
setTimeout(() => {
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
historyLoaded = false
|
||||
connectSSE()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// Détecter la fin du chargement de l'historique
|
||||
// (on considère qu'après 500ms sans log, l'historique est chargé)
|
||||
let historyTimeout
|
||||
const originalOnMessage = eventSource.onmessage
|
||||
eventSource.onmessage = (event) => {
|
||||
clearTimeout(historyTimeout)
|
||||
originalOnMessage(event)
|
||||
|
||||
if (!historyLoaded) {
|
||||
historyTimeout = setTimeout(() => {
|
||||
historyLoaded = true
|
||||
isLoadingHistory.value = false
|
||||
console.log('History loaded, now streaming live logs')
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connectSSE()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
}
|
||||
})
|
||||
|
||||
// Désactiver auto-scroll si l'utilisateur scroll manuellement
|
||||
watch(logContainer, (container) => {
|
||||
if (!container) return
|
||||
|
||||
container.addEventListener('scroll', () => {
|
||||
const isAtBottom =
|
||||
container.scrollHeight - container.scrollTop <= container.clientHeight + 50
|
||||
|
||||
if (!isAtBottom && autoScroll.value) {
|
||||
autoScroll.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 80vh;
|
||||
width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3c3c3c;
|
||||
color: #d4d4d4;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
button {
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #505050;
|
||||
}
|
||||
|
||||
button.active {
|
||||
background: #0e639c;
|
||||
border-color: #1177bb;
|
||||
}
|
||||
|
||||
.filter {
|
||||
padding: 0.5rem;
|
||||
background: #3c3c3c;
|
||||
color: #d4d4d4;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter {
|
||||
padding: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.log-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 80px 200px 1fr;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-left: 3px solid transparent;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.log-entry {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.3rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
border-left-width: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
.log-entry.is-history {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #858585;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timestamp {
|
||||
font-size: 0.75rem;
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.level {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.level {
|
||||
order: 2;
|
||||
width: fit-content;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.target {
|
||||
color: #4ec9b0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.target {
|
||||
order: 3;
|
||||
font-size: 0.8rem;
|
||||
color: #6eb8a5;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #d4d4d4;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.message {
|
||||
order: 4;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code) {
|
||||
background: #3c3c3c;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.85em;
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
.markdown-content :deep(pre) {
|
||||
background: #2d2d30;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid #3e3e42;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.markdown-content :deep(pre code) {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
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) {
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.markdown-content :deep(em) {
|
||||
color: #dcdcaa;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-content :deep(a) {
|
||||
color: #569cd6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-content :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content :deep(p) {
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul),
|
||||
.markdown-content :deep(ol) {
|
||||
margin: 0.25rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
/* Level colors */
|
||||
.level-trace {
|
||||
border-left-color: #808080;
|
||||
}
|
||||
|
||||
.level-trace .level {
|
||||
background: #3a3a3a;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.level-debug {
|
||||
border-left-color: #569cd6;
|
||||
}
|
||||
|
||||
.level-debug .level {
|
||||
background: #1e3a5f;
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.level-info {
|
||||
border-left-color: #4ec9b0;
|
||||
}
|
||||
|
||||
.level-info .level {
|
||||
background: #1e4d42;
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.level-warn {
|
||||
border-left-color: #dcdcaa;
|
||||
}
|
||||
|
||||
.level-warn .level {
|
||||
background: #4d4d2a;
|
||||
color: #dcdcaa;
|
||||
}
|
||||
|
||||
.level-error {
|
||||
border-left-color: #f48771;
|
||||
}
|
||||
|
||||
.level-error .level {
|
||||
background: #5a1e1e;
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #858585;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #569cd6;
|
||||
font-size: 1.1rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #252526;
|
||||
border-top: 1px solid #3e3e42;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.log-container::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
</style>
|
||||
7
pmoapp/webapp/src/main.ts
Normal file
7
pmoapp/webapp/src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
18
pmoapp/webapp/src/router/index.ts
Normal file
18
pmoapp/webapp/src/router/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HelloWorld from "../components/HelloWorld.vue";
|
||||
import LogView from "../components/LogView.vue";
|
||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: HelloWorld },
|
||||
{ path: "/logs", name: "logs", component: LogView },
|
||||
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
// history avec base /app
|
||||
history: createWebHistory("/app"),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
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}`;
|
||||
}
|
||||
5
pmoapp/webapp/src/shims-vue.d.ts
vendored
Normal file
5
pmoapp/webapp/src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module "*.vue" {
|
||||
import { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
80
pmoapp/webapp/src/style.css
Normal file
80
pmoapp/webapp/src/style.css
Normal file
@@ -0,0 +1,80 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
16
pmoapp/webapp/tsconfig.app.json
Normal file
16
pmoapp/webapp/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
pmoapp/webapp/tsconfig.json
Normal file
7
pmoapp/webapp/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
pmoapp/webapp/tsconfig.node.json
Normal file
26
pmoapp/webapp/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": [],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
8
pmoapp/webapp/vite.config.ts
Normal file
8
pmoapp/webapp/vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/app/', // Base path pour le déploiement
|
||||
})
|
||||
1
pmoconfig/.gitignore
vendored
Normal file
1
pmoconfig/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
17
pmoconfig/Cargo.toml
Normal file
17
pmoconfig/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
[package]
|
||||
name = "pmoconfig"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
pmoutils ={ path = "../pmoutils" }
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9.33"
|
||||
lazy_static = "1.4.0"
|
||||
dirs = "6.0.0"
|
||||
log = "0.4.20"
|
||||
anyhow = "1.0.75"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
tracing = "0.1.41"
|
||||
341
pmoconfig/src/lib.rs
Normal file
341
pmoconfig/src/lib.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use dirs::home_dir;
|
||||
use lazy_static::lazy_static;
|
||||
use pmoutils::guess_local_ip;
|
||||
use serde_yaml::{Mapping, Value};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
// Configuration par défaut intégrée
|
||||
const DEFAULT_CONFIG: &str = include_str!("pmomusic.yaml");
|
||||
|
||||
lazy_static! {
|
||||
static ref CONFIG: Arc<Config> =
|
||||
Arc::new(Config::load_config("").expect("Failed to load PMOMusic configuration"));
|
||||
}
|
||||
|
||||
const ENV_CONFIG_FILE: &str = "PMOMUSIC_CONFIG";
|
||||
const ENV_PREFIX: &str = "PMOMUSIC_CONFIG__";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
path: String,
|
||||
data: Mutex<Value>,
|
||||
}
|
||||
|
||||
// Implémentation manuelle de Clone
|
||||
impl Clone for Config {
|
||||
fn clone(&self) -> Self {
|
||||
let data = self.data.lock().unwrap().clone();
|
||||
Self {
|
||||
path: self.path.clone(),
|
||||
data: Mutex::new(data),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
||||
pub fn load_config(filename: &str) -> Result<Self> {
|
||||
let mut path = filename.to_string();
|
||||
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
|
||||
if !filename.is_empty() {
|
||||
info!(config_file=%path, "Trying to load config");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
if let Ok(env_path) = env::var(ENV_CONFIG_FILE) {
|
||||
info!(env_var=ENV_CONFIG_FILE, path=%env_path, "Trying to load config from env");
|
||||
path = env_path.clone();
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file from env var");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
path = current_dir
|
||||
.join(".pmomusic.yml")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
info!(config_file=%path, "Trying to load config file from current directory");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file in current dir");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
path = Self::get_home_yml_path();
|
||||
info!(config_file=%path, "Trying to load config file from home directory");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file in home directory");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let yaml_data = if let Some(d) = data {
|
||||
d
|
||||
} else {
|
||||
info!("Using default embedded config");
|
||||
DEFAULT_CONFIG.as_bytes().to_vec()
|
||||
};
|
||||
|
||||
|
||||
let external_value: Value = serde_yaml::from_slice(&yaml_data)?;
|
||||
merge_yaml(&mut default_value, &external_value);
|
||||
let mut config_value = Self::lower_keys_value(default_value);
|
||||
|
||||
Self::apply_env_overrides(&mut config_value);
|
||||
|
||||
if path.is_empty() || !Self::is_writable(&path) {
|
||||
let candidates = [
|
||||
filename.to_string(),
|
||||
env::var(ENV_CONFIG_FILE).unwrap_or_default(),
|
||||
".pmomusic.yml".to_string(),
|
||||
Self::get_home_yml_path(),
|
||||
];
|
||||
for candidate in candidates.iter().filter(|c| !c.is_empty()) {
|
||||
if Self::is_writable(candidate) {
|
||||
path = candidate.clone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
return Err(anyhow!("Cannot find a place to store config file"));
|
||||
}
|
||||
|
||||
info!(config_file=%path, "Config file will be stored here");
|
||||
|
||||
let config = Config {
|
||||
path,
|
||||
data: Mutex::new(config_value),
|
||||
};
|
||||
config.save()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let data = self.data.lock().unwrap();
|
||||
let yaml = serde_yaml::to_string(&*data)?;
|
||||
fs::write(&self.path, yaml)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_value(&self, path: &[&str], value: Value) -> Result<()> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
Self::set_value_internal(&mut data, path, value.clone())?;
|
||||
drop(data);
|
||||
self.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_value_internal(data: &mut Value, path: &[&str], value: Value) -> Result<()> {
|
||||
if path.is_empty() {
|
||||
*data = value;
|
||||
return Ok(());
|
||||
}
|
||||
if let Value::Mapping(map) = data {
|
||||
let key = path[0].to_lowercase();
|
||||
let key_value = Value::String(key.clone());
|
||||
if path.len() == 1 {
|
||||
map.insert(key_value, value);
|
||||
} else {
|
||||
let entry = map
|
||||
.entry(key_value)
|
||||
.or_insert(Value::Mapping(Mapping::new()));
|
||||
Self::set_value_internal(entry, &path[1..], value)?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Current node is not a map"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_value(&self, path: &[&str]) -> Result<Value> {
|
||||
let data = self.data.lock().unwrap();
|
||||
Self::get_value_internal(&data, path)
|
||||
}
|
||||
|
||||
fn get_value_internal(data: &Value, path: &[&str]) -> Result<Value> {
|
||||
let mut current = data;
|
||||
for (i, key) in path.iter().enumerate() {
|
||||
|
||||
if let Value::Mapping(map) = current {
|
||||
let key = key.to_lowercase();
|
||||
|
||||
if let Some(next) = map.get(&Value::String(key)) {
|
||||
current = next;
|
||||
} else {
|
||||
return Err(anyhow!("Path {} does not exist", path[..=i].join(".")));
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow!("Path {} is not a Config", path[..i].join(".")));
|
||||
}
|
||||
}
|
||||
Ok(current.clone())
|
||||
}
|
||||
|
||||
fn get_home_yml_path() -> String {
|
||||
home_dir()
|
||||
.map(|p| p.join(".pmomusic.yml"))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn apply_env_overrides(config: &mut Value) {
|
||||
for (key, value) in env::vars() {
|
||||
if key.starts_with(ENV_PREFIX) {
|
||||
let key_path = key
|
||||
.trim_start_matches(ENV_PREFIX)
|
||||
.split("__")
|
||||
.collect::<Vec<_>>();
|
||||
let yaml_value = Self::convert_env_value(&value);
|
||||
let _ = Self::set_value_internal(config, &key_path, yaml_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_env_value(value: &str) -> Value {
|
||||
if let Ok(parsed) = serde_yaml::from_str::<Value>(value) {
|
||||
return parsed;
|
||||
}
|
||||
Value::String(value.to_string())
|
||||
}
|
||||
|
||||
fn lower_keys_value(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Mapping(map) => {
|
||||
let mut new_map = Mapping::new();
|
||||
for (k, v) in map {
|
||||
if let Value::String(s) = k {
|
||||
let new_key = Value::String(s.to_lowercase());
|
||||
let new_val = Self::lower_keys_value(v);
|
||||
new_map.insert(new_key, new_val);
|
||||
} else {
|
||||
new_map.insert(k, Self::lower_keys_value(v));
|
||||
}
|
||||
}
|
||||
Value::Mapping(new_map)
|
||||
}
|
||||
Value::Sequence(seq) => {
|
||||
Value::Sequence(seq.into_iter().map(Self::lower_keys_value).collect())
|
||||
}
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_writable(path: &str) -> bool {
|
||||
let path = Path::new(path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::metadata(parent)
|
||||
.map(|m| !m.permissions().readonly())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_base_url(&self) -> String {
|
||||
match self.get_value(&["host", "base_url"]) {
|
||||
Ok(Value::String(s)) if !s.is_empty() => s,
|
||||
Ok(_) => {
|
||||
tracing::warn!("Base URL is not a string or empty, using default localhost");
|
||||
guess_local_ip()
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to get base URL: {}, using default localhost", err);
|
||||
guess_local_ip()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_http_port(&self) -> u16 {
|
||||
match self.get_value(&["host", "http_port"]) {
|
||||
Ok(Value::Number(n)) if n.is_i64() => n.as_i64().unwrap() as u16,
|
||||
Ok(Value::String(s)) => match s.parse::<u16>() {
|
||||
Ok(port) => port,
|
||||
Err(_) => {
|
||||
tracing::warn!("Invalid HTTP port '{}', using default 8080", s);
|
||||
8080
|
||||
}
|
||||
},
|
||||
Ok(_) => {
|
||||
tracing::warn!("HTTP port not a number or string, using default 8080");
|
||||
8080
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to get HTTP port: {}, using default 8080", err);
|
||||
8080
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_device_udn(&self, devtype: &str, name: &str) -> Result<String> {
|
||||
let path = &["devices", devtype, name, "udn"];
|
||||
match self.get_value(path) {
|
||||
Ok(Value::String(udn)) => Ok(udn),
|
||||
_ => {
|
||||
let new_udn = Uuid::new_v4().to_string();
|
||||
self.set_value(path, Value::String(new_udn.clone()))?;
|
||||
Ok(new_udn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cover_cache_dir(&self) -> Result<String> {
|
||||
match self.get_value(&["host", "cover_cache", "directory"])? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Ok("./.pmomusic_covers".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cover_cache_size(&self) -> Result<usize> {
|
||||
match self.get_value(&["host", "cover_cache", "size"])? {
|
||||
Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
|
||||
Value::Number(n) if n.is_u64() => Ok(n.as_u64().unwrap() as usize),
|
||||
_ => Ok(2000),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne l'instance globale
|
||||
pub fn get_config() -> Arc<Config> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
host:
|
||||
http_port: "1900"
|
||||
http_port: "8080"
|
||||
cover_cache:
|
||||
directory: "./.pmomusic_covers"
|
||||
size: 2000
|
||||
devices:
|
||||
mediarenderer:
|
||||
mpd_renderer:
|
||||
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)
|
||||
}
|
||||
12
pmodidl/Cargo.toml
Normal file
12
pmodidl/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "pmodidl"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = "1.0.228"
|
||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
||||
quick-xml = { version = "0.38.3", features = ["serialize"] }
|
||||
bevy_reflect = "0.17.1"
|
||||
bevy_reflect_derive = "0.17.1"
|
||||
580
pmodidl/src/lib.rs
Normal file
580
pmodidl/src/lib.rs
Normal file
@@ -0,0 +1,580 @@
|
||||
//! # pmodidl - DIDL-Lite Parser
|
||||
//!
|
||||
//! Parser et utilitaires pour le format DIDL-Lite utilisé dans UPnP/DLNA.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Write;
|
||||
use bevy_reflect::Reflect;
|
||||
|
||||
// ============= Couche d'abstraction générique =============
|
||||
|
||||
/// Trait pour tout parser de métadonnées média
|
||||
pub trait MediaMetadataParser: Sized {
|
||||
type Error: std::error::Error + Send + Sync + 'static;
|
||||
|
||||
/// Parse une chaîne de métadonnées
|
||||
fn parse(input: &str) -> Result<Self, Self::Error>;
|
||||
|
||||
/// Retourne le format du parser
|
||||
fn format_name() -> &'static str;
|
||||
}
|
||||
|
||||
/// Enveloppe générique pour tout type de métadonnées parsées
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Reflect)]
|
||||
pub struct ParsedMetadata<T> {
|
||||
/// Format du document (ex: "DIDL-Lite", "RSS", etc.)
|
||||
pub format: String,
|
||||
|
||||
/// Données parsées
|
||||
pub data: T,
|
||||
|
||||
/// Timestamp du parsing (exclu de la réflexion car SystemTime n'implémente pas Reflect)
|
||||
#[reflect(ignore)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parsed_at: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
impl<T> ParsedMetadata<T> {
|
||||
pub fn new(format: impl Into<String>, data: T) -> Self {
|
||||
Self {
|
||||
format: format.into(),
|
||||
data,
|
||||
parsed_at: Some(std::time::SystemTime::now()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforme les données avec une fonction
|
||||
pub fn map<U, F>(self, f: F) -> ParsedMetadata<U>
|
||||
where
|
||||
F: FnOnce(T) -> U,
|
||||
{
|
||||
ParsedMetadata {
|
||||
format: self.format,
|
||||
data: f(self.data),
|
||||
parsed_at: self.parsed_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fonction helper pour parser et envelopper automatiquement
|
||||
pub fn parse_metadata<P: MediaMetadataParser>(input: &str) -> Result<ParsedMetadata<P>, P::Error> {
|
||||
let data = P::parse(input)?;
|
||||
Ok(ParsedMetadata::new(P::format_name(), data))
|
||||
}
|
||||
|
||||
// ============= Implémentation pour DIDLLite =============
|
||||
|
||||
impl MediaMetadataParser for DIDLLite {
|
||||
type Error = quick_xml::de::DeError;
|
||||
|
||||
fn parse(input: &str) -> Result<Self, Self::Error> {
|
||||
quick_xml::de::from_str(input)
|
||||
}
|
||||
|
||||
fn format_name() -> &'static str {
|
||||
"DIDL-Lite"
|
||||
}
|
||||
}
|
||||
|
||||
/// Type alias pour faciliter l'utilisation
|
||||
pub type DidlMetadata = ParsedMetadata<DIDLLite>;
|
||||
|
||||
// ============= Structures DIDL-Lite =============
|
||||
|
||||
|
||||
/// Racine d'un document DIDL-Lite
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
||||
#[serde(rename = "DIDL-Lite")]
|
||||
pub struct DIDLLite {
|
||||
#[serde(rename = "@xmlns")]
|
||||
pub xmlns: String,
|
||||
|
||||
#[serde(rename = "@xmlns:upnp", skip_serializing_if = "Option::is_none")]
|
||||
pub xmlns_upnp: Option<String>,
|
||||
|
||||
#[serde(rename = "@xmlns:dc", skip_serializing_if = "Option::is_none")]
|
||||
pub xmlns_dc: Option<String>,
|
||||
|
||||
#[serde(rename = "@xmlns:dlna", skip_serializing_if = "Option::is_none")]
|
||||
pub xmlns_dlna: Option<String>,
|
||||
|
||||
#[serde(rename = "@xmlns:sec", skip_serializing_if = "Option::is_none")]
|
||||
pub xmlns_sec: Option<String>,
|
||||
|
||||
#[serde(rename = "@xmlns:pv", skip_serializing_if = "Option::is_none")]
|
||||
pub xmlns_pv: Option<String>,
|
||||
|
||||
#[serde(rename = "container", default)]
|
||||
pub containers: Vec<Container>,
|
||||
|
||||
#[serde(rename = "item", default)]
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
/// Container pouvant contenir d'autres containers ou items
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
||||
pub struct Container {
|
||||
#[serde(rename = "@id")]
|
||||
pub id: String,
|
||||
|
||||
#[serde(rename = "@parentID")]
|
||||
pub parent_id: String,
|
||||
|
||||
#[serde(rename = "@restricted", skip_serializing_if = "Option::is_none")]
|
||||
pub restricted: Option<String>,
|
||||
|
||||
#[serde(rename = "@childCount", skip_serializing_if = "Option::is_none")]
|
||||
pub child_count: Option<String>,
|
||||
|
||||
#[serde(rename = "dc:title", alias = "title")]
|
||||
pub title: String,
|
||||
|
||||
#[serde(rename = "upnp:class", alias = "class")]
|
||||
pub class: String,
|
||||
|
||||
#[serde(rename = "container", default)]
|
||||
pub containers: Vec<Container>,
|
||||
|
||||
#[serde(rename = "item", default)]
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
/// Item représentant un objet audio
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
||||
pub struct Item {
|
||||
#[serde(rename = "@id")]
|
||||
pub id: String,
|
||||
|
||||
#[serde(rename = "@parentID")]
|
||||
pub parent_id: String,
|
||||
|
||||
#[serde(rename = "@restricted", skip_serializing_if = "Option::is_none")]
|
||||
pub restricted: Option<String>,
|
||||
|
||||
#[serde(rename = "dc:title", alias = "title")]
|
||||
pub title: String,
|
||||
|
||||
#[serde(rename = "dc:creator", alias = "creator", skip_serializing_if = "Option::is_none")]
|
||||
pub creator: Option<String>,
|
||||
|
||||
#[serde(rename = "upnp:class", alias = "class")]
|
||||
pub class: String,
|
||||
|
||||
#[serde(rename = "upnp:artist", alias = "artist", skip_serializing_if = "Option::is_none")]
|
||||
pub artist: Option<String>,
|
||||
|
||||
#[serde(rename = "upnp:album", alias = "album", skip_serializing_if = "Option::is_none")]
|
||||
pub album: Option<String>,
|
||||
|
||||
#[serde(rename = "upnp:genre", alias = "genre", skip_serializing_if = "Option::is_none")]
|
||||
pub genre: Option<String>,
|
||||
|
||||
#[serde(rename = "upnp:albumArtURI", alias = "albumArtURI", skip_serializing_if = "Option::is_none")]
|
||||
pub album_art: Option<String>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub album_art_pk: Option<String>,
|
||||
|
||||
#[serde(rename = "dc:date", alias = "date", skip_serializing_if = "Option::is_none")]
|
||||
pub date: Option<String>,
|
||||
|
||||
#[serde(rename = "upnp:originalTrackNumber", alias = "originalTrackNumber", skip_serializing_if = "Option::is_none")]
|
||||
pub original_track_number: Option<String>,
|
||||
|
||||
#[serde(rename = "res", default)]
|
||||
pub resources: Vec<Resource>,
|
||||
|
||||
#[serde(rename = "desc", default)]
|
||||
pub descriptions: Vec<Description>,
|
||||
}
|
||||
|
||||
/// Ressource média (fichier audio)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
||||
pub struct Resource {
|
||||
#[serde(rename = "@protocolInfo")]
|
||||
pub protocol_info: String,
|
||||
|
||||
#[serde(rename = "@bitsPerSample", skip_serializing_if = "Option::is_none")]
|
||||
pub bits_per_sample: Option<String>,
|
||||
|
||||
#[serde(rename = "@sampleFrequency", skip_serializing_if = "Option::is_none")]
|
||||
pub sample_frequency: Option<String>,
|
||||
|
||||
#[serde(rename = "@nrAudioChannels", skip_serializing_if = "Option::is_none")]
|
||||
pub nr_audio_channels: Option<String>,
|
||||
|
||||
#[serde(rename = "@duration", skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<String>,
|
||||
|
||||
#[serde(rename = "$text")]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Description avec métadonnées additionnelles (replaygain, etc.)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)]
|
||||
pub struct Description {
|
||||
#[serde(rename = "@id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
|
||||
#[serde(rename = "@nameSpace", skip_serializing_if = "Option::is_none")]
|
||||
pub namespace: Option<String>,
|
||||
|
||||
#[serde(rename = "track_gain", skip_serializing_if = "Option::is_none")]
|
||||
pub track_gain: Option<String>,
|
||||
|
||||
#[serde(rename = "track_peak", skip_serializing_if = "Option::is_none")]
|
||||
pub track_peak: Option<String>,
|
||||
}
|
||||
|
||||
// ============= Implémentation des méthodes =============
|
||||
|
||||
impl DIDLLite {
|
||||
/// Itère sur tous les containers de manière récursive
|
||||
pub fn all_containers(&self) -> impl Iterator<Item = &Container> {
|
||||
AllContainersIter::new(&self.containers)
|
||||
}
|
||||
|
||||
/// Itère sur tous les items de manière récursive
|
||||
pub fn all_items(&self) -> impl Iterator<Item = &Item> {
|
||||
AllItemsIter::new(&self.containers, &self.items)
|
||||
}
|
||||
|
||||
/// Trouve un container par ID
|
||||
pub fn get_container_by_id(&self, id: &str) -> Option<&Container> {
|
||||
self.all_containers().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// Trouve un item par ID
|
||||
pub fn get_item_by_id(&self, id: &str) -> Option<&Item> {
|
||||
self.all_items().find(|i| i.id == id)
|
||||
}
|
||||
|
||||
/// Filtre les containers
|
||||
pub fn filter_containers<F>(&self, predicate: F) -> impl Iterator<Item = &Container>
|
||||
where
|
||||
F: Fn(&Container) -> bool,
|
||||
{
|
||||
self.all_containers().filter(move |c| predicate(c))
|
||||
}
|
||||
|
||||
/// Filtre les items
|
||||
pub fn filter_items<F>(&self, predicate: F) -> impl Iterator<Item = &Item>
|
||||
where
|
||||
F: Fn(&Item) -> bool,
|
||||
{
|
||||
self.all_items().filter(move |i| predicate(i))
|
||||
}
|
||||
|
||||
/// Génère une représentation Markdown
|
||||
pub fn to_markdown(&self) -> String {
|
||||
let mut buf = String::new();
|
||||
buf.push_str("### DIDL-Lite Document\n\n");
|
||||
|
||||
if !self.containers.is_empty() {
|
||||
buf.push_str("#### Containers\n\n");
|
||||
for container in &self.containers {
|
||||
container.write_markdown(&mut buf, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.items.is_empty() {
|
||||
buf.push_str("#### Items\n\n");
|
||||
for item in &self.items {
|
||||
item.write_markdown(&mut buf, 0);
|
||||
}
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
impl Container {
|
||||
/// Itère sur tous les containers enfants récursivement
|
||||
pub fn all_containers(&self) -> impl Iterator<Item = &Container> {
|
||||
AllContainersIter::new(&self.containers)
|
||||
}
|
||||
|
||||
/// Itère sur tous les items de ce container et ses enfants
|
||||
pub fn all_items(&self) -> impl Iterator<Item = &Item> {
|
||||
AllItemsIter::new(&self.containers, &self.items)
|
||||
}
|
||||
|
||||
fn write_markdown(&self, buf: &mut String, depth: usize) {
|
||||
let indent = " ".repeat(depth);
|
||||
|
||||
writeln!(buf, "{}- **Container**: {}", indent, self.title).unwrap();
|
||||
writeln!(buf, "{} - ID: `{}`", indent, self.id).unwrap();
|
||||
writeln!(buf, "{} - ParentID: `{}`", indent, self.parent_id).unwrap();
|
||||
writeln!(buf, "{} - Class: `{}`", indent, self.class).unwrap();
|
||||
|
||||
if let Some(ref restricted) = self.restricted {
|
||||
writeln!(buf, "{} - Restricted: `{}`", indent, restricted).unwrap();
|
||||
}
|
||||
if let Some(ref count) = self.child_count {
|
||||
writeln!(buf, "{} - ChildCount: `{}`", indent, count).unwrap();
|
||||
}
|
||||
|
||||
if !self.containers.is_empty() {
|
||||
writeln!(buf, "{} - Subcontainers:", indent).unwrap();
|
||||
for sub in &self.containers {
|
||||
sub.write_markdown(buf, depth + 2);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.items.is_empty() {
|
||||
writeln!(buf, "{} - Items:", indent).unwrap();
|
||||
for item in &self.items {
|
||||
item.write_markdown(buf, depth + 2);
|
||||
}
|
||||
}
|
||||
|
||||
buf.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Itère sur les ressources audio uniquement
|
||||
pub fn audio_resources(&self) -> impl Iterator<Item = &Resource> {
|
||||
self.resources.iter()
|
||||
.filter(|r| r.protocol_info.contains("audio/"))
|
||||
}
|
||||
|
||||
/// Retourne la ressource principale (première disponible)
|
||||
pub fn primary_resource(&self) -> Option<&Resource> {
|
||||
self.resources.first()
|
||||
}
|
||||
|
||||
/// Itère sur les métadonnées sous forme de paires clé-valeur
|
||||
pub fn metadata(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
let mut pairs = Vec::new();
|
||||
|
||||
pairs.push(("title", self.title.as_str()));
|
||||
|
||||
if let Some(ref artist) = self.artist {
|
||||
pairs.push(("artist", artist.as_str()));
|
||||
}
|
||||
if let Some(ref album) = self.album {
|
||||
pairs.push(("album", album.as_str()));
|
||||
}
|
||||
if let Some(ref genre) = self.genre {
|
||||
pairs.push(("genre", genre.as_str()));
|
||||
}
|
||||
if let Some(ref date) = self.date {
|
||||
pairs.push(("date", date.as_str()));
|
||||
}
|
||||
if let Some(ref track) = self.original_track_number {
|
||||
pairs.push(("trackNumber", track.as_str()));
|
||||
}
|
||||
|
||||
for desc in &self.descriptions {
|
||||
if let Some(ref gain) = desc.track_gain {
|
||||
pairs.push(("replayGain", gain.as_str()));
|
||||
}
|
||||
if let Some(ref peak) = desc.track_peak {
|
||||
pairs.push(("replayPeak", peak.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
pairs.into_iter()
|
||||
}
|
||||
|
||||
fn write_markdown(&self, buf: &mut String, depth: usize) {
|
||||
let indent = " ".repeat(depth);
|
||||
|
||||
writeln!(buf, "{}- **Item**: {}", indent, self.title).unwrap();
|
||||
writeln!(buf, "{} - ID: `{}`", indent, self.id).unwrap();
|
||||
writeln!(buf, "{} - ParentID: `{}`", indent, self.parent_id).unwrap();
|
||||
writeln!(buf, "{} - Class: `{}`", indent, self.class).unwrap();
|
||||
|
||||
if let Some(ref creator) = self.creator {
|
||||
writeln!(buf, "{} - Creator: {}", indent, creator).unwrap();
|
||||
}
|
||||
if let Some(ref artist) = self.artist {
|
||||
writeln!(buf, "{} - Artist: {}", indent, artist).unwrap();
|
||||
}
|
||||
if let Some(ref album) = self.album {
|
||||
writeln!(buf, "{} - Album: {}", indent, album).unwrap();
|
||||
}
|
||||
if let Some(ref genre) = self.genre {
|
||||
writeln!(buf, "{} - Genre: {}", indent, genre).unwrap();
|
||||
}
|
||||
if let Some(ref art) = self.album_art {
|
||||
writeln!(buf, "{} - Album Art: ", indent, art).unwrap();
|
||||
}
|
||||
if let Some(ref date) = self.date {
|
||||
writeln!(buf, "{} - Date: {}", indent, date).unwrap();
|
||||
}
|
||||
if let Some(ref track) = self.original_track_number {
|
||||
writeln!(buf, "{} - Track: {}", indent, track).unwrap();
|
||||
}
|
||||
|
||||
if !self.resources.is_empty() {
|
||||
writeln!(buf, "{} - Resources:", indent).unwrap();
|
||||
for res in &self.resources {
|
||||
writeln!(buf, "{} - URL: {}", indent, res.url).unwrap();
|
||||
writeln!(buf, "{} - Protocol: `{}`", indent, res.protocol_info).unwrap();
|
||||
if let Some(ref dur) = res.duration {
|
||||
writeln!(buf, "{} - Duration: `{}`", indent, dur).unwrap();
|
||||
}
|
||||
if let Some(ref bits) = res.bits_per_sample {
|
||||
writeln!(buf, "{} - BitsPerSample: `{}`", indent, bits).unwrap();
|
||||
}
|
||||
if let Some(ref freq) = res.sample_frequency {
|
||||
writeln!(buf, "{} - SampleFrequency: `{}`", indent, freq).unwrap();
|
||||
}
|
||||
if let Some(ref channels) = res.nr_audio_channels {
|
||||
writeln!(buf, "{} - Channels: `{}`", indent, channels).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.descriptions.is_empty() {
|
||||
writeln!(buf, "{} - Descriptions:", indent).unwrap();
|
||||
for desc in &self.descriptions {
|
||||
if let Some(ref ns) = desc.namespace {
|
||||
writeln!(buf, "{} - Namespace: `{}`", indent, ns).unwrap();
|
||||
}
|
||||
if let Some(ref gain) = desc.track_gain {
|
||||
writeln!(buf, "{} - Track Gain: `{}`", indent, gain).unwrap();
|
||||
}
|
||||
if let Some(ref peak) = desc.track_peak {
|
||||
writeln!(buf, "{} - Track Peak: `{}`", indent, peak).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buf.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Itérateurs personnalisés =============
|
||||
|
||||
struct AllContainersIter<'a> {
|
||||
stack: Vec<&'a Container>,
|
||||
}
|
||||
|
||||
impl<'a> AllContainersIter<'a> {
|
||||
fn new(containers: &'a [Container]) -> Self {
|
||||
Self {
|
||||
stack: containers.iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for AllContainersIter<'a> {
|
||||
type Item = &'a Container;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.stack.pop().map(|container| {
|
||||
// Ajouter les enfants à la pile
|
||||
self.stack.extend(container.containers.iter());
|
||||
container
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct AllItemsIter<'a> {
|
||||
containers: Vec<&'a Container>,
|
||||
current_items: std::slice::Iter<'a, Item>,
|
||||
}
|
||||
|
||||
impl<'a> AllItemsIter<'a> {
|
||||
fn new(containers: &'a [Container], items: &'a [Item]) -> Self {
|
||||
Self {
|
||||
containers: containers.iter().collect(),
|
||||
current_items: items.iter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for AllItemsIter<'a> {
|
||||
type Item = &'a Item;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
if let Some(item) = self.current_items.next() {
|
||||
return Some(item);
|
||||
}
|
||||
|
||||
let container = self.containers.pop()?;
|
||||
self.containers.extend(container.containers.iter());
|
||||
self.current_items = container.items.iter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_didl() {
|
||||
let xml = r#"
|
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||
<item id="1" parentID="0">
|
||||
<dc:title>Test Song</dc:title>
|
||||
<upnp:class>object.item.audioItem.musicTrack</upnp:class>
|
||||
<res protocolInfo="http-get:*:audio/mpeg:*">http://example.com/song.mp3</res>
|
||||
</item>
|
||||
</DIDL-Lite>
|
||||
"#;
|
||||
|
||||
let didl = DIDLLite::parse(xml).unwrap();
|
||||
assert_eq!(didl.items.len(), 1);
|
||||
assert_eq!(didl.items[0].title, "Test Song");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_without_namespaces() {
|
||||
// Teste un XML sans namespaces explicites (devices UPnP laxistes)
|
||||
let xml = r#"
|
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
|
||||
<item id="1" parentID="0">
|
||||
<title>Test Song</title>
|
||||
<class>object.item.audioItem.musicTrack</class>
|
||||
<res protocolInfo="http-get:*:audio/mpeg:*">http://example.com/song.mp3</res>
|
||||
</item>
|
||||
</DIDL-Lite>
|
||||
"#;
|
||||
|
||||
let didl = DIDLLite::parse(xml).unwrap();
|
||||
assert_eq!(didl.items.len(), 1);
|
||||
assert_eq!(didl.items[0].title, "Test Song");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generic_parser() {
|
||||
let xml = r#"
|
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||
</DIDL-Lite>
|
||||
"#;
|
||||
|
||||
// Utiliser le parser générique
|
||||
let metadata: DidlMetadata = parse_metadata(xml).unwrap();
|
||||
|
||||
assert_eq!(metadata.format, "DIDL-Lite");
|
||||
assert!(metadata.parsed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_map() {
|
||||
let xml = r#"
|
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||
</DIDL-Lite>
|
||||
"#;
|
||||
|
||||
let metadata: DidlMetadata = parse_metadata(xml).unwrap();
|
||||
|
||||
// Transformer les données
|
||||
let item_count = metadata.map(|didl| didl.items.len());
|
||||
|
||||
assert_eq!(item_count.format, "DIDL-Lite");
|
||||
assert_eq!(item_count.data, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>🚀 Real-Time Logs</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--border: #30363d;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #7d8590;
|
||||
--error: #f85149;
|
||||
--warning: #d29922;
|
||||
--info: #58a6ff;
|
||||
--debug: #8957e5;
|
||||
--success: #3fb950;
|
||||
}
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: var(--info);
|
||||
}
|
||||
#controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.control-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
#logs {
|
||||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
padding: 15px;
|
||||
}
|
||||
.log {
|
||||
margin: 8px 0;
|
||||
padding: 8px 12px;
|
||||
border-left: 4px solid;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.log.error { border-color: var(--error); }
|
||||
.log.warning { border-color: var(--warning); }
|
||||
.log.info { border-color: var(--info); }
|
||||
.log.debug { border-color: var(--debug); }
|
||||
.log-time {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.log-level {
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.level-error { background: var(--error); color: white; }
|
||||
.level-warning { background: var(--warning); color: black; }
|
||||
.level-info { background: var(--info); color: white; }
|
||||
.level-debug { background: var(--debug); color: white; }
|
||||
.log-content {
|
||||
margin-top: 6px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, SFMono-Regular, SF Mono, Consolas, Liberation Mono, Menlo, monospace;
|
||||
}
|
||||
.log-fields {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
margin-right: 4px;
|
||||
accent-color: var(--info);
|
||||
}
|
||||
#search {
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
width: 180px;
|
||||
}
|
||||
button {
|
||||
background: #238636;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #2ea043; }
|
||||
#status {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-connected { background: var(--success); }
|
||||
.status-disconnected { background: var(--error); }
|
||||
.status-connecting { background: var(--warning); }
|
||||
#log-count {
|
||||
background: var(--bg-primary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.highlight {
|
||||
background: rgba(255, 255, 0, 0.2);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>📝 Real-Time Logs</h1>
|
||||
<div id="controls">
|
||||
<div class="control-group">
|
||||
<label><input type="checkbox" value="error" checked>❌ Error</label>
|
||||
<label><input type="checkbox" value="warning" checked>⚠️ Warning</label>
|
||||
<label><input type="checkbox" value="info" checked>ℹ️ Info</label>
|
||||
<label><input type="checkbox" value="debug" checked>🐛 Debug</label>
|
||||
</div>
|
||||
<input id="search" type="text" placeholder="Search...">
|
||||
<div class="control-group">
|
||||
<label><input type="checkbox" id="autoscroll" checked> Auto-scroll</label>
|
||||
<button id="clear">Clear</button>
|
||||
<button id="export">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status">
|
||||
<span class="status-indicator status-connecting" id="status-indicator"></span>
|
||||
<span id="status-text">Connecting...</span>
|
||||
<span id="log-count">0 logs</span>
|
||||
</div>
|
||||
</header>
|
||||
<div id="logs"></div>
|
||||
<script>
|
||||
const logsContainer = document.getElementById('logs');
|
||||
const statusIndicator = document.getElementById('status-indicator');
|
||||
const statusText = document.getElementById('status-text');
|
||||
const logCountElement = document.getElementById('log-count');
|
||||
const maxLogs = 1000;
|
||||
let eventSource = null;
|
||||
let filters = {
|
||||
error: true,
|
||||
warning: true,
|
||||
info: true,
|
||||
debug: true
|
||||
};
|
||||
let searchTerm = "";
|
||||
let autoScroll = true;
|
||||
let logCount = 0;
|
||||
let reconnectAttempts = 0;
|
||||
let isConnected = false;
|
||||
|
||||
// Initialize Marked and Highlight.js
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
highlight: (code, lang) => {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
});
|
||||
|
||||
// Format log level with colors
|
||||
function formatLevel(level) {
|
||||
const levelClasses = {
|
||||
error: 'level-error',
|
||||
warning: 'level-warning',
|
||||
info: 'level-info',
|
||||
debug: 'level-debug'
|
||||
};
|
||||
return '<span class="log-level ' + (levelClasses[level] || '') + '">' + level.toUpperCase() + '</span>';
|
||||
}
|
||||
|
||||
// Format timestamp
|
||||
function formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return '<span class="log-time">' + date.toLocaleTimeString() + '.' + date.getMilliseconds().toString().padStart(3, '0') + '</span>';
|
||||
}
|
||||
|
||||
// Highlight search terms in text
|
||||
function highlightText(text, term) {
|
||||
if (!term) return text;
|
||||
const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
|
||||
return text.replace(regex, '<span class="highlight">$1</span>');
|
||||
}
|
||||
|
||||
// Format log fields
|
||||
function formatFields(fields) {
|
||||
if (!fields || Object.keys(fields).length === 0) return '';
|
||||
|
||||
let html = '<div class="log-fields">';
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
html += '<div><strong>' + key + ':</strong> ' + JSON.stringify(value) + '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// Add a log entry to the UI
|
||||
function addLogEntry(data) {
|
||||
// Apply filtering
|
||||
if (!filters[data.level]) return;
|
||||
|
||||
const contentLower = data.content.toLowerCase();
|
||||
const levelLower = data.level.toLowerCase();
|
||||
if (searchTerm &&
|
||||
!contentLower.includes(searchTerm) &&
|
||||
!levelLower.includes(searchTerm)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = 'log ' + data.level;
|
||||
|
||||
// Add timestamp and level
|
||||
line.innerHTML = '<div>' +
|
||||
formatTimestamp(data.time) +
|
||||
formatLevel(data.level) +
|
||||
'</div>' +
|
||||
'<div class="log-content">' + highlightText(marked.parse(data.content), searchTerm) + '</div>' +
|
||||
formatFields(data.fields);
|
||||
|
||||
logsContainer.appendChild(line);
|
||||
logCount++;
|
||||
logCountElement.textContent = logCount + ' logs';
|
||||
|
||||
// Limit the number of logs displayed
|
||||
if (logsContainer.children.length > maxLogs) {
|
||||
logsContainer.removeChild(logsContainer.firstChild);
|
||||
}
|
||||
|
||||
// Auto-scroll if enabled
|
||||
if (autoScroll) {
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to the SSE server
|
||||
function connect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
// Build URL with query parameters for filters
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([level, enabled]) => {
|
||||
if (!enabled) params.set(level, 'false');
|
||||
});
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
|
||||
const url = '/log-sse' + (params.toString() ? '?' + params.toString() : '');
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
eventSource.addEventListener('message', (e) => {
|
||||
if (e.data) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
addLogEntry(data);
|
||||
} catch (err) {
|
||||
console.error('Error parsing log message:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('heartbeat', (e) => {
|
||||
// Update last activity timestamp
|
||||
updateStatus(true);
|
||||
});
|
||||
|
||||
eventSource.onopen = () => {
|
||||
updateStatus(true);
|
||||
reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
eventSource.onerror = (e) => {
|
||||
console.error('SSE error', e);
|
||||
updateStatus(false);
|
||||
|
||||
// Attempt to reconnect with exponential backoff
|
||||
eventSource.close();
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||
reconnectAttempts++;
|
||||
setTimeout(connect, delay);
|
||||
};
|
||||
}
|
||||
|
||||
// Update connection status UI
|
||||
function updateStatus(connected) {
|
||||
isConnected = connected;
|
||||
statusIndicator.className = 'status-indicator ' +
|
||||
(connected ? 'status-connected' : 'status-disconnected');
|
||||
statusText.textContent = connected ? 'Connected' : 'Disconnected';
|
||||
}
|
||||
|
||||
// Initialize controls
|
||||
function initControls() {
|
||||
// Filter checkboxes
|
||||
document.querySelectorAll('input[type=checkbox][value]').forEach(cb => {
|
||||
cb.checked = filters[cb.value];
|
||||
cb.addEventListener('change', () => {
|
||||
filters[cb.value] = cb.checked;
|
||||
// Reconnect with new filters
|
||||
connect();
|
||||
});
|
||||
});
|
||||
|
||||
// Search input
|
||||
const searchInput = document.getElementById('search');
|
||||
searchInput.addEventListener('input', e => {
|
||||
searchTerm = e.target.value.toLowerCase();
|
||||
// Reconnect with new search term
|
||||
connect();
|
||||
});
|
||||
|
||||
// Auto-scroll
|
||||
document.getElementById('autoscroll').addEventListener('change', e => {
|
||||
autoScroll = e.target.checked;
|
||||
if (autoScroll) {
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear button
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
logsContainer.innerHTML = '';
|
||||
logCount = 0;
|
||||
logCountElement.textContent = '0 logs';
|
||||
});
|
||||
|
||||
// Export button
|
||||
document.getElementById('export').addEventListener('click', () => {
|
||||
const logs = Array.from(logsContainer.children).map(log => log.textContent).join('\n');
|
||||
const blob = new Blob([logs], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'logs-' + new Date().toISOString().slice(0, 10) + '.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
function init() {
|
||||
initControls();
|
||||
connect();
|
||||
|
||||
// Handle visibility change - reconnect when tab becomes visible again
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && !isConnected) {
|
||||
connect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start the application
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,211 +0,0 @@
|
||||
package pmolog
|
||||
|
||||
import (
|
||||
"container/ring"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var indexHTML string
|
||||
|
||||
const (
|
||||
bufferSize = 1000
|
||||
clientChanSize = 50
|
||||
heartbeatInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
// ---------- Enhanced SSE Broker ----------
|
||||
|
||||
type Client struct {
|
||||
messageChan chan string
|
||||
filters map[string]bool
|
||||
searchTerm string
|
||||
}
|
||||
|
||||
type SSEBroker struct {
|
||||
clients map[*Client]bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
broker = &SSEBroker{clients: make(map[*Client]bool)}
|
||||
logBuffer = ring.New(bufferSize)
|
||||
bufferMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// ---------- Enhanced Hook for Logrus ----------
|
||||
|
||||
type SSELogHook struct{}
|
||||
|
||||
func (SSELogHook) Levels() []logrus.Level { return logrus.AllLevels }
|
||||
|
||||
func (SSELogHook) Fire(entry *logrus.Entry) error {
|
||||
msg := map[string]interface{}{
|
||||
"time": time.Now().Format(time.RFC3339Nano),
|
||||
"level": entry.Level.String(),
|
||||
"content": entry.Message,
|
||||
"fields": entry.Data,
|
||||
}
|
||||
b, _ := json.Marshal(msg)
|
||||
|
||||
// Add to buffer
|
||||
bufferMutex.Lock()
|
||||
logBuffer.Value = string(b)
|
||||
logBuffer = logBuffer.Next()
|
||||
bufferMutex.Unlock()
|
||||
|
||||
// Broadcast to clients
|
||||
broker.mu.RLock()
|
||||
for client := range broker.clients {
|
||||
// Apply client-side filtering before sending
|
||||
if !client.filters[strings.ToLower(msg["level"].(string))] {
|
||||
continue
|
||||
}
|
||||
|
||||
if client.searchTerm != "" &&
|
||||
!strings.Contains(strings.ToLower(msg["content"].(string)), client.searchTerm) &&
|
||||
!strings.Contains(strings.ToLower(msg["level"].(string)), client.searchTerm) {
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case client.messageChan <- string(b):
|
||||
default:
|
||||
// Skip if client channel is full (client is too slow)
|
||||
}
|
||||
}
|
||||
broker.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Enhanced SSE Handler ----------
|
||||
|
||||
func sseHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // Disable buffering for nginx
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters for initial filters
|
||||
query := r.URL.Query()
|
||||
filters := map[string]bool{
|
||||
"error": query.Get("error") != "false",
|
||||
"warning": query.Get("warning") != "false",
|
||||
"info": query.Get("info") != "false",
|
||||
"debug": query.Get("debug") != "false",
|
||||
}
|
||||
searchTerm := strings.ToLower(query.Get("search"))
|
||||
|
||||
// Create client
|
||||
client := &Client{
|
||||
messageChan: make(chan string, clientChanSize),
|
||||
filters: filters,
|
||||
searchTerm: searchTerm,
|
||||
}
|
||||
|
||||
// Register client
|
||||
broker.mu.Lock()
|
||||
broker.clients[client] = true
|
||||
broker.mu.Unlock()
|
||||
|
||||
// Send initial heartbeat to prevent connection timeout
|
||||
fmt.Fprintf(w, "event: heartbeat\ndata: %s\n\n", time.Now().Format(time.RFC3339))
|
||||
flusher.Flush()
|
||||
|
||||
// Replay buffer
|
||||
bufferMutex.RLock()
|
||||
logBuffer.Do(func(v interface{}) {
|
||||
if v != nil {
|
||||
// Apply filtering to historical messages
|
||||
var msg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(v.(string)), &msg); err == nil {
|
||||
if !filters[strings.ToLower(msg["level"].(string))] {
|
||||
return
|
||||
}
|
||||
|
||||
if searchTerm != "" &&
|
||||
!strings.Contains(strings.ToLower(msg["content"].(string)), searchTerm) &&
|
||||
!strings.Contains(strings.ToLower(msg["level"].(string)), searchTerm) {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "event: message\ndata: %s\n\n", v.(string))
|
||||
}
|
||||
}
|
||||
})
|
||||
flusher.Flush()
|
||||
bufferMutex.RUnlock()
|
||||
|
||||
// Create a ticker for heartbeats
|
||||
heartbeat := time.NewTicker(heartbeatInterval)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
// Stream new messages
|
||||
for {
|
||||
select {
|
||||
case msg := <-client.messageChan:
|
||||
fmt.Fprintf(w, "event: message\ndata: %s\n\n", msg)
|
||||
flusher.Flush()
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprintf(w, "event: heartbeat\ndata: %s\n\n", time.Now().Format(time.RFC3339))
|
||||
flusher.Flush()
|
||||
case <-r.Context().Done():
|
||||
broker.mu.Lock()
|
||||
delete(broker.clients, client)
|
||||
broker.mu.Unlock()
|
||||
close(client.messageChan)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Handlers ----------
|
||||
|
||||
func indexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
|
||||
fmt.Fprint(w, indexHTML)
|
||||
}
|
||||
|
||||
// LoggerWeb installe les routes et arrête le broker quand ctx est annulé.
|
||||
func LoggerWeb(ctx context.Context, mux *http.ServeMux) {
|
||||
logrus.SetFormatter(&logrus.TextFormatter{
|
||||
ForceColors: true,
|
||||
FullTimestamp: true,
|
||||
})
|
||||
logrus.AddHook(SSELogHook{})
|
||||
|
||||
mux.HandleFunc("/log", indexHandler)
|
||||
mux.HandleFunc("/log-sse", sseHandler)
|
||||
|
||||
// Goroutine d'arrêt
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
broker.mu.Lock()
|
||||
for client := range broker.clients {
|
||||
close(client.messageChan)
|
||||
delete(broker.clients, client)
|
||||
}
|
||||
broker.mu.Unlock()
|
||||
logrus.Info("Web logger stopped")
|
||||
}()
|
||||
|
||||
logrus.Info("Web logger connected at /log")
|
||||
}
|
||||
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};
|
||||
217
pmoserver/src/logs/mod.rs
Normal file
217
pmoserver/src/logs/mod.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
// logs.rs
|
||||
mod sselayer;
|
||||
|
||||
pub use sselayer::SseLayer;
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::{Arc, RwLock},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
response::{
|
||||
IntoResponse,
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing_subscriber::{Registry, layer::SubscriberExt};
|
||||
|
||||
/// Représente une entrée de log
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: SystemTime,
|
||||
pub level: String,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Buffer circulaire partagé
|
||||
#[derive(Clone)]
|
||||
pub struct LogState {
|
||||
buffer: Arc<RwLock<VecDeque<LogEntry>>>,
|
||||
tx: broadcast::Sender<LogEntry>,
|
||||
}
|
||||
|
||||
impl LogState {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(capacity))),
|
||||
tx: broadcast::channel(1000).0,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&self, entry: LogEntry) {
|
||||
let mut buf = self.buffer.write().unwrap();
|
||||
if buf.len() == buf.capacity() {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(entry.clone());
|
||||
let _ = self.tx.send(entry);
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<LogEntry> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn dump(&self) -> Vec<LogEntry> {
|
||||
self.buffer.read().unwrap().iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Query params pour /log-sse
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LogQuery {
|
||||
#[serde(default)]
|
||||
pub error: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub warn: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub info: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub debug: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub trace: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
/// Handler SSE
|
||||
// Dans logs.rs
|
||||
pub async fn log_sse(
|
||||
State(state): State<LogState>,
|
||||
Query(params): Query<LogQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let mut rx = state.subscribe();
|
||||
|
||||
// Récupérer l'historique du buffer
|
||||
let history = state.dump();
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
// 1. Envoyer d'abord tous les logs historiques
|
||||
for entry in history {
|
||||
if !filter_entry(&entry, ¶ms) {
|
||||
continue;
|
||||
}
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
yield Ok::<_, axum::Error>(Event::default().data(json));
|
||||
}
|
||||
|
||||
// 2. Puis streamer les nouveaux logs en temps réel
|
||||
while let Ok(entry) = rx.recv().await {
|
||||
if !filter_entry(&entry, ¶ms) {
|
||||
continue;
|
||||
}
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
yield Ok::<_, axum::Error>(Event::default().data(json));
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// Handler REST (dump JSON du buffer)
|
||||
pub async fn log_dump(State(state): State<LogState>) -> impl IntoResponse {
|
||||
Json(state.dump())
|
||||
}
|
||||
|
||||
/// Fonction de filtrage
|
||||
fn filter_entry(entry: &LogEntry, q: &LogQuery) -> bool {
|
||||
// Filtrage par niveau
|
||||
let lvl = entry.level.to_lowercase();
|
||||
let mut allowed = false;
|
||||
|
||||
if let Some(true) = q.error {
|
||||
allowed |= lvl == "error";
|
||||
}
|
||||
if let Some(true) = q.warn {
|
||||
allowed |= lvl == "warn";
|
||||
}
|
||||
if let Some(true) = q.info {
|
||||
allowed |= lvl == "info";
|
||||
}
|
||||
if let Some(true) = q.debug {
|
||||
allowed |= lvl == "debug";
|
||||
}
|
||||
if let Some(true) = q.trace {
|
||||
allowed |= lvl == "trace";
|
||||
}
|
||||
|
||||
// si aucun flag → tout est autorisé
|
||||
if !(q.error.unwrap_or(false)
|
||||
|| q.warn.unwrap_or(false)
|
||||
|| q.info.unwrap_or(false)
|
||||
|| q.debug.unwrap_or(false)
|
||||
|| q.trace.unwrap_or(false))
|
||||
{
|
||||
allowed = true;
|
||||
}
|
||||
|
||||
// Filtrage par mot-clé
|
||||
if let Some(search) = &q.search {
|
||||
allowed &= entry.message.contains(search) || entry.target.contains(search);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
63
pmoserver/src/logs/sselayer.rs
Normal file
63
pmoserver/src/logs/sselayer.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing::{Event, Subscriber};
|
||||
use tracing_subscriber::{Layer, layer::Context};
|
||||
|
||||
use super::{LogEntry, LogState};
|
||||
use std::time::SystemTime;
|
||||
|
||||
struct LogVisitor {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl LogVisitor {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visit for LogVisitor {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
// capture le champ "message" ou concatène les autres
|
||||
if field.name() == "message" {
|
||||
self.message = format!("{:?}", value);
|
||||
} else {
|
||||
if !self.message.is_empty() {
|
||||
self.message.push(' ');
|
||||
}
|
||||
self.message
|
||||
.push_str(&format!("{}={:?}", field.name(), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer de tracing qui pousse les events dans le buffer
|
||||
pub struct SseLayer {
|
||||
state: LogState,
|
||||
}
|
||||
|
||||
impl SseLayer {
|
||||
pub fn new(state: LogState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for SseLayer
|
||||
where
|
||||
S: Subscriber,
|
||||
{
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
let mut visitor = LogVisitor::new();
|
||||
event.record(&mut visitor);
|
||||
|
||||
let entry = LogEntry {
|
||||
timestamp: SystemTime::now(),
|
||||
level: event.metadata().level().to_string(),
|
||||
target: event.metadata().target().to_string(),
|
||||
message: visitor.message,
|
||||
};
|
||||
|
||||
self.state.push(entry);
|
||||
}
|
||||
}
|
||||
560
pmoserver/src/server.rs
Normal file
560
pmoserver/src/server.rs
Normal file
@@ -0,0 +1,560 @@
|
||||
//! # Module Server - API de haut niveau pour Axum
|
||||
//!
|
||||
//! Ce module fournit une abstraction simple et ergonomique pour créer des serveurs HTTP
|
||||
//! avec Axum, en cachant la complexité de la configuration et du routage.
|
||||
//!
|
||||
//! ## Fonctionnalités
|
||||
//!
|
||||
//! - 🚀 **Routes JSON simples** : Ajoutez des endpoints API avec `add_route()`
|
||||
//! - 📁 **Fichiers statiques** : Servez des assets avec `add_dir()`
|
||||
//! - ⚛️ **Applications SPA** : Support pour Vue.js/React avec `add_spa()`
|
||||
//! - 🔀 **Redirections** : Redirigez des routes avec `add_redirect()`
|
||||
//! - 🎯 **Handlers personnalisés** : Support SSE, WebSocket, etc. avec `add_handler_with_state()`
|
||||
//! - 📚 **Documentation API** : OpenAPI/Swagger automatique avec `add_openapi()`
|
||||
//! - ⚡ **Gestion gracieuse** : Arrêt propre sur Ctrl+C
|
||||
|
||||
use crate::logs::{LogState, LoggingOptions, init_logging, log_dump, log_sse};
|
||||
use axum::handler::Handler;
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use axum_embed::ServeEmbed;
|
||||
use pmoconfig::get_config;
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::Serialize;
|
||||
use std::future::Future;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::{signal, sync::RwLock, task::JoinHandle};
|
||||
use tracing::info;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
/// Info serveur sérialisable
|
||||
#[derive(Clone, Serialize, utoipa::ToSchema)]
|
||||
pub struct ServerInfo {
|
||||
pub name: String,
|
||||
pub base_url: String,
|
||||
pub http_port: u16,
|
||||
}
|
||||
|
||||
/// Serveur principal
|
||||
pub struct Server {
|
||||
name: String,
|
||||
base_url: String,
|
||||
http_port: u16,
|
||||
router: Arc<RwLock<Router>>,
|
||||
api_router: Arc<RwLock<Option<Router>>>,
|
||||
join_handle: Option<JoinHandle<()>>,
|
||||
log_state: Option<LogState>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Crée une nouvelle instance de serveur
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - Nom du serveur (pour les logs)
|
||||
/// * `base_url` - URL de base (ex: "http://localhost:3000")
|
||||
/// * `http_port` - Port HTTP à écouter
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// let server = Server::new("MyAPI", "http://localhost:3000", 3000);
|
||||
/// ```
|
||||
pub fn new(name: impl Into<String>, base_url: impl Into<String>, http_port: u16) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
base_url: base_url.into(),
|
||||
http_port,
|
||||
router: Arc::new(RwLock::new(Router::new())),
|
||||
api_router: Arc::new(RwLock::new(None)),
|
||||
join_handle: None,
|
||||
log_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_configured() -> Self {
|
||||
let config = get_config();
|
||||
let url = config.get_base_url();
|
||||
let port = config.get_http_port();
|
||||
Self::new("PMO-Music-Server", url, port)
|
||||
}
|
||||
|
||||
/// Ajoute une route JSON dynamique
|
||||
///
|
||||
/// Crée un endpoint qui retourne du JSON. La closure fournie sera appelée
|
||||
/// à chaque requête GET sur le chemin spécifié.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin de la route (ex: "/api/hello")
|
||||
/// * `f` - Closure async retournant une valeur sérialisable
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// server.add_route("/api/status", || async {
|
||||
/// serde_json::json!({
|
||||
/// "status": "online",
|
||||
/// "version": "1.0.0"
|
||||
/// })
|
||||
/// }).await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn add_route<F, Fut, T>(&mut self, path: &str, f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = T> + Send + 'static,
|
||||
T: Serialize + Send + 'static,
|
||||
{
|
||||
let f = Arc::new(f);
|
||||
let handler = {
|
||||
let f = f.clone();
|
||||
move || {
|
||||
let f = f.clone();
|
||||
async move { Json(f().await) }
|
||||
}
|
||||
};
|
||||
|
||||
let route = Router::new().route("/", get(handler));
|
||||
|
||||
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 Axum standard
|
||||
pub async fn add_handler<H, T>(&mut self, path: &str, handler: H)
|
||||
where
|
||||
H: Handler<T, ()> + Clone + 'static,
|
||||
T: 'static,
|
||||
{
|
||||
let route = Router::new().route("/", get(handler.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 handler POST avec état
|
||||
pub async fn add_post_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("/", post(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 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)
|
||||
where
|
||||
E: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let serve = ServeEmbed::<E>::new();
|
||||
let mut r = self.router.write().await;
|
||||
|
||||
let route = Router::new().fallback_service(serve);
|
||||
*r = if path == "/" {
|
||||
std::mem::take(&mut *r).merge(route)
|
||||
} else {
|
||||
std::mem::take(&mut *r).nest(path, route)
|
||||
};
|
||||
}
|
||||
|
||||
/// Ajoute une Single Page Application (SPA)
|
||||
///
|
||||
/// Sert une application JavaScript moderne (Vue.js, React, etc.) avec support
|
||||
/// du routage côté client. Tous les chemins non trouvés renvoient `index.html`
|
||||
/// pour permettre au routeur JavaScript de gérer la navigation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin où monter l'application (souvent "/" ou "/app")
|
||||
///
|
||||
/// # Type Parameter
|
||||
///
|
||||
/// * `E` - Type RustEmbed contenant les fichiers de la SPA
|
||||
///
|
||||
/// # Exemple avec Vue.js
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # use rust_embed::RustEmbed;
|
||||
/// #[derive(RustEmbed, Clone)]
|
||||
/// #[folder = "webapp/dist"] // Build output de Vue.js
|
||||
/// struct WebApp;
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// server.add_spa::<WebApp>("/").await;
|
||||
/// // L'app Vue.js gère toutes les routes comme /about, /users, etc.
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Pour Vue.js/Vite, configure le `base` dans `vite.config.js` si tu montes
|
||||
/// sur un sous-chemin :
|
||||
/// ```javascript
|
||||
/// export default {
|
||||
/// base: '/app/'
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn add_spa<E>(&mut self, path: &str)
|
||||
where
|
||||
E: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let serve = ServeEmbed::<E>::with_parameters(
|
||||
Some("index.html".to_string()),
|
||||
axum_embed::FallbackBehavior::Ok,
|
||||
Some("index.html".to_string()),
|
||||
);
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
|
||||
let route = Router::new().fallback_service(serve);
|
||||
*r = if path == "/" {
|
||||
std::mem::take(&mut *r).merge(route)
|
||||
} else {
|
||||
std::mem::take(&mut *r).nest(path, route)
|
||||
};
|
||||
}
|
||||
|
||||
/// Ajoute une redirection HTTP
|
||||
///
|
||||
/// Redirige automatiquement les requêtes d'un chemin vers un autre avec un code 308 (permanent).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `from` - Chemin source (peut être "/" pour la racine)
|
||||
/// * `to` - Chemin de destination
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// // Rediriger la racine vers /app
|
||||
/// server.add_redirect("/", "/app").await;
|
||||
/// # }
|
||||
/// ```
|
||||
|
||||
pub async fn add_redirect(&mut self, from: &str, to: &str) {
|
||||
let to = to.to_string();
|
||||
let make_handler = || {
|
||||
let target = to.clone();
|
||||
get(move || async move { Redirect::permanent(&target) })
|
||||
};
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = if from == "/" {
|
||||
std::mem::take(&mut *r).merge(Router::new().route("/", make_handler()))
|
||||
} else {
|
||||
std::mem::take(&mut *r).nest(from, Router::new().route("/", make_handler()))
|
||||
};
|
||||
}
|
||||
|
||||
/// Ajoute une API documentée avec OpenAPI et 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
|
||||
///
|
||||
/// * `api_router` - Router Axum contenant les routes API
|
||||
/// * `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
|
||||
///
|
||||
/// ```ignore
|
||||
/// use utoipa::OpenApi;
|
||||
/// use axum::{Router, Json, routing::get};
|
||||
/// use serde::{Serialize, Deserialize};
|
||||
///
|
||||
/// #[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
/// struct User {
|
||||
/// id: u64,
|
||||
/// name: String,
|
||||
/// }
|
||||
///
|
||||
/// #[derive(utoipa::OpenApi)]
|
||||
/// #[openapi(
|
||||
/// paths(get_users),
|
||||
/// components(schemas(User))
|
||||
/// )]
|
||||
/// struct ApiDoc1;
|
||||
///
|
||||
/// #[utoipa::path(
|
||||
/// get,
|
||||
/// path = "/users",
|
||||
/// responses((status = 200, description = "List users"))
|
||||
/// )]
|
||||
/// async fn get_users() -> Json<Vec<User>> {
|
||||
/// Json(vec![])
|
||||
/// }
|
||||
///
|
||||
/// #[derive(utoipa::OpenApi)]
|
||||
/// #[openapi(
|
||||
/// paths(get_products),
|
||||
/// components(schemas(Product))
|
||||
/// )]
|
||||
/// struct ApiDoc2;
|
||||
///
|
||||
/// #[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;
|
||||
/// ```
|
||||
///
|
||||
/// 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;
|
||||
*api_r = Some(api_router.clone());
|
||||
drop(api_r);
|
||||
|
||||
let swagger_path = format!("/swagger-ui/{}", name);
|
||||
let swagger_path_static: &'static str = Box::leak(swagger_path.into_boxed_str());
|
||||
|
||||
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;
|
||||
*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
|
||||
///
|
||||
/// Lance le serveur sur le port configuré et met en place la gestion
|
||||
/// de Ctrl+C pour un arrêt gracieux.
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// server.start().await;
|
||||
/// server.wait().await; // Attend Ctrl+C
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn start(&mut self) {
|
||||
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
|
||||
);
|
||||
|
||||
let router = self.router.clone();
|
||||
let server_task = tokio::spawn(async move {
|
||||
let r = router.read().await.clone();
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, r.into_make_service()).await.unwrap();
|
||||
});
|
||||
|
||||
let shutdown_task = tokio::spawn(async move {
|
||||
signal::ctrl_c().await.expect("failed to listen for ctrl_c");
|
||||
info!("Ctrl+C reçu, arrêt gracieux");
|
||||
});
|
||||
|
||||
self.join_handle = Some(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = server_task => {},
|
||||
_ = shutdown_task => {},
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Attend la fin du serveur
|
||||
pub async fn wait(&mut self) {
|
||||
if let Some(h) = self.join_handle.take() {
|
||||
let _ = h.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les infos du serveur
|
||||
pub fn info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
name: self.name.clone(),
|
||||
base_url: self.base_url.clone(),
|
||||
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
|
||||
pub struct ServerBuilder {
|
||||
name: String,
|
||||
base_url: String,
|
||||
http_port: u16,
|
||||
}
|
||||
|
||||
impl ServerBuilder {
|
||||
/// Crée un nouveau builder
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - Nom du serveur
|
||||
/// * `base_url` - URL de base (ex: "http://localhost:3000")
|
||||
/// * `http_port` - Port HTTP
|
||||
pub fn new(name: impl Into<String>, base_url: impl Into<String>, http_port: u16) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
base_url: base_url.into(),
|
||||
http_port,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_configured() -> Self {
|
||||
let config = get_config();
|
||||
Self {
|
||||
name: "PMO-Music-Server".to_string(),
|
||||
base_url: config.get_base_url(),
|
||||
http_port: config.get_http_port(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit le serveur
|
||||
///
|
||||
/// Consomme le builder et retourne une instance de `Server` prête à l'emploi.
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pmoupnp::server::ServerBuilder;
|
||||
/// let mut server = ServerBuilder::new("MyAPI", "http://localhost:3000", 3000)
|
||||
/// .build();
|
||||
/// ```
|
||||
pub fn build(self) -> Server {
|
||||
Server::new(self.name, self.base_url, self.http_port)
|
||||
}
|
||||
}
|
||||
28
pmoupnp/Cargo.toml
Normal file
28
pmoupnp/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "pmoupnp"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmodidl = { path = "../pmodidl"}
|
||||
pmoutils = { path = "../pmoutils" }
|
||||
pmoserver = { path = "../pmoserver" }
|
||||
|
||||
url = "2.5.7"
|
||||
uuid = "1.18.1"
|
||||
hex = "0.4.3"
|
||||
base64 = "0.22.1"
|
||||
thiserror = "2.0.16"
|
||||
xmltree = "0.11.0"
|
||||
axum = "0.8.4"
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
once_cell = "1.20"
|
||||
parking_lot = "0.12"
|
||||
tracing = "0.1"
|
||||
bevy_reflect = "0.17.1"
|
||||
bevy_reflect_derive = "0.17.1"
|
||||
reqwest = "0.12.23"
|
||||
72
pmoupnp/errors.rs
Normal file
72
pmoupnp/errors.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use thiserror::Error;
|
||||
|
||||
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum StateVariableError {
|
||||
#[error("Conversion error: {0}")]
|
||||
ConversionError(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
#[error("Range error: {0}")]
|
||||
RangeError(String),
|
||||
|
||||
#[error("Type error: {0}")]
|
||||
TypeError(String),
|
||||
|
||||
#[error("Parse error: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Event condition error: {0}")]
|
||||
EventConditionError(String),
|
||||
|
||||
#[error("Arithmetic error: {0}")]
|
||||
ArithmeticError(String),
|
||||
|
||||
#[error("Unknown error: {0}")]
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<std::num::TryFromIntError> for StateVariableError {
|
||||
fn from(err: std::num::TryFromIntError) -> Self {
|
||||
StateVariableError::ConversionError(format!("Integer conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::str::ParseBoolError> for StateVariableError {
|
||||
fn from(err: std::str::ParseBoolError) -> Self {
|
||||
StateVariableError::ConversionError(format!("Boolean conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Error> for StateVariableError {
|
||||
fn from(err: uuid::Error) -> Self {
|
||||
StateVariableError::ConversionError(format!("UUID conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chrono::ParseError> for StateVariableError {
|
||||
fn from(err: chrono::ParseError) -> Self {
|
||||
StateVariableError::ConversionError(format!("Time conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for StateVariableError {
|
||||
fn from(err: url::ParseError) -> Self {
|
||||
StateVariableError::ConversionError(format!("URI conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for StateVariableError {
|
||||
fn from(err: base64::DecodeError) -> Self {
|
||||
StateVariableError::ConversionError(format!("Base64 conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<hex::FromHexError> for StateVariableError {
|
||||
fn from(err: hex::FromHexError) -> Self {
|
||||
StateVariableError::ConversionError(format!("Hex conversion error: {}", err))
|
||||
}
|
||||
}
|
||||
134
pmoupnp/src/actions/action_instance.rs
Normal file
134
pmoupnp/src/actions/action_instance.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::actions::Action;
|
||||
use crate::actions::ArgInstanceSet;
|
||||
use crate::actions::ActionInstance;
|
||||
use crate::UpnpInstance;
|
||||
use crate::UpnpObject;
|
||||
use crate::UpnpTyped;
|
||||
use crate::UpnpTypedInstance;
|
||||
use crate::UpnpObjectType;
|
||||
|
||||
impl UpnpObject for ActionInstance {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("action");
|
||||
|
||||
// <name>
|
||||
let mut name_elem = Element::new("name");
|
||||
name_elem.children.push(XMLNode::Text(self.get_name().clone()));
|
||||
elem.children.push(XMLNode::Element(name_elem));
|
||||
|
||||
// Utiliser le set d'instances d'arguments
|
||||
let args_container = self.arguments.to_xml_element();
|
||||
elem.children.push(XMLNode::Element(args_container));
|
||||
|
||||
elem
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpTyped for ActionInstance {
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType {
|
||||
return &self.object;
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpInstance for ActionInstance {
|
||||
|
||||
type Model = Action;
|
||||
|
||||
fn new(action: &Action) -> Self {
|
||||
// Créer les instances d'arguments
|
||||
let mut arguments = ArgInstanceSet::new();
|
||||
|
||||
for arg_model in action.arguments().all() {
|
||||
let arg_instance = Arc::new(crate::actions::ArgumentInstance::new(&*arg_model));
|
||||
if let Err(e) = arguments.insert(arg_instance) {
|
||||
tracing::error!("Failed to insert argument instance: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
object: UpnpObjectType {
|
||||
name: action.get_name().clone(),
|
||||
object_type: "ActionInstance".to_string(),
|
||||
},
|
||||
model: action.clone(),
|
||||
arguments, // ⬅️ Set d'instances, pas le modèle !
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
impl UpnpTypedInstance for ActionInstance {
|
||||
|
||||
fn get_model(&self) -> &Self::Model {
|
||||
&self.model
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionInstance {
|
||||
/// Retourne une instance d'argument par son nom.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - Nom de l'argument à rechercher
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Some(Arc<ArgumentInstance>)` si trouvé, `None` sinon.
|
||||
pub fn argument(&self, name: &str) -> Option<Arc<crate::actions::ArgumentInstance>> {
|
||||
self.arguments.get_by_name(name)
|
||||
}
|
||||
|
||||
/// Retourne le set d'instances d'arguments.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Référence vers le `ArgInstanceSet` contenant toutes les instances.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// for arg_instance in action_instance.arguments_set().all() {
|
||||
/// println!("Argument: {}", arg_instance.get_name());
|
||||
/// if let Some(var) = arg_instance.get_variable_instance() {
|
||||
/// println!(" Variable: {} = {}", var.get_name(), var.value());
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn arguments_set(&self) -> &ArgInstanceSet {
|
||||
&self.arguments // ⬅️ Retourne les INSTANCES, pas les modèles !
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::Action;
|
||||
use crate::UpnpInstance;
|
||||
|
||||
#[test]
|
||||
fn test_action_instance_creation() {
|
||||
let action = Action::new("Play".to_string());
|
||||
let instance = ActionInstance::new(&action);
|
||||
|
||||
assert_eq!(instance.get_name(), "Play");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_instance_has_argument_instances() {
|
||||
let action = Action::new("Play".to_string());
|
||||
let instance = ActionInstance::new(&action);
|
||||
|
||||
// Vérifier que arguments_set() retourne bien des instances
|
||||
assert!(instance.arguments_set().all().iter().all(|arg| {
|
||||
// Chaque argument doit être une ArgumentInstance
|
||||
arg.get_model(); // Cette méthode existe seulement sur les instances
|
||||
true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
21
pmoupnp/src/actions/action_instance_set.rs
Normal file
21
pmoupnp/src/actions/action_instance_set.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::{
|
||||
UpnpObject,
|
||||
actions::{ActionInstanceSet},
|
||||
};
|
||||
|
||||
use xmltree::{Element,XMLNode};
|
||||
|
||||
impl UpnpObject for ActionInstanceSet {
|
||||
// Méthode pour convertir en XML (à implémenter avec une librairie XML)
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("actionList");
|
||||
|
||||
for action in self.all() {
|
||||
let action_elem = action.to_xml_element(); // retourne un <action> complet
|
||||
elem.children.push(XMLNode::Element(action_elem));
|
||||
}
|
||||
|
||||
elem
|
||||
}
|
||||
}
|
||||
|
||||
62
pmoupnp/src/actions/action_methods.rs
Normal file
62
pmoupnp/src/actions/action_methods.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::UpnpModel;
|
||||
use crate::UpnpObject;
|
||||
use crate::UpnpObjectSetError;
|
||||
use crate::UpnpObjectType;
|
||||
use crate::UpnpTyped;
|
||||
use crate::actions::Action;
|
||||
use crate::actions::ActionInstance;
|
||||
use crate::actions::Argument;
|
||||
use crate::actions::ArgumentSet;
|
||||
|
||||
impl UpnpObject for Action {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut action_elem = Element::new("action");
|
||||
|
||||
// <name>
|
||||
let mut name_elem = Element::new("name");
|
||||
name_elem
|
||||
.children
|
||||
.push(XMLNode::Text(self.get_name().clone()));
|
||||
action_elem.children.push(XMLNode::Element(name_elem));
|
||||
|
||||
// <argumentList>
|
||||
let args_elem = self.arguments.to_xml_element();
|
||||
action_elem.children.push(XMLNode::Element(args_elem));
|
||||
|
||||
action_elem
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpModel for Action {
|
||||
type Instance = ActionInstance;
|
||||
}
|
||||
|
||||
impl UpnpTyped for Action {
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType {
|
||||
return &self.object;
|
||||
}
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn new(name: String) -> Action {
|
||||
Self {
|
||||
object: UpnpObjectType {
|
||||
name,
|
||||
object_type: "Action".to_string(),
|
||||
},
|
||||
arguments: ArgumentSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_argument(&mut self, arg: Arc<Argument>) -> Result<(), UpnpObjectSetError> {
|
||||
self.arguments.insert(arg)
|
||||
}
|
||||
|
||||
pub fn arguments(&self) -> &ArgumentSet {
|
||||
&self.arguments
|
||||
}
|
||||
}
|
||||
23
pmoupnp/src/actions/action_set_methods.rs
Normal file
23
pmoupnp/src/actions/action_set_methods.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::actions::{ActionInstanceSet, ActionSet};
|
||||
use crate::{UpnpModel, UpnpObject};
|
||||
|
||||
impl UpnpObject for ActionSet {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("actionList");
|
||||
|
||||
for action in self.all() {
|
||||
let action_elem = action.to_xml_element(); // retourne un <action> complet
|
||||
elem.children.push(XMLNode::Element(action_elem));
|
||||
}
|
||||
|
||||
elem
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
impl UpnpModel for ActionSet {
|
||||
type Instance = ActionInstanceSet;
|
||||
}
|
||||
31
pmoupnp/src/actions/arg_inst_set_methods.rs
Normal file
31
pmoupnp/src/actions/arg_inst_set_methods.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use std::sync::RwLock;
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::{actions::{ArgInstanceSet, ArgumentSet}, UpnpObject};
|
||||
|
||||
use crate::UpnpInstance;
|
||||
|
||||
impl UpnpObject for ArgInstanceSet {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("serviceStateTable");
|
||||
|
||||
for state_var in self.all() {
|
||||
let state_var_elem = state_var.to_xml_element(); // retourne un <stateVariable> complet
|
||||
elem.children.push(XMLNode::Element(state_var_elem));
|
||||
}
|
||||
|
||||
elem
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpInstance for ArgInstanceSet {
|
||||
type Model = ArgumentSet;
|
||||
|
||||
fn new(_: &ArgumentSet) -> Self {
|
||||
Self { objects: RwLock::new(HashMap::new()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
256
pmoupnp/src/actions/arg_instance_methods.rs
Normal file
256
pmoupnp/src/actions/arg_instance_methods.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
use std::{collections::HashMap, sync::{Arc, RwLock}};
|
||||
|
||||
use xmltree::Element;
|
||||
|
||||
use crate::{actions::{ActionInstanceSet, ActionSet, Argument, ArgumentInstance}, state_variables::StateVarInstance, UpnpInstance, UpnpObject, UpnpObjectType, UpnpTyped, UpnpTypedInstance};
|
||||
|
||||
|
||||
impl UpnpObject for ArgumentInstance {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
self.get_model().to_xml_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpTyped for ArgumentInstance {
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType {
|
||||
return &self.object;
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémentation de [`UpnpTypedInstance`] pour [`ArgumentInstance`].
|
||||
///
|
||||
/// Cette implémentation permet d'accéder au modèle [`Argument`] depuis l'instance
|
||||
/// via la méthode [`get_model()`](UpnpTypedInstance::get_model).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmoupnp::UpnpTypedInstance;
|
||||
///
|
||||
/// let arg_instance = ArgumentInstance::new(&arg_model);
|
||||
///
|
||||
/// // Accéder au modèle
|
||||
/// let model = arg_instance.get_model();
|
||||
/// println!("Direction: in={}, out={}", model.is_in(), model.is_out());
|
||||
/// println!("Related variable: {}", model.state_variable().get_name());
|
||||
/// ```
|
||||
impl UpnpTypedInstance for ArgumentInstance {
|
||||
/// Retourne une référence vers le modèle [`Argument`].
|
||||
///
|
||||
/// Permet d'accéder aux métadonnées statiques définies dans le modèle :
|
||||
/// - Direction de l'argument (in/out)
|
||||
/// - Variable d'état associée
|
||||
/// - Nom et type
|
||||
fn get_model(&self) -> &Self::Model {
|
||||
&self.model
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Implémentation de [`UpnpInstance`] pour [`ArgumentInstance`].
|
||||
///
|
||||
/// Cette implémentation fournit le constructeur standard qui crée une instance
|
||||
/// **non liée** d'un argument. La liaison à une [`StateVarInstance`] doit être
|
||||
/// effectuée séparément via [`bind_variable`](ArgumentInstance::bind_variable).
|
||||
///
|
||||
/// # Processus de construction en deux phases
|
||||
///
|
||||
/// ```text
|
||||
/// Phase 1 (new) Phase 2 (bind_variable)
|
||||
/// ┌─────────────────┐ ┌──────────────────────┐
|
||||
/// │ ArgumentInstance│ │ StateVarInstance │
|
||||
/// │ │ │ │
|
||||
/// │ model: Arc<...> │────>│ Liaison établie │
|
||||
/// │ variable: None │ │ variable: Some(...) │
|
||||
/// └─────────────────┘ └──────────────────────┘
|
||||
/// ↓ ↓
|
||||
/// Création bind_variable(&var)
|
||||
/// ```
|
||||
///
|
||||
/// # Pourquoi deux phases ?
|
||||
///
|
||||
/// 1. **Ordre de création** : Les modèles (`Argument`) existent avant les instances
|
||||
/// 2. **Validation différée** : Les dépendances sont vérifiées après instanciation
|
||||
/// 3. **Découplage** : Permet de créer des arguments même si les variables n'existent pas encore
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmoupnp::actions::{Argument, ArgumentInstance};
|
||||
/// use pmoupnp::UpnpInstance;
|
||||
///
|
||||
/// let arg_model = Argument::new_in("InstanceID".to_string(), instance_id_var);
|
||||
///
|
||||
/// // Création de l'instance - Phase 1
|
||||
/// let arg_instance = ArgumentInstance::new(&arg_model);
|
||||
///
|
||||
/// // À ce stade, l'instance existe mais n'est pas encore liée
|
||||
/// assert_eq!(arg_instance.get_name(), "InstanceID");
|
||||
/// assert!(arg_instance.get_variable_instance().is_none());
|
||||
///
|
||||
/// // La liaison se fera plus tard via bind_variable()
|
||||
/// ```
|
||||
impl UpnpInstance for ArgumentInstance {
|
||||
type Model = Argument;
|
||||
|
||||
/// Crée une nouvelle instance d'argument depuis son modèle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `from` - Référence vers le modèle [`Argument`] définissant cet argument
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Une nouvelle `ArgumentInstance` avec :
|
||||
/// - Nom copié depuis le modèle
|
||||
/// - Référence vers le modèle (clone)
|
||||
/// - `variable_instance` initialisé à `None` (liaison non établie)
|
||||
///
|
||||
/// # État initial
|
||||
///
|
||||
/// L'instance créée n'est **pas encore liée** à une variable d'état.
|
||||
/// Pour établir la liaison, appelez [`bind_variable`](ArgumentInstance::bind_variable).
|
||||
///
|
||||
/// # Thread-safety
|
||||
///
|
||||
/// L'instance retournée est thread-safe et peut être partagée via `Arc`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmoupnp::UpnpInstance;
|
||||
///
|
||||
/// // Création depuis un modèle
|
||||
/// let instance = ArgumentInstance::new(&arg_model);
|
||||
///
|
||||
/// // L'instance hérite des propriétés du modèle
|
||||
/// assert_eq!(instance.get_name(), arg_model.get_name());
|
||||
/// assert_eq!(instance.is_in(), arg_model.is_in());
|
||||
///
|
||||
/// // Mais n'a pas encore de valeur runtime
|
||||
/// assert!(instance.get_variable_instance().is_none());
|
||||
/// ```
|
||||
fn new(from: &Argument) -> Self {
|
||||
Self {
|
||||
// Copie des métadonnées depuis le modèle
|
||||
object: UpnpObjectType {
|
||||
name: from.get_name().clone(),
|
||||
object_type: "ArgumentInstance".to_string(),
|
||||
},
|
||||
|
||||
// Clone du modèle pour référence future
|
||||
model: from.clone(),
|
||||
|
||||
// Initialisation à None - sera lié plus tard via bind_variable()
|
||||
// Arc<RwLock<...>> permet la modification thread-safe post-construction
|
||||
variable_instance: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Méthodes de liaison et d'accès
|
||||
// ============================================================================
|
||||
|
||||
impl ArgumentInstance {
|
||||
/// Lie cet argument à une instance de variable d'état.
|
||||
///
|
||||
/// Cette méthode établit la connexion entre l'argument et sa variable d'état,
|
||||
/// permettant l'accès aux valeurs runtime lors de l'exécution d'actions.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `var_instance` - Instance de la variable d'état à lier
|
||||
///
|
||||
/// # Thread-safety
|
||||
///
|
||||
/// Cette méthode acquiert un **write lock** sur `variable_instance` et peut
|
||||
/// bloquer si d'autres threads lisent actuellement la valeur.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panique si le lock est empoisonné (poisoned), ce qui ne devrait jamais
|
||||
/// arriver dans un usage normal.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// let arg_instance = ArgumentInstance::new(&arg_model);
|
||||
/// let var_instance = Arc::new(StateVarInstance::new(&state_var));
|
||||
///
|
||||
/// // Établir la liaison
|
||||
/// arg_instance.bind_variable(var_instance.clone());
|
||||
///
|
||||
/// // Vérifier que la liaison est établie
|
||||
/// assert!(arg_instance.get_variable_instance().is_some());
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Cette méthode peut être appelée plusieurs fois pour changer la variable liée,
|
||||
/// bien que ce ne soit généralement pas recommandé dans un usage normal.
|
||||
pub fn bind_variable(&self, var_instance: Arc<StateVarInstance>) {
|
||||
let mut var = self.variable_instance.write().unwrap();
|
||||
*var = Some(var_instance);
|
||||
}
|
||||
|
||||
/// Retourne l'instance de variable d'état liée, si elle existe.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Some(Arc<StateVarInstance>)` si une variable est liée
|
||||
/// - `None` si aucune liaison n'a été établie via [`bind_variable`](Self::bind_variable)
|
||||
///
|
||||
/// # Thread-safety
|
||||
///
|
||||
/// Cette méthode acquiert un **read lock** sur `variable_instance`.
|
||||
/// Plusieurs threads peuvent lire simultanément sans blocage.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panique si le lock est empoisonné (poisoned).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Vérifier si la liaison existe
|
||||
/// if let Some(var) = arg_instance.get_variable_instance() {
|
||||
/// println!("Variable liée : {}", var.get_name());
|
||||
/// println!("Valeur actuelle : {}", var.value());
|
||||
/// } else {
|
||||
/// println!("Aucune variable liée");
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Usage dans l'exécution d'actions
|
||||
///
|
||||
/// ```ignore
|
||||
/// async fn execute_action(action: &ActionInstance) -> Result<(), ActionError> {
|
||||
/// for arg in action.arguments_set().all() {
|
||||
/// if let Some(var) = arg.get_variable_instance() {
|
||||
/// // Utiliser var.value() pour lire/écrire
|
||||
/// println!("Paramètre {} = {}", arg.get_name(), var.value());
|
||||
/// } else {
|
||||
/// return Err(ActionError::UnboundArgument(arg.get_name().to_string()));
|
||||
/// }
|
||||
/// }
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub fn get_variable_instance(&self) -> Option<Arc<StateVarInstance>> {
|
||||
self.variable_instance.read().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl UpnpInstance for ActionInstanceSet {
|
||||
type Model = ActionSet;
|
||||
|
||||
fn new(_: &ActionSet) -> Self {
|
||||
Self {
|
||||
objects: RwLock::new(HashMap::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
29
pmoupnp/src/actions/arg_set_methods.rs
Normal file
29
pmoupnp/src/actions/arg_set_methods.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crate::actions::ArgInstanceSet;
|
||||
use crate::UpnpModel;
|
||||
use crate::{
|
||||
UpnpObject,
|
||||
actions::{ArgumentSet},
|
||||
};
|
||||
use xmltree::Element;
|
||||
|
||||
impl UpnpObject for ArgumentSet {
|
||||
// Méthode pour convertir en XML (à implémenter avec une librairie XML)
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("argumentList");
|
||||
|
||||
for arg in self.all() {
|
||||
let arg_elem = arg.to_xml_element(); // toujours un <argumentList> contenant 1 ou 2 <argument>
|
||||
|
||||
// Pour InOut, on ajoute tous les enfants du <argumentList> généré
|
||||
for child in arg_elem.children {
|
||||
elem.children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
elem
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpModel for ArgumentSet {
|
||||
type Instance = ArgInstanceSet;
|
||||
}
|
||||
116
pmoupnp/src/actions/argument_methods.rs
Normal file
116
pmoupnp/src/actions/argument_methods.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::{
|
||||
actions::{Argument, ArgumentInstance}, state_variables::StateVariable, UpnpModel, UpnpObject, UpnpObjectType, UpnpTyped
|
||||
};
|
||||
|
||||
impl UpnpTyped for Argument {
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType {
|
||||
&self.object
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpObject for Argument {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut parent = Element::new("argumentList");
|
||||
|
||||
if self.is_in() && self.is_out() {
|
||||
// InOut → deux arguments
|
||||
parent.children.push(XMLNode::Element(make_argument_elem(
|
||||
self.get_name(),
|
||||
"in",
|
||||
self.state_variable().get_name(),
|
||||
)));
|
||||
parent.children.push(XMLNode::Element(make_argument_elem(
|
||||
self.get_name(),
|
||||
"out",
|
||||
self.state_variable().get_name(),
|
||||
)));
|
||||
} else {
|
||||
// Cas simple
|
||||
let direction = if self.is_in() { "in" } else { "out" };
|
||||
parent.children.push(XMLNode::Element(make_argument_elem(
|
||||
self.get_name(),
|
||||
direction,
|
||||
self.state_variable().get_name(),
|
||||
)));
|
||||
}
|
||||
|
||||
parent
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpModel for Argument {
|
||||
type Instance = ArgumentInstance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl Argument {
|
||||
fn new(name: String, state_variable: Arc<StateVariable>) -> Self {
|
||||
Self {
|
||||
object: UpnpObjectType {
|
||||
name,
|
||||
object_type: "Argument".to_string(),
|
||||
},
|
||||
state_variable,
|
||||
is_in: false,
|
||||
is_out: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_in(name: String, state_variable: Arc<StateVariable>) -> Self {
|
||||
let mut arg = Self::new(name, state_variable);
|
||||
arg.is_in = true;
|
||||
arg
|
||||
}
|
||||
|
||||
pub fn new_out(name: String, state_variable: Arc<StateVariable>) -> Self {
|
||||
let mut arg = Self::new(name, state_variable);
|
||||
arg.is_out = true;
|
||||
arg
|
||||
}
|
||||
|
||||
pub fn new_in_out(name: String, state_variable: Arc<StateVariable>) -> Self {
|
||||
let mut arg = Self::new(name, state_variable);
|
||||
arg.is_in = true;
|
||||
arg.is_out = true;
|
||||
arg
|
||||
}
|
||||
|
||||
pub fn state_variable(&self) -> &StateVariable {
|
||||
&self.state_variable
|
||||
}
|
||||
|
||||
pub fn is_in(&self) -> bool {
|
||||
self.is_in
|
||||
}
|
||||
|
||||
pub fn is_out(&self) -> bool {
|
||||
self.is_out
|
||||
}
|
||||
}
|
||||
|
||||
/// Fabrique un <argument> complet avec ses sous-éléments
|
||||
fn make_argument_elem(name: &str, direction: &str, state_var_name: &str) -> Element {
|
||||
let mut arg = Element::new("argument");
|
||||
|
||||
let mut name_elem = Element::new("name");
|
||||
name_elem.children.push(XMLNode::Text(name.to_string()));
|
||||
|
||||
let mut dir_elem = Element::new("direction");
|
||||
dir_elem.children.push(XMLNode::Text(direction.to_string()));
|
||||
|
||||
let mut rel_elem = Element::new("relatedStateVariable");
|
||||
rel_elem
|
||||
.children
|
||||
.push(XMLNode::Text(state_var_name.to_string()));
|
||||
|
||||
arg.children.push(XMLNode::Element(name_elem));
|
||||
arg.children.push(XMLNode::Element(dir_elem));
|
||||
arg.children.push(XMLNode::Element(rel_elem));
|
||||
|
||||
arg
|
||||
}
|
||||
37
pmoupnp/src/actions/errors.rs
Normal file
37
pmoupnp/src/actions/errors.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ActionError {
|
||||
#[error("Action error: {0}")]
|
||||
GeneralError(String),
|
||||
|
||||
#[error("Argument error: {0}")]
|
||||
ArgumentError(String),
|
||||
|
||||
#[error("Set operation error: {0}")]
|
||||
SetError(String),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ActionError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
ActionError::GeneralError(format!("IO error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ArgumentError {
|
||||
#[error("Argument error: {0}")]
|
||||
GeneralError(String),
|
||||
|
||||
#[error("Argument error: {0}")]
|
||||
ArgumentError(String),
|
||||
|
||||
#[error("Set operation error: {0}")]
|
||||
SetError(String),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ArgumentError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
ArgumentError::GeneralError(format!("IO error: {}", err))
|
||||
}
|
||||
}
|
||||
280
pmoupnp/src/actions/macros.rs
Normal file
280
pmoupnp/src/actions/macros.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
/// Macro pour définir facilement une action UPnP.
|
||||
///
|
||||
/// Cette macro simplifie la création d'actions UPnP statiques en générant
|
||||
/// automatiquement le code nécessaire pour initialiser une action avec ses arguments.
|
||||
///
|
||||
/// # Syntaxe
|
||||
///
|
||||
/// ## Action avec arguments
|
||||
///
|
||||
/// ```ignore
|
||||
/// define_action! {
|
||||
/// pub static ACTION_NAME = "ActionName" {
|
||||
/// in "ParamName" => VARIABLE_REF,
|
||||
/// out "ResultParam" => RESULT_VAR,
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## Action sans arguments
|
||||
///
|
||||
/// ```ignore
|
||||
/// define_action! {
|
||||
/// pub static ACTION_NAME = "ActionName"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - `ACTION_NAME` : Nom de la constante statique Rust
|
||||
/// - `"ActionName"` : Nom de l'action UPnP (chaîne littérale)
|
||||
/// - `in` ou `out` : Direction de l'argument (entrée ou sortie)
|
||||
/// - `"ParamName"` : Nom du paramètre UPnP (chaîne littérale)
|
||||
/// - `VARIABLE_REF` : Référence vers une `Lazy<Arc<StateVariable>>`
|
||||
///
|
||||
/// # Type de retour
|
||||
///
|
||||
/// La macro génère une `Lazy<Arc<Action>>` qui sera initialisée lors du premier accès.
|
||||
///
|
||||
/// # Prérequis
|
||||
///
|
||||
/// Les variables d'état référencées doivent être définies comme :
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub static MY_VAR: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::UI4, "MyVar".to_string()))
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use once_cell::sync::Lazy;
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// // Définir les variables d'état
|
||||
/// pub static INSTANCE_ID: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::UI4, "InstanceID".to_string()))
|
||||
/// });
|
||||
///
|
||||
/// pub static TRANSPORT_URI: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::String, "TransportURI".to_string()))
|
||||
/// });
|
||||
///
|
||||
/// // Définir une action avec arguments
|
||||
/// define_action! {
|
||||
/// pub static PLAY = "Play" {
|
||||
/// in "InstanceID" => INSTANCE_ID,
|
||||
/// in "Speed" => TRANSPORT_SPEED,
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // Action sans arguments
|
||||
/// define_action! {
|
||||
/// pub static PAUSE = "Pause"
|
||||
/// }
|
||||
///
|
||||
/// // Utilisation
|
||||
/// fn main() {
|
||||
/// let play_action = &*PLAY; // Déréférence la Lazy<Arc<Action>>
|
||||
/// println!("Action: {}", play_action.get_name());
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Notes d'implémentation
|
||||
///
|
||||
/// - Les `Arc<StateVariable>` sont clonés (shallow copy du pointeur)
|
||||
/// - Chaque `Argument` est wrappé dans un `Arc`
|
||||
/// - L'`Action` finale est wrappée dans un `Arc`
|
||||
/// - Initialisation paresseuse via `Lazy` (thread-safe)
|
||||
#[macro_export]
|
||||
macro_rules! define_action {
|
||||
// Variante sans arguments
|
||||
(pub static $name:ident = $action_name:literal) => {
|
||||
pub static $name: once_cell::sync::Lazy<std::sync::Arc<$crate::actions::Action>> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
std::sync::Arc::new($crate::actions::Action::new($action_name.to_string()))
|
||||
});
|
||||
};
|
||||
|
||||
// Variante avec arguments
|
||||
(pub static $name:ident = $action_name:literal {
|
||||
$(
|
||||
$direction:ident $arg_name:literal => $var_ref:expr
|
||||
),* $(,)?
|
||||
}) => {
|
||||
pub static $name: once_cell::sync::Lazy<std::sync::Arc<$crate::actions::Action>> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
let mut ac = $crate::actions::Action::new($action_name.to_string());
|
||||
|
||||
$(
|
||||
ac.add_argument(
|
||||
define_action!(@arg $direction $arg_name, $var_ref)
|
||||
);
|
||||
)*
|
||||
|
||||
std::sync::Arc::new(ac)
|
||||
});
|
||||
};
|
||||
|
||||
// Helper interne pour créer un argument d'entrée
|
||||
(@arg in $name:literal, $var:expr) => {
|
||||
std::sync::Arc::new(
|
||||
$crate::actions::Argument::new_in(
|
||||
$name.to_string(),
|
||||
std::sync::Arc::clone(&$var)
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
// Helper interne pour créer un argument de sortie
|
||||
(@arg out $name:literal, $var:expr) => {
|
||||
std::sync::Arc::new(
|
||||
$crate::actions::Argument::new_out(
|
||||
$name.to_string(),
|
||||
std::sync::Arc::clone(&$var)
|
||||
)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Macro pour définir plusieurs actions UPnP en une seule déclaration.
|
||||
///
|
||||
/// Cette macro permet de regrouper la définition de plusieurs actions pour
|
||||
/// améliorer la lisibilité et réduire la répétition de code.
|
||||
///
|
||||
/// # Syntaxe
|
||||
///
|
||||
/// ```ignore
|
||||
/// define_actions! {
|
||||
/// ACTION1 = "Action1" {
|
||||
/// in "Param1" => VAR1,
|
||||
/// out "Result1" => VAR2,
|
||||
/// }
|
||||
///
|
||||
/// ACTION2 = "Action2" {
|
||||
/// in "Param1" => VAR1,
|
||||
/// }
|
||||
///
|
||||
/// ACTION3 = "Action3"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// Chaque action suit la même syntaxe que [`define_action!`], mais sans
|
||||
/// le mot-clé `pub static`.
|
||||
///
|
||||
/// # Type de retour
|
||||
///
|
||||
/// Génère une `Lazy<Arc<Action>>` pour chaque action définie.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use once_cell::sync::Lazy;
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// // Variables d'état
|
||||
/// pub static INSTANCE_ID: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::UI4, "InstanceID".to_string()))
|
||||
/// });
|
||||
///
|
||||
/// pub static TRANSPORT_URI: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::String, "TransportURI".to_string()))
|
||||
/// });
|
||||
///
|
||||
/// pub static URI_METADATA: Lazy<Arc<StateVariable>> = Lazy::new(|| {
|
||||
/// Arc::new(StateVariable::new(StateVarType::String, "URIMetaData".to_string()))
|
||||
/// });
|
||||
///
|
||||
/// // Définir plusieurs actions ensemble
|
||||
/// define_actions! {
|
||||
/// PLAY = "Play" {
|
||||
/// in "InstanceID" => INSTANCE_ID,
|
||||
/// }
|
||||
///
|
||||
/// STOP = "Stop" {
|
||||
/// in "InstanceID" => INSTANCE_ID,
|
||||
/// }
|
||||
///
|
||||
/// PAUSE = "Pause" {
|
||||
/// in "InstanceID" => INSTANCE_ID,
|
||||
/// }
|
||||
///
|
||||
/// SET_AV_TRANSPORT_URI = "SetAVTransportURI" {
|
||||
/// in "InstanceID" => INSTANCE_ID,
|
||||
/// in "CurrentURI" => TRANSPORT_URI,
|
||||
/// in "CurrentURIMetaData" => URI_METADATA,
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // Utilisation
|
||||
/// fn setup_transport_service() {
|
||||
/// let actions = vec![&*PLAY, &*STOP, &*PAUSE, &*SET_AV_TRANSPORT_URI];
|
||||
/// for action in actions {
|
||||
/// println!("Action: {}", action.get_name());
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Avantages
|
||||
///
|
||||
/// - Regroupement logique des actions d'un service
|
||||
/// - Réduction de la répétition de `pub static` et `define_action!`
|
||||
/// - Meilleure lisibilité pour les services avec nombreuses actions
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// - Toutes les actions définies sont publiques (`pub`)
|
||||
/// - Chaque action est indépendante et peut être utilisée séparément
|
||||
/// - La macro se développe en plusieurs appels à [`define_action!`]
|
||||
#[macro_export]
|
||||
macro_rules! define_actions {
|
||||
// Variante avec arguments pour chaque action
|
||||
(
|
||||
$(
|
||||
$name:ident = $action_name:literal {
|
||||
$(
|
||||
$direction:ident $arg_name:literal => $var_ref:expr
|
||||
),* $(,)?
|
||||
}
|
||||
)*
|
||||
) => {
|
||||
$(
|
||||
define_action! {
|
||||
pub static $name = $action_name {
|
||||
$($direction $arg_name => $var_ref),*
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
|
||||
// Variante mixte : actions avec et sans arguments
|
||||
(
|
||||
$(
|
||||
$name:ident = $action_name:literal $({
|
||||
$(
|
||||
$direction:ident $arg_name:literal => $var_ref:expr
|
||||
),* $(,)?
|
||||
})?
|
||||
)*
|
||||
) => {
|
||||
$(
|
||||
$(
|
||||
define_action! {
|
||||
pub static $name = $action_name {
|
||||
$($direction $arg_name => $var_ref),*
|
||||
}
|
||||
}
|
||||
)?
|
||||
$(
|
||||
// Cas sans accolades (action sans arguments)
|
||||
#[allow(unused)]
|
||||
define_action! {
|
||||
pub static $name = $action_name
|
||||
}
|
||||
)?
|
||||
)*
|
||||
};
|
||||
}
|
||||
116
pmoupnp/src/actions/mod.rs
Normal file
116
pmoupnp/src/actions/mod.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
mod errors;
|
||||
|
||||
mod action_instance;
|
||||
mod action_instance_set;
|
||||
mod action_methods;
|
||||
mod action_set_methods;
|
||||
mod arg_inst_set_methods;
|
||||
mod arg_instance_methods;
|
||||
mod arg_set_methods;
|
||||
mod argument_methods;
|
||||
|
||||
mod macros;
|
||||
|
||||
use crate::{
|
||||
UpnpObjectSet, UpnpObjectType,
|
||||
state_variables::{StateVarInstance, StateVariable},
|
||||
};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
pub use errors::ActionError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Action {
|
||||
object: UpnpObjectType,
|
||||
arguments: ArgumentSet,
|
||||
}
|
||||
|
||||
pub type ActionSet = UpnpObjectSet<Action>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActionInstance {
|
||||
object: UpnpObjectType,
|
||||
model: Action,
|
||||
arguments: ArgInstanceSet,
|
||||
}
|
||||
|
||||
pub type ActionInstanceSet = UpnpObjectSet<ActionInstance>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Argument {
|
||||
object: UpnpObjectType,
|
||||
state_variable: Arc<StateVariable>,
|
||||
is_in: bool,
|
||||
is_out: bool,
|
||||
}
|
||||
|
||||
pub type ArgumentSet = UpnpObjectSet<Argument>;
|
||||
|
||||
/// Instance d'un argument d'action UPnP.
|
||||
///
|
||||
/// Un `ArgumentInstance` représente un argument concret utilisé lors de l'exécution
|
||||
/// d'une action. Contrairement au modèle [`Argument`] qui définit la structure,
|
||||
/// l'instance maintient une liaison dynamique vers une [`StateVarInstance`] qui
|
||||
/// contient la valeur runtime.
|
||||
///
|
||||
/// # Cycle de vie
|
||||
///
|
||||
/// 1. **Création** : Instanciation via [`UpnpInstance::new`] avec `variable_instance = None`
|
||||
/// 2. **Liaison** : Association à une [`StateVarInstance`] via [`bind_variable`](Self::bind_variable)
|
||||
/// 3. **Utilisation** : Accès à la valeur runtime via [`get_variable_instance`](Self::get_variable_instance)
|
||||
///
|
||||
/// # Pourquoi `variable_instance` est optionnel ?
|
||||
///
|
||||
/// La liaison ne peut pas être faite dans le constructeur car :
|
||||
/// - Les `StateVarInstance` sont créées **après** les modèles
|
||||
/// - Les `ActionInstance` sont créées **avant** que toutes les variables soient disponibles
|
||||
/// - La validation des dépendances se fait en deux phases
|
||||
///
|
||||
/// # Thread-safety
|
||||
///
|
||||
/// Le champ `variable_instance` est protégé par un `RwLock` pour permettre :
|
||||
/// - La liaison après création (write lock)
|
||||
/// - L'accès concurrent en lecture (read lock)
|
||||
/// - L'utilisation dans un contexte multi-thread
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmoupnp::actions::{Argument, ArgumentInstance};
|
||||
/// use pmoupnp::state_variables::StateVarInstance;
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// // Phase 1 : Créer l'instance (sans liaison)
|
||||
/// let arg_model = Argument::new_in("Volume".to_string(), volume_var);
|
||||
/// let arg_instance = ArgumentInstance::new(&arg_model);
|
||||
/// assert!(arg_instance.get_variable_instance().is_none());
|
||||
///
|
||||
/// // Phase 2 : Lier à une variable d'état
|
||||
/// let var_instance = Arc::new(StateVarInstance::new(&volume_var));
|
||||
/// arg_instance.bind_variable(var_instance.clone());
|
||||
/// assert!(arg_instance.get_variable_instance().is_some());
|
||||
///
|
||||
/// // Phase 3 : Utiliser la valeur runtime
|
||||
/// if let Some(var) = arg_instance.get_variable_instance() {
|
||||
/// println!("Current value: {}", var.value());
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArgumentInstance {
|
||||
/// Métadonnées de l'objet UPnP
|
||||
object: UpnpObjectType,
|
||||
|
||||
/// Référence vers le modèle définissant la structure
|
||||
model: Argument,
|
||||
|
||||
/// Liaison optionnelle vers l'instance de variable d'état.
|
||||
///
|
||||
/// - `None` : Pas encore liée (état initial après construction)
|
||||
/// - `Some(Arc<StateVarInstance>)` : Liée et prête à l'emploi
|
||||
///
|
||||
/// Protégée par `RwLock` pour permettre la liaison post-construction
|
||||
/// et l'accès concurrent en lecture.
|
||||
variable_instance: Arc<RwLock<Option<Arc<StateVarInstance>>>>,
|
||||
}
|
||||
|
||||
pub type ArgInstanceSet = UpnpObjectSet<ArgumentInstance>;
|
||||
309
pmoupnp/src/devices/device.rs
Normal file
309
pmoupnp/src/devices/device.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
//! Définition du modèle Device UPnP.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
UpnpTyped, UpnpObjectType,
|
||||
services::Service,
|
||||
};
|
||||
|
||||
use super::errors::DeviceError;
|
||||
|
||||
/// Modèle d'un device UPnP.
|
||||
///
|
||||
/// Représente la définition d'un device selon la spécification UPnP Device Architecture.
|
||||
/// Un device peut contenir plusieurs services et éventuellement des sous-devices.
|
||||
#[derive(Debug)]
|
||||
pub struct Device {
|
||||
/// Métadonnées de l'objet
|
||||
object: UpnpObjectType,
|
||||
|
||||
/// Type de device UPnP (ex: "MediaRenderer", "MediaServer")
|
||||
device_type: String,
|
||||
|
||||
/// Version du device
|
||||
version: u8,
|
||||
|
||||
/// Nom convivial du device
|
||||
friendly_name: String,
|
||||
|
||||
/// Fabricant
|
||||
manufacturer: String,
|
||||
|
||||
/// URL du fabricant
|
||||
manufacturer_url: Option<String>,
|
||||
|
||||
/// Description du modèle
|
||||
model_description: Option<String>,
|
||||
|
||||
/// Nom du modèle
|
||||
model_name: String,
|
||||
|
||||
/// Numéro du modèle
|
||||
model_number: Option<String>,
|
||||
|
||||
/// URL du modèle
|
||||
model_url: Option<String>,
|
||||
|
||||
/// Numéro de série
|
||||
serial_number: Option<String>,
|
||||
|
||||
/// UDN (Unique Device Name) - sera généré à l'instance
|
||||
udn_prefix: String,
|
||||
|
||||
/// UPC (Universal Product Code)
|
||||
upc: Option<String>,
|
||||
|
||||
/// URL de l'icône
|
||||
icon_url: Option<String>,
|
||||
|
||||
/// URL de présentation
|
||||
presentation_url: Option<String>,
|
||||
|
||||
/// Services du device
|
||||
services: RwLock<HashMap<String, Arc<Service>>>,
|
||||
|
||||
/// Sous-devices (embedded devices)
|
||||
devices: RwLock<HashMap<String, Arc<Device>>>,
|
||||
}
|
||||
|
||||
impl Clone for Device {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
object: self.object.clone(),
|
||||
device_type: self.device_type.clone(),
|
||||
version: self.version,
|
||||
friendly_name: self.friendly_name.clone(),
|
||||
manufacturer: self.manufacturer.clone(),
|
||||
manufacturer_url: self.manufacturer_url.clone(),
|
||||
model_description: self.model_description.clone(),
|
||||
model_name: self.model_name.clone(),
|
||||
model_number: self.model_number.clone(),
|
||||
model_url: self.model_url.clone(),
|
||||
serial_number: self.serial_number.clone(),
|
||||
udn_prefix: self.udn_prefix.clone(),
|
||||
upc: self.upc.clone(),
|
||||
icon_url: self.icon_url.clone(),
|
||||
presentation_url: self.presentation_url.clone(),
|
||||
services: RwLock::new(self.services.read().unwrap().clone()),
|
||||
devices: RwLock::new(self.devices.read().unwrap().clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Device {
|
||||
/// Crée un nouveau modèle de device.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - Nom unique du device
|
||||
/// * `device_type` - Type UPnP du device
|
||||
/// * `friendly_name` - Nom convivial pour l'utilisateur
|
||||
pub fn new(name: String, device_type: String, friendly_name: String) -> Self {
|
||||
Self {
|
||||
object: UpnpObjectType {
|
||||
name: name.clone(),
|
||||
object_type: "Device".to_string(),
|
||||
},
|
||||
device_type,
|
||||
version: 1,
|
||||
friendly_name,
|
||||
manufacturer: "PMOMusic".to_string(),
|
||||
manufacturer_url: None,
|
||||
model_description: None,
|
||||
model_name: name.clone(),
|
||||
model_number: None,
|
||||
model_url: None,
|
||||
serial_number: None,
|
||||
udn_prefix: "pmomusic".to_string(),
|
||||
upc: None,
|
||||
icon_url: None,
|
||||
presentation_url: None,
|
||||
services: RwLock::new(HashMap::new()),
|
||||
devices: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le type de device UPnP.
|
||||
///
|
||||
/// Format: `urn:schemas-upnp-org:device:{type}:{version}`
|
||||
pub fn device_type(&self) -> String {
|
||||
format!("urn:schemas-upnp-org:device:{}:{}", self.device_type, self.version)
|
||||
}
|
||||
|
||||
/// Définit la version du device.
|
||||
pub fn set_version(&mut self, version: u8) -> Result<(), DeviceError> {
|
||||
if version == 0 {
|
||||
return Err(DeviceError::InvalidVersion);
|
||||
}
|
||||
self.version = version;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retourne la version du device.
|
||||
pub fn version(&self) -> u8 {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// Définit le fabricant.
|
||||
pub fn set_manufacturer(&mut self, manufacturer: String) {
|
||||
self.manufacturer = manufacturer;
|
||||
}
|
||||
|
||||
/// Définit l'URL du fabricant.
|
||||
pub fn set_manufacturer_url(&mut self, url: String) {
|
||||
self.manufacturer_url = Some(url);
|
||||
}
|
||||
|
||||
/// Définit la description du modèle.
|
||||
pub fn set_model_description(&mut self, description: String) {
|
||||
self.model_description = Some(description);
|
||||
}
|
||||
|
||||
/// Définit le nom du modèle.
|
||||
pub fn set_model_name(&mut self, name: String) {
|
||||
self.model_name = name;
|
||||
}
|
||||
|
||||
/// Définit le numéro du modèle.
|
||||
pub fn set_model_number(&mut self, number: String) {
|
||||
self.model_number = Some(number);
|
||||
}
|
||||
|
||||
/// Définit le numéro de série.
|
||||
pub fn set_serial_number(&mut self, serial: String) {
|
||||
self.serial_number = Some(serial);
|
||||
}
|
||||
|
||||
/// Définit le préfixe UDN.
|
||||
pub fn set_udn_prefix(&mut self, prefix: String) {
|
||||
self.udn_prefix = prefix;
|
||||
}
|
||||
|
||||
/// Retourne le préfixe UDN.
|
||||
pub fn udn_prefix(&self) -> &str {
|
||||
&self.udn_prefix
|
||||
}
|
||||
|
||||
/// Définit l'URL de présentation.
|
||||
pub fn set_presentation_url(&mut self, url: String) {
|
||||
self.presentation_url = Some(url);
|
||||
}
|
||||
|
||||
/// Ajoute un service au device.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Retourne une erreur si un service avec le même nom existe déjà.
|
||||
pub fn add_service(&self, service: Arc<Service>) -> Result<(), DeviceError> {
|
||||
let mut services = self.services.write().unwrap();
|
||||
let name = service.get_name().to_string();
|
||||
|
||||
if services.contains_key(&name) {
|
||||
return Err(DeviceError::ServiceAlreadyExists(name));
|
||||
}
|
||||
|
||||
services.insert(name, service);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retourne tous les services.
|
||||
pub fn services(&self) -> Vec<Arc<Service>> {
|
||||
self.services.read().unwrap().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Retourne un service par nom.
|
||||
pub fn get_service(&self, name: &str) -> Option<Arc<Service>> {
|
||||
self.services.read().unwrap().get(name).cloned()
|
||||
}
|
||||
|
||||
/// Ajoute un sous-device.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Retourne une erreur si un device avec le même nom existe déjà.
|
||||
pub fn add_device(&self, device: Arc<Device>) -> Result<(), DeviceError> {
|
||||
let mut devices = self.devices.write().unwrap();
|
||||
let name = device.get_name().to_string();
|
||||
|
||||
if devices.contains_key(&name) {
|
||||
return Err(DeviceError::DeviceAlreadyExists(name));
|
||||
}
|
||||
|
||||
devices.insert(name, device);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retourne tous les sous-devices.
|
||||
pub fn devices(&self) -> Vec<Arc<Device>> {
|
||||
self.devices.read().unwrap().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Retourne le nom convivial.
|
||||
pub fn friendly_name(&self) -> &str {
|
||||
&self.friendly_name
|
||||
}
|
||||
|
||||
/// Retourne le fabricant.
|
||||
pub fn manufacturer(&self) -> &str {
|
||||
&self.manufacturer
|
||||
}
|
||||
|
||||
/// Retourne le nom du modèle.
|
||||
pub fn model_name(&self) -> &str {
|
||||
&self.model_name
|
||||
}
|
||||
|
||||
/// Retourne la description du modèle.
|
||||
pub fn model_description(&self) -> Option<&str> {
|
||||
self.model_description.as_deref()
|
||||
}
|
||||
|
||||
/// Retourne l'URL de présentation.
|
||||
pub fn presentation_url(&self) -> Option<&str> {
|
||||
self.presentation_url.as_deref()
|
||||
}
|
||||
|
||||
/// Retourne l'URL du fabricant.
|
||||
pub fn manufacturer_url(&self) -> Option<&str> {
|
||||
self.manufacturer_url.as_deref()
|
||||
}
|
||||
|
||||
/// Retourne le numéro du modèle.
|
||||
pub fn model_number(&self) -> Option<&str> {
|
||||
self.model_number.as_deref()
|
||||
}
|
||||
|
||||
/// Retourne l'URL du modèle.
|
||||
pub fn model_url(&self) -> Option<&str> {
|
||||
self.model_url.as_deref()
|
||||
}
|
||||
|
||||
/// Retourne le numéro de série.
|
||||
pub fn serial_number(&self) -> Option<&str> {
|
||||
self.serial_number.as_deref()
|
||||
}
|
||||
|
||||
/// Retourne l'UPC.
|
||||
pub fn upc(&self) -> Option<&str> {
|
||||
self.upc.as_deref()
|
||||
}
|
||||
|
||||
/// Retourne l'URL de l'icône.
|
||||
pub fn icon_url(&self) -> Option<&str> {
|
||||
self.icon_url.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Device {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "Device({}:{})", self.get_name(), self.version)
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpTyped for Device {
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType {
|
||||
&self.object
|
||||
}
|
||||
}
|
||||
374
pmoupnp/src/devices/device_instance.rs
Normal file
374
pmoupnp/src/devices/device_instance.rs
Normal file
@@ -0,0 +1,374 @@
|
||||
//! Implémentation de DeviceInstance.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use tracing::info;
|
||||
use xmltree::{Element, XMLNode, EmitterConfig};
|
||||
|
||||
use crate::{
|
||||
devices::{Device, errors::DeviceError},
|
||||
services::ServiceInstance,
|
||||
UpnpObject, UpnpInstance, UpnpTyped, UpnpTypedInstance, UpnpObjectType,
|
||||
};
|
||||
|
||||
/// Instance d'un device UPnP.
|
||||
///
|
||||
/// Représente une instance concrète d'un device UPnP, avec ses services instanciés
|
||||
/// et son UDN unique.
|
||||
pub struct DeviceInstance {
|
||||
/// Métadonnées de l'objet
|
||||
object: UpnpObjectType,
|
||||
|
||||
/// Référence vers le modèle
|
||||
model: Arc<Device>,
|
||||
|
||||
/// UDN unique pour cette instance
|
||||
udn: String,
|
||||
|
||||
/// URL de base du serveur
|
||||
server_base_url: String,
|
||||
|
||||
/// Instances de services
|
||||
services: RwLock<HashMap<String, Arc<ServiceInstance>>>,
|
||||
|
||||
/// Instances de sous-devices
|
||||
devices: RwLock<HashMap<String, Arc<DeviceInstance>>>,
|
||||
}
|
||||
|
||||
impl Clone for DeviceInstance {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
object: self.object.clone(),
|
||||
model: Arc::clone(&self.model),
|
||||
udn: self.udn.clone(),
|
||||
server_base_url: self.server_base_url.clone(),
|
||||
services: RwLock::new(self.services.read().unwrap().clone()),
|
||||
devices: RwLock::new(self.devices.read().unwrap().clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DeviceInstance {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DeviceInstance")
|
||||
.field("object", &self.object)
|
||||
.field("udn", &self.udn)
|
||||
.field("server_base_url", &self.server_base_url)
|
||||
.field("services", &self.services)
|
||||
.field("devices", &self.devices)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpTyped for DeviceInstance {
|
||||
fn as_upnp_object_type(&self) -> &UpnpObjectType {
|
||||
&self.object
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpInstance for DeviceInstance {
|
||||
type Model = Device;
|
||||
|
||||
fn new(model: &Device) -> Self {
|
||||
// Obtenir ou créer un UDN persistant via la configuration
|
||||
let device_name = model.get_name();
|
||||
|
||||
let udn = if let Ok(config_udn) = pmoconfig::get_config().get_device_udn("mediarenderer", device_name) {
|
||||
config_udn
|
||||
} else {
|
||||
// Fallback : générer un UDN
|
||||
tracing::warn!("Failed to get/save UDN from config, using generated UUID");
|
||||
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 {
|
||||
object: UpnpObjectType {
|
||||
name: model.get_name().to_string(),
|
||||
object_type: "DeviceInstance".to_string(),
|
||||
},
|
||||
model: Arc::new(model.clone()),
|
||||
udn,
|
||||
server_base_url,
|
||||
services: RwLock::new(HashMap::new()),
|
||||
devices: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpTypedInstance for DeviceInstance {
|
||||
fn get_model(&self) -> &Self::Model {
|
||||
&self.model
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpObject for DeviceInstance {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("device");
|
||||
|
||||
// deviceType
|
||||
let mut device_type = Element::new("deviceType");
|
||||
device_type.children.push(XMLNode::Text(self.model.device_type()));
|
||||
elem.children.push(XMLNode::Element(device_type));
|
||||
|
||||
// friendlyName
|
||||
let mut friendly_name = Element::new("friendlyName");
|
||||
friendly_name.children.push(XMLNode::Text(self.model.friendly_name().to_string()));
|
||||
elem.children.push(XMLNode::Element(friendly_name));
|
||||
|
||||
// manufacturer
|
||||
let mut manufacturer = Element::new("manufacturer");
|
||||
manufacturer.children.push(XMLNode::Text(self.model.manufacturer().to_string()));
|
||||
elem.children.push(XMLNode::Element(manufacturer));
|
||||
|
||||
// modelName
|
||||
let mut model_name = Element::new("modelName");
|
||||
model_name.children.push(XMLNode::Text(self.model.model_name().to_string()));
|
||||
elem.children.push(XMLNode::Element(model_name));
|
||||
|
||||
// UDN
|
||||
let mut udn = Element::new("UDN");
|
||||
udn.children.push(XMLNode::Text(self.udn.clone()));
|
||||
elem.children.push(XMLNode::Element(udn));
|
||||
|
||||
// serviceList
|
||||
let services = self.services.read().unwrap();
|
||||
if !services.is_empty() {
|
||||
let mut service_list = Element::new("serviceList");
|
||||
for service in services.values() {
|
||||
service_list.children.push(XMLNode::Element(service.to_xml_element()));
|
||||
}
|
||||
elem.children.push(XMLNode::Element(service_list));
|
||||
}
|
||||
|
||||
// deviceList (sous-devices)
|
||||
let devices = self.devices.read().unwrap();
|
||||
if !devices.is_empty() {
|
||||
let mut device_list = Element::new("deviceList");
|
||||
for device in devices.values() {
|
||||
device_list.children.push(XMLNode::Element(device.to_xml_element()));
|
||||
}
|
||||
elem.children.push(XMLNode::Element(device_list));
|
||||
}
|
||||
|
||||
// presentationURL
|
||||
if let Some(url) = self.model.presentation_url() {
|
||||
let mut presentation_url = Element::new("presentationURL");
|
||||
presentation_url.children.push(XMLNode::Text(url.to_string()));
|
||||
elem.children.push(XMLNode::Element(presentation_url));
|
||||
}
|
||||
|
||||
elem
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceInstance {
|
||||
/// Définit l'URL de base du serveur.
|
||||
pub fn set_server_base_url(&mut self, url: String) {
|
||||
self.server_base_url = url;
|
||||
}
|
||||
|
||||
/// Retourne l'UDN du device.
|
||||
pub fn udn(&self) -> &str {
|
||||
&self.udn
|
||||
}
|
||||
|
||||
/// Retourne l'URL de base du serveur (protocole + host + port).
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.server_base_url
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
format!("/device/{}", self.udn())
|
||||
}
|
||||
|
||||
/// Retourne la route de description du device.
|
||||
pub fn description_route(&self) -> String {
|
||||
format!("{}/desc.xml", self.route())
|
||||
}
|
||||
|
||||
/// Ajoute une instance de service au device.
|
||||
///
|
||||
/// Cette méthode configure automatiquement le service pour qu'il connaisse
|
||||
/// son device parent.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Retourne une erreur si un service avec le même nom existe déjà.
|
||||
pub fn add_service(self: &Arc<Self>, service: Arc<ServiceInstance>) -> Result<(), DeviceError> {
|
||||
let mut services = self.services.write().unwrap();
|
||||
let name = service.get_name().to_string();
|
||||
|
||||
if services.contains_key(&name) {
|
||||
return Err(DeviceError::ServiceAlreadyExists(name));
|
||||
}
|
||||
|
||||
// Configurer le service pour qu'il connaisse son device parent
|
||||
service.set_device(Arc::clone(self));
|
||||
|
||||
services.insert(name, service);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retourne tous les services.
|
||||
pub fn services(&self) -> Vec<Arc<ServiceInstance>> {
|
||||
self.services.read().unwrap().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Retourne un service par nom.
|
||||
pub fn get_service(&self, name: &str) -> Option<Arc<ServiceInstance>> {
|
||||
self.services.read().unwrap().get(name).cloned()
|
||||
}
|
||||
|
||||
/// Ajoute une instance de sous-device.
|
||||
pub fn add_device(&self, device: Arc<DeviceInstance>) -> Result<(), DeviceError> {
|
||||
let mut devices = self.devices.write().unwrap();
|
||||
let name = device.get_name().to_string();
|
||||
|
||||
if devices.contains_key(&name) {
|
||||
return Err(DeviceError::DeviceAlreadyExists(name));
|
||||
}
|
||||
|
||||
devices.insert(name, device);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retourne tous les sous-devices.
|
||||
pub fn devices(&self) -> Vec<Arc<DeviceInstance>> {
|
||||
self.devices.read().unwrap().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Enregistre toutes les URLs du device et de ses services dans le serveur.
|
||||
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 {
|
||||
info!(
|
||||
"✅ Device description for {} available at: {}{}",
|
||||
self.get_name(),
|
||||
self.base_url(),
|
||||
self.description_route(),
|
||||
);
|
||||
|
||||
// Handler pour la description du device
|
||||
let instance_desc = self.clone();
|
||||
server.add_handler(&self.description_route(), move || {
|
||||
let instance = instance_desc.clone();
|
||||
async move { instance.description_handler().await }
|
||||
}).await;
|
||||
|
||||
// Enregistrer les services
|
||||
for service in self.services() {
|
||||
service.register_urls(server).await
|
||||
.map_err(|e| DeviceError::UrlRegistrationError(e.to_string()))?;
|
||||
}
|
||||
|
||||
// Enregistrer les sous-devices
|
||||
for device in self.devices() {
|
||||
device.register_urls(server).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Génère l'élément XML de description du device.
|
||||
pub fn description_element(&self) -> Element {
|
||||
let mut root = Element::new("root");
|
||||
root.attributes.insert(
|
||||
"xmlns".to_string(),
|
||||
"urn:schemas-upnp-org:device-1-0".to_string(),
|
||||
);
|
||||
|
||||
// specVersion
|
||||
let mut spec = Element::new("specVersion");
|
||||
let mut major = Element::new("major");
|
||||
major.children.push(XMLNode::Text("1".to_string()));
|
||||
spec.children.push(XMLNode::Element(major));
|
||||
|
||||
let mut minor = Element::new("minor");
|
||||
minor.children.push(XMLNode::Text("0".to_string()));
|
||||
spec.children.push(XMLNode::Element(minor));
|
||||
|
||||
root.children.push(XMLNode::Element(spec));
|
||||
|
||||
// device
|
||||
root.children.push(XMLNode::Element(self.to_xml_element()));
|
||||
|
||||
root
|
||||
}
|
||||
|
||||
/// Handler HTTP pour la description du device.
|
||||
async fn description_handler(&self) -> Response {
|
||||
let elem = self.description_element();
|
||||
|
||||
let config = EmitterConfig::new()
|
||||
.perform_indent(true)
|
||||
.indent_string(" ");
|
||||
|
||||
let mut xml_output = Vec::new();
|
||||
if let Err(e) = elem.write_with_config(&mut xml_output, config) {
|
||||
tracing::error!("Failed to serialize device description XML: {}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
|
||||
let xml = String::from_utf8_lossy(&xml_output).to_string();
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")],
|
||||
xml,
|
||||
).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
|
||||
}
|
||||
}
|
||||
134
pmoupnp/src/devices/device_methods.rs
Normal file
134
pmoupnp/src/devices/device_methods.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! Implémentation des traits UPnP pour Device.
|
||||
|
||||
use std::sync::Arc;
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::{
|
||||
devices::{Device, DeviceInstance},
|
||||
UpnpObject, UpnpModel, UpnpInstance,
|
||||
};
|
||||
|
||||
impl UpnpObject for Device {
|
||||
fn to_xml_element(&self) -> Element {
|
||||
let mut elem = Element::new("device");
|
||||
|
||||
// deviceType
|
||||
let mut device_type = Element::new("deviceType");
|
||||
device_type.children.push(XMLNode::Text(self.device_type()));
|
||||
elem.children.push(XMLNode::Element(device_type));
|
||||
|
||||
// friendlyName
|
||||
let mut friendly_name = Element::new("friendlyName");
|
||||
friendly_name.children.push(XMLNode::Text(self.friendly_name().to_string()));
|
||||
elem.children.push(XMLNode::Element(friendly_name));
|
||||
|
||||
// manufacturer
|
||||
let mut manufacturer = Element::new("manufacturer");
|
||||
manufacturer.children.push(XMLNode::Text(self.manufacturer().to_string()));
|
||||
elem.children.push(XMLNode::Element(manufacturer));
|
||||
|
||||
// manufacturerURL (optionnel)
|
||||
if let Some(url) = self.manufacturer_url() {
|
||||
let mut manufacturer_url = Element::new("manufacturerURL");
|
||||
manufacturer_url.children.push(XMLNode::Text(url.to_string()));
|
||||
elem.children.push(XMLNode::Element(manufacturer_url));
|
||||
}
|
||||
|
||||
// modelDescription (optionnel)
|
||||
if let Some(desc) = self.model_description() {
|
||||
let mut model_description = Element::new("modelDescription");
|
||||
model_description.children.push(XMLNode::Text(desc.to_string()));
|
||||
elem.children.push(XMLNode::Element(model_description));
|
||||
}
|
||||
|
||||
// modelName
|
||||
let mut model_name = Element::new("modelName");
|
||||
model_name.children.push(XMLNode::Text(self.model_name().to_string()));
|
||||
elem.children.push(XMLNode::Element(model_name));
|
||||
|
||||
// modelNumber (optionnel)
|
||||
if let Some(number) = self.model_number() {
|
||||
let mut model_number = Element::new("modelNumber");
|
||||
model_number.children.push(XMLNode::Text(number.to_string()));
|
||||
elem.children.push(XMLNode::Element(model_number));
|
||||
}
|
||||
|
||||
// modelURL (optionnel)
|
||||
if let Some(url) = self.model_url() {
|
||||
let mut model_url = Element::new("modelURL");
|
||||
model_url.children.push(XMLNode::Text(url.to_string()));
|
||||
elem.children.push(XMLNode::Element(model_url));
|
||||
}
|
||||
|
||||
// serialNumber (optionnel)
|
||||
if let Some(serial) = self.serial_number() {
|
||||
let mut serial_number = Element::new("serialNumber");
|
||||
serial_number.children.push(XMLNode::Text(serial.to_string()));
|
||||
elem.children.push(XMLNode::Element(serial_number));
|
||||
}
|
||||
|
||||
// UPC (optionnel)
|
||||
if let Some(upc) = self.upc() {
|
||||
let mut upc_elem = Element::new("UPC");
|
||||
upc_elem.children.push(XMLNode::Text(upc.to_string()));
|
||||
elem.children.push(XMLNode::Element(upc_elem));
|
||||
}
|
||||
|
||||
// iconList (optionnel)
|
||||
if let Some(icon_url) = self.icon_url() {
|
||||
let mut icon_list = Element::new("iconList");
|
||||
let mut icon = Element::new("icon");
|
||||
|
||||
let mut mimetype = Element::new("mimetype");
|
||||
mimetype.children.push(XMLNode::Text("image/png".to_string()));
|
||||
icon.children.push(XMLNode::Element(mimetype));
|
||||
|
||||
let mut width = Element::new("width");
|
||||
width.children.push(XMLNode::Text("48".to_string()));
|
||||
icon.children.push(XMLNode::Element(width));
|
||||
|
||||
let mut height = Element::new("height");
|
||||
height.children.push(XMLNode::Text("48".to_string()));
|
||||
icon.children.push(XMLNode::Element(height));
|
||||
|
||||
let mut depth = Element::new("depth");
|
||||
depth.children.push(XMLNode::Text("24".to_string()));
|
||||
icon.children.push(XMLNode::Element(depth));
|
||||
|
||||
let mut url = Element::new("url");
|
||||
url.children.push(XMLNode::Text(icon_url.to_string()));
|
||||
icon.children.push(XMLNode::Element(url));
|
||||
|
||||
icon_list.children.push(XMLNode::Element(icon));
|
||||
elem.children.push(XMLNode::Element(icon_list));
|
||||
}
|
||||
|
||||
// presentationURL (optionnel)
|
||||
if let Some(url) = self.presentation_url() {
|
||||
let mut presentation_url = Element::new("presentationURL");
|
||||
presentation_url.children.push(XMLNode::Text(url.to_string()));
|
||||
elem.children.push(XMLNode::Element(presentation_url));
|
||||
}
|
||||
|
||||
elem
|
||||
}
|
||||
}
|
||||
|
||||
impl UpnpModel for Device {
|
||||
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
|
||||
}
|
||||
}
|
||||
23
pmoupnp/src/devices/errors.rs
Normal file
23
pmoupnp/src/devices/errors.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! Erreurs relatives aux devices UPnP.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Erreurs liées aux devices UPnP.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DeviceError {
|
||||
/// Service déjà existant
|
||||
#[error("Service '{0}' already exists in device")]
|
||||
ServiceAlreadyExists(String),
|
||||
|
||||
/// Device déjà existant
|
||||
#[error("Device '{0}' already exists")]
|
||||
DeviceAlreadyExists(String),
|
||||
|
||||
/// Version invalide
|
||||
#[error("Device version must be > 0")]
|
||||
InvalidVersion,
|
||||
|
||||
/// Erreur d'enregistrement d'URL
|
||||
#[error("Failed to register URL: {0}")]
|
||||
UrlRegistrationError(String),
|
||||
}
|
||||
40
pmoupnp/src/devices/mod.rs
Normal file
40
pmoupnp/src/devices/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Module pour les devices UPnP.
|
||||
//!
|
||||
//! Ce module fournit les structures et fonctionnalites pour creer et gerer
|
||||
//! des devices UPnP selon la specification UPnP Device Architecture.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! - [`Device`] : Modele d'un device UPnP
|
||||
//! - [`DeviceInstance`] : Instance concrete d'un device
|
||||
//! - [`DeviceError`](errors::DeviceError) : Erreurs liees aux devices
|
||||
//!
|
||||
//! # Exemple
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use pmoupnp::devices::Device;
|
||||
//! use pmoupnp::services::Service;
|
||||
//! use std::sync::Arc;
|
||||
//!
|
||||
//! // Creer un device MediaRenderer
|
||||
//! let mut device = Device::new(
|
||||
//! "MediaRenderer".to_string(),
|
||||
//! "MediaRenderer".to_string(),
|
||||
//! "PMOMusic Renderer".to_string()
|
||||
//! );
|
||||
//!
|
||||
//! // Ajouter des services
|
||||
//! let avtransport = Arc::new(Service::new("AVTransport".to_string()));
|
||||
//! device.add_service(avtransport).unwrap();
|
||||
//!
|
||||
//! // Creer une instance
|
||||
//! let instance = device.create_instance();
|
||||
//! ```
|
||||
|
||||
mod device;
|
||||
mod device_instance;
|
||||
mod device_methods;
|
||||
pub mod errors;
|
||||
|
||||
pub use device::Device;
|
||||
pub use device_instance::DeviceInstance;
|
||||
40
pmoupnp/src/lib.rs
Normal file
40
pmoupnp/src/lib.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
mod object_trait;
|
||||
mod object_set;
|
||||
mod server;
|
||||
|
||||
pub mod actions;
|
||||
pub mod devices;
|
||||
pub mod mediarenderer;
|
||||
pub mod services;
|
||||
pub mod soap;
|
||||
pub mod ssdp;
|
||||
pub mod state_variables;
|
||||
pub mod value_ranges;
|
||||
pub mod variable_types;
|
||||
|
||||
|
||||
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use std::sync::RwLock;
|
||||
|
||||
pub use crate::object_trait::*;
|
||||
pub use crate::server::UpnpServer;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpnpObjectType {
|
||||
name: String,
|
||||
object_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpnpObjectSet<T: UpnpTypedObject> {
|
||||
objects: RwLock<HashMap<String, Arc<T>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UpnpObjectSetError {
|
||||
AlreadyExists(String),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETCURRENTTRANSPORTACTIONS = "GetCurrentTransportActions" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETDEVICECAPABILITIES = "GetDeviceCapabilities" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, NUMBEROFTRACKS, CURRENTTRACK, AVTRANSPORTURI, AVTRANSPORTURIMETADATA, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETMEDIAINFO = "GetMediaInfo" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
out "NrTracks" => NUMBEROFTRACKS,
|
||||
out "CurrentTrack" => CURRENTTRACK,
|
||||
out "CurrentURI" => AVTRANSPORTURI,
|
||||
out "CurrentURIMetaData" => AVTRANSPORTURIMETADATA,
|
||||
out "NextURI" => AVTRANSPORTNEXTURI,
|
||||
out "NextURIMetaData" => AVTRANSPORTNEXTURIMETADATA,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, CURRENTTRACK, CURRENTTRACKDURATION, AVTRANSPORTURI, AVTRANSPORTURIMETADATA, RELATIVETIMEPOSITION, ABSOLUTETIMEPOSITION};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETPOSITIONINFO = "GetPositionInfo" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
out "Track" => CURRENTTRACK,
|
||||
out "TrackDuration" => CURRENTTRACKDURATION,
|
||||
out "TrackURI" => AVTRANSPORTURI,
|
||||
out "TrackMetaData" => AVTRANSPORTURIMETADATA,
|
||||
out "RelTime" => RELATIVETIMEPOSITION,
|
||||
out "AbsTime" => ABSOLUTETIMEPOSITION,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, TRANSPORTSTATE, TRANSPORTSTATUS};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETTRANSPORTINFO = "GetTransportInfo" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
out "CurrentTransportState" => TRANSPORTSTATE,
|
||||
out "CurrentTransportStatus" => TRANSPORTSTATUS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static GETTRANSPORTSETTINGS = "GetTransportSettings" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
30
pmoupnp/src/mediarenderer/avtransport/actions/mod.rs
Normal file
30
pmoupnp/src/mediarenderer/avtransport/actions/mod.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
mod getcurrenttransportactions;
|
||||
mod getdevicecapabilities;
|
||||
mod getmediainfo;
|
||||
mod getpositioninfo;
|
||||
mod gettransportinfo;
|
||||
mod gettransportsettings;
|
||||
mod next;
|
||||
mod pause;
|
||||
mod play;
|
||||
mod previous;
|
||||
mod seek;
|
||||
mod setavtransportnexturi;
|
||||
mod setavtransporturi;
|
||||
mod stop;
|
||||
|
||||
pub use getcurrenttransportactions::GETCURRENTTRANSPORTACTIONS;
|
||||
pub use getdevicecapabilities::GETDEVICECAPABILITIES;
|
||||
pub use getmediainfo::GETMEDIAINFO;
|
||||
pub use getpositioninfo::GETPOSITIONINFO;
|
||||
pub use gettransportinfo::GETTRANSPORTINFO;
|
||||
pub use gettransportsettings::GETTRANSPORTSETTINGS;
|
||||
pub use next::NEXT;
|
||||
pub use pause::PAUSE;
|
||||
pub use play::PLAY;
|
||||
pub use previous::PREVIOUS;
|
||||
pub use seek::SEEK;
|
||||
pub use setavtransportnexturi::SETNEXTAVTRANSPORTURI;
|
||||
pub use setavtransporturi::SETAVTRANSPORTURI;
|
||||
pub use stop::STOP;
|
||||
|
||||
8
pmoupnp/src/mediarenderer/avtransport/actions/next.rs
Normal file
8
pmoupnp/src/mediarenderer/avtransport/actions/next.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static NEXT = "Next" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
8
pmoupnp/src/mediarenderer/avtransport/actions/pause.rs
Normal file
8
pmoupnp/src/mediarenderer/avtransport/actions/pause.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static PAUSE = "Pause" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
9
pmoupnp/src/mediarenderer/avtransport/actions/play.rs
Normal file
9
pmoupnp/src/mediarenderer/avtransport/actions/play.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, TRANSPORTPLAYSPEED};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static PLAY = "Play" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
in "Speed" => TRANSPORTPLAYSPEED,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static PREVIOUS = "Previous" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
}
|
||||
}
|
||||
10
pmoupnp/src/mediarenderer/avtransport/actions/seek.rs
Normal file
10
pmoupnp/src/mediarenderer/avtransport/actions/seek.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, A_ARG_TYPE_SEEKMODE, CURRENTTRACKDURATION};
|
||||
use crate::define_action;
|
||||
|
||||
define_action! {
|
||||
pub static SEEK = "Seek" {
|
||||
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
|
||||
in "Unit" => A_ARG_TYPE_SEEKMODE,
|
||||
in "Target" => CURRENTTRACKDURATION,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user