52 Commits

Author SHA1 Message Date
ff4aefd019 Merge pull request 'push-xsnmptvkprqt' (#13) from push-xsnmptvkprqt into main
Reviewed-on: #13
2025-10-06 11:47:18 +02:00
7af90970cf Sort webapp du pmoserver dans la crate pmoapp 2025-10-06 11:28:24 +02:00
7e601430f7 Sort le serveur dans une crate pmoserver 2025-10-06 06:28:48 +02:00
582d47647b lastest clean version 2025-10-06 05:59:01 +02:00
dc45c230e2 Merge pull request 'push-qsztxxtruxvo' (#12) from push-qsztxxtruxvo into main
Reviewed-on: #12
2025-10-05 22:06:05 +02:00
cce6cad218 implementation de ConnectionManager 2025-10-05 22:04:18 +02:00
36ac796279 lastest clean version 2025-10-05 22:04:18 +02:00
ad51fc952a Implementation de rendering control 2025-10-05 22:03:13 +02:00
5c0a8ba392 Mise en place d'une macro pour la définition des variables 2025-10-05 22:03:13 +02:00
7dbacd0112 Je laisse Claude faire ses modification sur avtransport 2025-10-05 22:03:13 +02:00
cf0f6ea9e0 Revenons sur les devices 2025-10-05 22:03:13 +02:00
b850d898b3 Fin du debuggage 2025-10-05 22:03:13 +02:00
ad24ee57f8 Gros refactoring 2025-10-05 22:03:13 +02:00
40c003cca4 Començons un Mediarenderer 2025-10-05 22:03:13 +02:00
2da758aa24 On continue l'implementation de pmoupnp 2025-10-05 22:03:13 +02:00
8c2e126a8c On reformate le code ;-) 2025-10-05 22:03:13 +02:00
d53a458a52 On essaie de faire démarer un serveur web 2025-10-05 22:03:13 +02:00
c17eb08b11 Merge pull request 'push-txpxkkntoxrz' (#10) from push-txpxkkntoxrz into main
Reviewed-on: #10
2025-10-05 21:44:56 +02:00
2a6037479f Developpement de la library pmoupnp 2025-10-05 21:29:10 +02:00
2a9ca141f0 on redemare en Rust
This commit contains the following changes:
     M .gitignore
     A Cargo.lock
     A Cargo.toml
     A PMOMusic/Cargo.toml
     A PMOMusic/src/main.rs
     D README.md
     A Readme.md
2025-10-05 21:29:10 +02:00
a16a7c3847 Merge pull request 'On range tout pour recommencer en rust' (#9) from push-nmoutlzzmlso into main
Reviewed-on: #9
2025-09-16 21:01:53 +02:00
f087ac4c82 On range tout pour recommencer en rust 2025-09-16 21:00:02 +02:00
79a690834f Merge pull request 'On efface le cache des image' (#8) from push-xxzvzpzxoxuz into main
Reviewed-on: #8
2025-09-16 20:36:41 +02:00
85125a9c19 On efface le cache des image 2025-09-16 20:35:52 +02:00
293f74db64 Merge pull request 'push-zuzrmsnvxmlp' (#7) from push-zuzrmsnvxmlp into main
Reviewed-on: #7
2025-09-16 20:26:58 +02:00
6c066e5d23 Intégration de libsoxr pour le rééchantillonnage audio 2025-09-16 20:25:46 +02:00
0d6c7f9499 J'ai retirer le cache du suivi 2025-09-16 20:11:51 +02:00
8aec7d94b7 Mise à jour .gitignore et retrait du suivi 2025-09-16 20:11:51 +02:00
2cf6d6dc8b Retrait de .pmomusic_covers du suivi via .gitignore 2025-09-16 20:11:51 +02:00
e13a1e139c Merge pull request 'cover-cache' (#6) from cover-cache into main
Reviewed-on: #6
2025-09-09 21:13:29 +02:00
f1fbc877eb Maintenant capter les URL des documents didl parser et les stocker dans le cache... 2025-09-09 21:05:05 +02:00
c1af6f2992 Amélioration légère du CSS pour les logs 2025-09-09 19:49:42 +02:00
768521d453 On renome le module upnp pmoupnp pour homogénéiser le tout 2025-09-09 19:44:00 +02:00
cd01b5b2ca Ajoute un cache pour les couvertures 2025-09-09 11:31:53 +02:00
78c3dfa686 Petit changement cosmétique 2025-09-09 09:07:44 +02:00
490b83e250 Merge pull request 'push-wvlyutrvykvk' (#5) from push-wvlyutrvykvk into main
Reviewed-on: #5
2025-09-08 18:09:53 +02:00
96ae2f6386 Changement d'application web 2025-09-08 18:03:03 +02:00
fa6ed6ae34 Essayons de passer la page de log en react 2025-09-08 17:57:53 +02:00
70681a8b55 Fini l'implementation du RenderingControl 2025-09-07 22:07:24 +02:00
073716d443 Merge pull request 'push-krxtpvssqpqz' (#4) from push-krxtpvssqpqz into main
Reviewed-on: #4
2025-09-07 18:07:20 +02:00
79d2114b03 Maintenant modifier les instance de state variable pour qu'elles notifient leurs changements 2025-09-07 18:05:10 +02:00
35a0ad5eb9 On ajoute la gestion des notification 2025-09-07 17:18:00 +02:00
c01110b26c Merge pull request 'push-yrukqqxpolnk' (#3) from push-yrukqqxpolnk into main
Reviewed-on: #3
2025-09-07 15:20:44 +02:00
dc238af6a1 J'ai finalement retravailler la feuille de style des log. 2025-09-07 15:17:37 +02:00
b0ccfeff01 J'ai désactiver l'extension git dans ce projet 2025-09-07 12:47:33 +02:00
114fadbd4b Après mon dernier merge, j'espère que tout tes bon 2025-09-07 12:45:19 +02:00
adb452fe83 Merge pull request 'feature/soap-parser' (#2) from feature/soap-parser into main
Reviewed-on: #2
2025-09-07 12:18:26 +02:00
df43b4f4cf Merge branch 'main' into feature/soap-parser 2025-09-07 12:18:14 +02:00
4608b68f6a Avec un parser soap un peu plus modulaire et un debut de ControlHandler sur les services qui fait des choses 2025-09-07 12:15:17 +02:00
f4bed07f12 Change ID: xnmuwquy
This commit contains the following changes:
     A .DS_Store
     A .pmomusic.yml
     A .vscode/settings.json
     A db/chroma.sqlite3
2025-09-07 12:14:50 +02:00
9eb6b3a5c9 Merge pull request 'J'essaie un commit jujutsu' (#1) from push-wotyqxzlpsvl into main
Reviewed-on: #1
2025-09-07 11:59:43 +02:00
932f693d10 J'essaie un commit jujutsu 2025-09-07 11:58:25 +02:00
270 changed files with 19691 additions and 6094 deletions

18
.gitignore vendored
View File

@@ -3,6 +3,22 @@
/vendor/
**/*.log
**/*.old
**/*.o
**/*.o.d
**/*.a
xxx
/dcai/
***/.pmomusic.yml
**/.pmomusic.yml
**/.pmomusic_covers/**
**/.DS_Strore/**
**/.DS_Strore
/target/
.pmomusic_covers
C/src/soxr-0.1.3/Release/tests
**/Release/
**/Debug/
OLD-GO-CODE/
xxx
xx
all.txt
pmo_src.txt

9
.pmomusic.yml Normal file
View File

@@ -0,0 +1,9 @@
devices:
mediarenderer:
fakerenderer:
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
mediaserver:
qobuz:
udn: 28963b75-4c5f-4da7-b10e-ffafd
host:
http_port: '8080'

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"makefile.configureOnOpen": false,
"git.enabled": false,
"claude-code.environmentVariables": [
]
}

2963
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

3
Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
resolver = "3"
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp"]

192
Makefile
View File

@@ -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)"

17
PMOMusic/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "PMOMusic"
version = "0.1.0"
edition = "2024"
[dependencies]
pmoconfig = { path = "../pmoconfig" }
pmoupnp = { path = "../pmoupnp"}
pmoserver = { path = "../pmoserver" }
pmoapp = { path = "../pmoapp" }
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"

58
PMOMusic/src/main.rs Normal file
View File

@@ -0,0 +1,58 @@
use pmoupnp::{mediarenderer::avtransport::AVTTRANSPORT, UpnpObject};
use pmoserver::{
logs::{log_dump, log_sse, LogState, SseLayer},
ServerBuilder
};
use pmoapp::Webapp;
use tracing_subscriber::Registry;
use tracing_subscriber::prelude::*;
use tracing::info;
#[tokio::main]
async fn main() {
// Charger la config
let mut server = ServerBuilder::new_configured().build();
// Ajouter des routes
server
.add_route("/hello", || async {
serde_json::json!({"message": "Hello World"})
})
.await;
server
.add_route("/info", || async {
serde_json::json!({"version": "1.0.0"})
})
.await;
server.add_spa::<Webapp>("/app").await;
// Gère la sortie des logs et sur le serveur SSE pour l'interface web et sur la console
let log_state = LogState::new(1000);
let subscriber = Registry::default()
.with(
tracing_subscriber::fmt::layer()
.with_target(true)
.with_level(true)
.with_ansi(true), // Couleurs dans le terminal
)
.with(SseLayer::new(log_state.clone()));
tracing::subscriber::set_global_default(subscriber).unwrap();
server
.add_handler_with_state("/log-sse", log_sse, log_state.clone())
.await;
server
.add_handler_with_state("/log-dump", log_dump, log_state.clone())
.await;
server.add_redirect("/", "/app").await;
info!("{}",AVTTRANSPORT.to_markdown());
info!("{}",AVTTRANSPORT.scpd_xml());
server.start().await;
server.wait().await;
}

View File

@@ -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
View 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

View File

@@ -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 lIP 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

Binary file not shown.

3168
deps.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 182 KiB

View File

@@ -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: ![Cover](%s)\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")
}

View File

@@ -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"`
}

View File

@@ -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
View 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
View 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 lembarquer via `embed`. Cest dailleurs 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 derreurs 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 dinté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 nas pas besoin de SWC pour linstant, 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 lentrée de lapplication (`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 nas 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 **larborescence 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
Veuxtu que je fasse ça?

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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
}

7
pmoapp/Cargo.toml Normal file
View File

@@ -0,0 +1,7 @@
[package]
name = "pmoapp"
version = "0.1.0"
edition = "2021"
[dependencies]
rust-embed = "8.5.0"

51
pmoapp/src/lib.rs Normal file
View File

@@ -0,0 +1,51 @@
//! # pmoapp - Application web UPnP pour PMOMusic
//!
//! Cette crate fournit l'application web frontend pour le contrôle UPnP,
//! intégrée via RustEmbed pour être servie par pmoserver.
//!
//! ## Fonctionnalités
//!
//! - 📦 **Frontend intégré** : Application web compilée et embarquée dans le binaire
//! - 🎨 **Interface de contrôle** : UI pour gérer les devices UPnP MediaRenderer
//! - 🚀 **Zero configuration** : Pas besoin de servir des fichiers statiques séparés
//!
//! ## Utilisation
//!
//! ```rust,no_run
//! use pmoapp::Webapp;
//! use pmoserver::ServerBuilder;
//!
//! # async fn example() {
//! let mut server = ServerBuilder::new("MyApp").build();
//! server.add_spa::<Webapp>("/app").await;
//! # }
//! ```
//!
//! ## Structure
//!
//! La webapp est construite avec Vite et Vue.js, et les fichiers statiques
//! sont embarqués dans le binaire au moment de la compilation via `RustEmbed`.
use rust_embed::RustEmbed;
/// 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;
/// use pmoserver::ServerBuilder;
///
/// # async fn example() {
/// let mut server = ServerBuilder::new("MyApp").build();
///
/// // Ajouter la webapp comme SPA sur le chemin /app
/// server.add_spa::<Webapp>("/app").await;
/// # }
/// ```
#[derive(RustEmbed, Clone)]
#[folder = "webapp/dist"]
pub struct Webapp;

23
pmoapp/webapp/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
pmoapp/webapp/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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

29
pmoapp/webapp/src/App.vue Normal file
View File

@@ -0,0 +1,29 @@
<template>
<div>
<nav>
<router-link to="/">Accueil</router-link> |
<router-link to="/logs">Logs</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>

View 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

View 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>

View 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>

View 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");

View File

@@ -0,0 +1,16 @@
import { createRouter, createWebHistory } from "vue-router";
import HelloWorld from "../components/HelloWorld.vue";
import LogView from "../components/LogView.vue";
const routes = [
{ path: "/", name: "home", component: HelloWorld },
{ path: "/logs", name: "logs", component: LogView },
];
const router = createRouter({
// history avec base /app
history: createWebHistory("/app"),
routes,
});
export default router;

5
pmoapp/webapp/src/shims-vue.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module "*.vue" {
import { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View 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;
}
}

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View 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
View File

@@ -0,0 +1 @@
/target

17
pmoconfig/Cargo.toml Normal file
View 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"

319
pmoconfig/src/lib.rs Normal file
View File

@@ -0,0 +1,319 @@
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;
// 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 mut config_value: Value = serde_yaml::from_slice(&yaml_data)?;
config_value = Self::lower_keys_value(config_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()
}

View File

@@ -1,5 +1,8 @@
host:
http_port: "1900"
http_port: "8080"
cover_cache:
directory: "./.pmomusic_covers"
size: 2000
devices:
mediarenderer:
mpd_renderer:

12
pmodidl/Cargo.toml Normal file
View 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
View 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: ![Cover]({})", 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);
}
}

View File

@@ -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>

View File

@@ -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")
}

27
pmoserver/Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[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"] }
[dependencies.pmoupnp]
path = "../pmoupnp"
default-features = false

79
pmoserver/src/lib.rs Normal file
View File

@@ -0,0 +1,79 @@
//! # 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
//! - `upnp_impl` : Implémentation du trait `pmoupnp::UpnpServer` (privé)
//!
//! ## 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 implémente automatiquement le trait `pmoupnp::UpnpServer`, permettant
//! de connecter des devices UPnP :
//!
//! ```rust,no_run
//! use pmoupnp::{UpnpServer, mediarenderer::device::MEDIA_RENDERER};
//! use pmoupnp::devices::DeviceInstance;
//! use pmoserver::ServerBuilder;
//! use std::sync::Arc;
//!
//! # async fn example() {
//! let mut server = ServerBuilder::new("MediaRenderer").build();
//! let device = Arc::new(DeviceInstance::new(&MEDIA_RENDERER));
//!
//! // Le device enregistre automatiquement ses routes
//! device.register_urls(&mut server).await;
//! # }
//! ```
pub mod server;
pub mod logs;
mod upnp_impl;
pub use server::{Server, ServerBuilder, ServerInfo};
pub use logs::{LogState, SseLayer, log_sse, log_dump};

159
pmoserver/src/logs/mod.rs Normal file
View File

@@ -0,0 +1,159 @@
// 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;
/// 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, &params) {
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, &params) {
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
}

View 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);
}
}

559
pmoserver/src/server.rs Normal file
View File

@@ -0,0 +1,559 @@
//! # 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 axum::handler::Handler;
use axum::response::Redirect;
use axum::routing::get;
use axum::{Json, Router};
use axum_embed::ServeEmbed;
use pmoconfig::get_config;
use rust_embed::RustEmbed;
use serde::Serialize;
use std::{net::SocketAddr, 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 {
/// Nom du serveur
pub name: String,
/// URL de base
pub base_url: String,
/// Port HTTP
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<()>>,
}
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,
}
}
pub fn new_configured() -> Self {
let config = get_config();
let url = config.get_base_url();
let port = config.get_http_port();
return 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: std::future::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 = std::mem::take(&mut *r).nest(path, route);
}
/// Ajoute un répertoire de fichiers statiques
///
/// Sert des fichiers embarqués via `RustEmbed`. Les fichiers sont compilés
/// dans le binaire à la compilation.
///
/// # Arguments
///
/// * `path` - Chemin où monter les fichiers statiques
///
/// # Type Parameter
///
/// * `E` - Type RustEmbed définissant le répertoire à servir
///
/// # Exemple
///
/// ```ignore
/// use pmoupnp::server::Server;
/// use rust_embed::RustEmbed;
///
/// #[derive(RustEmbed, Clone)]
/// #[folder = "static/"]
/// struct Assets;
///
/// # #[tokio::main]
/// # async fn main() {
/// let mut server = Server::new("Test", "http://localhost:3000", 3000);
/// server.add_dir::<Assets>("/assets").await;
/// // Les fichiers de static/ sont accessibles via /assets/*
/// # }
/// ```
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;
if path == "/" {
*r = std::mem::take(&mut *r).fallback_service(serve);
} else {
let route = Router::new().fallback_service(serve);
*r = 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;
if path == "/" {
*r = std::mem::take(&mut *r).fallback_service(serve);
} else {
let route = Router::new().fallback_service(serve);
*r = std::mem::take(&mut *r).nest(path, route);
}
}
/// Ajoute un handler Axum personnalisé
///
/// Pour des cas d'usage avancés nécessitant un contrôle complet sur le handler.
///
/// # Arguments
///
/// * `path` - Chemin de la route
/// * `handler` - Handler Axum
///
/// # Exemple
///
/// ```rust,no_run
/// # use pmoupnp::server::Server;
/// # use axum::response::Html;
/// # #[tokio::main]
/// # async fn main() {
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
/// async fn custom_handler() -> Html<&'static str> {
/// Html("<h1>Custom Response</h1>")
/// }
///
/// server.add_handler("/custom", custom_handler).await;
/// # }
/// ```
pub async fn add_handler<H, T>(&mut self, path: &str, handler: H)
where
H: Handler<T, ()>,
T: 'static,
{
let route = Router::new().route("/", get(handler));
let mut r = self.router.write().await;
*r = std::mem::take(&mut *r).nest(path, route);
}
/// Ajoute un handler avec state (pour SSE, extracteurs, etc.)
///
/// Permet d'utiliser des extracteurs Axum comme `State`, `Query`, etc.
/// Idéal pour Server-Sent Events (SSE), WebSockets ou tout handler nécessitant un état partagé.
///
/// # Arguments
///
/// * `path` - Chemin de la route
/// * `handler` - Handler Axum avec extracteurs
/// * `state` - État partagé (doit être Clone + Send + Sync)
///
/// # Exemple avec SSE
///
/// ```ignore
/// use pmoupnp::server::Server;
/// use axum::extract::State;
/// use axum::response::sse::{Event, Sse, KeepAlive};
/// use tokio::sync::broadcast;
///
/// #[derive(Clone)]
/// struct LogState {
/// tx: broadcast::Sender<String>
/// }
///
/// impl LogState {
/// fn subscribe(&self) -> broadcast::Receiver<String> {
/// self.tx.subscribe()
/// }
/// }
///
/// async fn log_sse(State(state): State<LogState>) -> Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>> {
/// let mut rx = state.subscribe();
/// let stream = async_stream::stream! {
/// while let Ok(msg) = rx.recv().await {
/// yield Ok(Event::default().data(msg));
/// }
/// };
/// Sse::new(stream).keep_alive(KeepAlive::default())
/// }
///
/// let log_state = LogState { tx: broadcast::channel(100).0 };
/// server.add_handler_with_state("/logs", log_sse, log_state).await;
/// ```
pub async fn add_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
where
H: Handler<T, S>,
T: 'static,
S: Clone + Send + Sync + 'static,
{
let route = Router::new()
.route("/", get(handler))
.with_state(state);
let mut r = self.router.write().await;
*r = std::mem::take(&mut *r).nest(path, route);
}
/// Ajoute un handler POST avec state
///
/// Similaire à `add_handler_with_state` mais pour les requêtes POST.
///
/// # Arguments
///
/// * `path` - Chemin de la route
/// * `handler` - Handler Axum pour POST
/// * `state` - État partagé
pub async fn add_post_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
where
H: Handler<T, S>,
T: 'static,
S: Clone + Send + Sync + 'static,
{
let route = Router::new()
.route("/", axum::routing::post(handler))
.with_state(state);
let mut r = self.router.write().await;
*r = std::mem::take(&mut *r).nest(path, route);
}
/// Ajoute une redirection HTTP
///
/// 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 handler = move || {
let to = to.clone();
async move { Redirect::permanent(&to) }
};
let mut r = self.router.write().await;
if from == "/" {
// Pour la racine, utiliser merge au lieu de nest
let route = Router::new().route("/", get(handler));
*r = std::mem::take(&mut *r).merge(route);
} else {
let route = Router::new().route("/", get(handler));
*r = std::mem::take(&mut *r).nest(from, route);
}
}
/// Ajoute une API documentée avec OpenAPI
///
/// Monte un routeur d'API sous `/api` et active Swagger UI sur `/swagger-ui`
///
/// # Arguments
///
/// * `api_router` - Router Axum contenant les routes API
/// * `openapi` - Spécification OpenAPI générée par utoipa
///
/// # 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 ApiDoc;
///
/// #[utoipa::path(
/// get,
/// path = "/users",
/// responses((status = 200, description = "List users"))
/// )]
/// async fn get_users() -> Json<Vec<User>> {
/// Json(vec![])
/// }
///
/// let api_router = Router::new()
/// .route("/users", get(get_users));
///
/// server.add_openapi(api_router, ApiDoc::openapi()).await;
/// ```
pub async fn add_openapi(&mut self, api_router: Router, openapi: utoipa::openapi::OpenApi) {
// Stocker le routeur API
let mut api_r = self.api_router.write().await;
*api_r = Some(api_router);
// Ajouter Swagger UI
let swagger = SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", openapi);
let mut r = self.router.write().await;
*r = std::mem::take(&mut *r).merge(swagger);
}
/// 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);
// Merger le routeur API si présent
let api_router = self.api_router.read().await;
if let Some(api_r) = api_router.as_ref() {
let mut r = self.router.write().await;
*r = std::mem::take(&mut *r).nest("/api", api_r.clone());
}
drop(api_router);
let router = self.router.clone();
let 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,
}
}
}
/// 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)
}
}

View File

@@ -0,0 +1,94 @@
//! Implémentation du trait UpnpServer pour le serveur pmoserver
//!
//! Ce module fournit l'implémentation du trait [`pmoupnp::UpnpServer`] pour
//! le [`Server`](crate::server::Server) de pmoserver, permettant aux devices
//! et services UPnP d'enregistrer automatiquement leurs endpoints HTTP.
//!
//! ## Architecture
//!
//! L'implémentation fait le pont entre :
//! - Les pointeurs de fonction du trait `UpnpServer` (agnostiques du framework web)
//! - Les handlers Axum (spécifiques à l'implémentation `pmoserver`)
//!
//! Chaque méthode du trait crée un wrapper qui :
//! 1. Convertit les pointeurs de fonction en closures compatibles Axum
//! 2. Délègue l'enregistrement aux méthodes internes du `Server`
//! 3. Retourne une future qui se résout une fois le handler enregistré
//!
//! ## Exemple d'utilisation
//!
//! ```rust,no_run
//! use pmoupnp::{UpnpServer, mediarenderer::device::MEDIA_RENDERER};
//! use pmoupnp::devices::DeviceInstance;
//! use pmoserver::ServerBuilder;
//! use std::sync::Arc;
//!
//! # async fn example() {
//! let mut server = ServerBuilder::new("MyRenderer").build();
//! let device = Arc::new(DeviceInstance::new(&MEDIA_RENDERER));
//!
//! // Le trait UpnpServer est automatiquement disponible
//! device.register_urls(&mut server).await;
//! # }
//! ```
use crate::server::Server;
use pmoupnp::{UpnpServer, server::{Response, HeaderMap, Request}};
use std::future::Future;
use std::pin::Pin;
use axum::extract::State;
impl UpnpServer for Server {
fn add_handler<F, Fut>(&mut self, path: &str, handler: F) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
where
F: Fn() -> Fut + Send + Sync + 'static + Clone,
Fut: Future<Output = Response> + Send + 'static,
{
let path = path.to_string();
Box::pin(async move {
Self::add_handler(self, &path, handler).await;
})
}
fn add_post_handler_with_state<S>(
&mut self,
path: &str,
handler: fn(State<S>, String) -> Pin<Box<dyn Future<Output = Response> + Send>>,
state: S,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
where
S: Clone + Send + Sync + 'static,
{
let path = path.to_string();
// Créer un wrapper qui convertit le fn pointer en handler Axum
let wrapper = move |State(s): State<S>, body: String| -> Pin<Box<dyn Future<Output = Response> + Send>> {
handler(State(s), body)
};
Box::pin(async move {
Self::add_post_handler_with_state(self, &path, wrapper, state).await;
})
}
fn add_handler_with_state<S>(
&mut self,
path: &str,
handler: fn(State<S>, HeaderMap, Request) -> Pin<Box<dyn Future<Output = Response> + Send>>,
state: S,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
where
S: Clone + Send + Sync + 'static,
{
let path = path.to_string();
// Créer un wrapper qui convertit le fn pointer en handler Axum
let wrapper = move |State(s): State<S>, headers: HeaderMap, req: Request| -> Pin<Box<dyn Future<Output = Response> + Send>> {
handler(State(s), headers, req)
};
Box::pin(async move {
Self::add_handler_with_state(self, &path, wrapper, state).await;
})
}
}

26
pmoupnp/Cargo.toml Normal file
View File

@@ -0,0 +1,26 @@
[package]
name = "pmoupnp"
version = "0.1.0"
edition = "2024"
[dependencies]
pmoconfig = { path = "../pmoconfig" }
pmodidl = { path = "../pmodidl"}
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
View 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))
}
}

View File

@@ -0,0 +1,136 @@
use std::sync::Arc;
use xmltree::{Element, XMLNode};
use crate::actions::Action;
use crate::actions::Argument;
use crate::actions::ArgumentSet;
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
}));
}
}

View 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
}
}

View 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
}
}

View 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;
}

View 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()) }
}
}

View 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())
}
}
}

View 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;
}

View 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
}

View 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))
}
}

View 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
View 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>;

View 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
}
}

View File

@@ -0,0 +1,330 @@
//! 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())
};
Self {
object: UpnpObjectType {
name: model.get_name().to_string(),
object_type: "DeviceInstance".to_string(),
},
model: Arc::new(model.clone()),
udn,
server_base_url: "http://localhost:8080".to_string(),
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).
pub fn route(&self) -> String {
format!("/device/{}", self.get_name())
}
/// 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, S: crate::UpnpServer + ?Sized>(&'a self, server: &'a mut S) -> 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 mut xml = String::from_utf8_lossy(&xml_output).to_string();
// Ajouter l'en-tête XML
xml.insert_str(0, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")],
xml,
).into_response()
}
}

View File

@@ -0,0 +1,118 @@
//! Implémentation des traits UPnP pour Device.
use xmltree::{Element, XMLNode};
use crate::{
devices::{Device, DeviceInstance},
UpnpObject, UpnpModel,
};
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;
}

View 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),
}

View 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;

38
pmoupnp/src/lib.rs Normal file
View File

@@ -0,0 +1,38 @@
mod object_trait;
mod object_set;
pub mod actions;
pub mod devices;
pub mod mediarenderer;
pub mod server;
pub mod services;
pub mod state_variables;
pub mod value_ranges;
pub mod variable_types;
// Re-exports
pub use server::UpnpServer;
use std::{collections::HashMap, sync::Arc};
use std::sync::RwLock;
pub use crate::object_trait::*;
#[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),
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View 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;

View 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,
}
}

View 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,
}
}

View 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,
}
}

View File

@@ -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,
}
}

View 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,
}
}

View File

@@ -0,0 +1,10 @@
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA};
use crate::define_action;
define_action! {
pub static SETNEXTAVTRANSPORTURI = "SetNextAVTransportURI" {
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
in "NextURI" => AVTRANSPORTNEXTURI,
in "NextURIMetaData" => AVTRANSPORTNEXTURIMETADATA,
}
}

View File

@@ -0,0 +1,10 @@
use crate::mediarenderer::avtransport::variables::{A_ARG_TYPE_INSTANCE_ID, AVTRANSPORTURI, AVTRANSPORTURIMETADATA};
use crate::define_action;
define_action! {
pub static SETAVTRANSPORTURI = "SetAVTransportURI" {
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
in "CurrentURI" => AVTRANSPORTURI,
in "CurrentURIMetaData" => AVTRANSPORTURIMETADATA,
}
}

View File

@@ -0,0 +1,8 @@
use crate::mediarenderer::avtransport::variables::A_ARG_TYPE_INSTANCE_ID;
use crate::define_action;
define_action! {
pub static STOP = "Stop" {
in "InstanceID" => A_ARG_TYPE_INSTANCE_ID,
}
}

View File

@@ -0,0 +1,156 @@
//! # AVTransport Service - Service de contrôle de transport UPnP
//!
//! Ce module implémente le service AVTransport:1 selon la spécification UPnP AV.
//! Le service AVTransport permet de contrôler la lecture de médias **audio**
//! sur des rendus multimédias (MediaRenderer Audio).
//!
//! ## Fonctionnalités
//!
//! Le service AVTransport permet :
//! - **Contrôle de lecture** : Play, Pause, Stop
//! - **Navigation** : Next, Previous, Seek
//! - **Gestion des URIs** : SetAVTransportURI, SetNextAVTransportURI
//! - **Information d'état** : GetTransportInfo, GetPositionInfo, GetMediaInfo
//! - **Capacités** : GetDeviceCapabilities, GetCurrentTransportActions
//!
//! ## Conformité UPnP
//!
//! Cette implémentation suit la spécification **UPnP AVTransport:1 Service Template**.
//! Toutes les actions obligatoires (Required) sont implémentées :
//!
//! - ✅ SetAVTransportURI
//! - ✅ GetMediaInfo
//! - ✅ GetTransportInfo
//! - ✅ GetPositionInfo
//! - ✅ GetDeviceCapabilities
//! - ✅ GetTransportSettings
//! - ✅ GetCurrentTransportActions
//! - ✅ Stop, Play, Pause
//!
//! Et certaines actions optionnelles :
//! - ✅ SetNextAVTransportURI
//! - ✅ Seek, Next, Previous
//!
//! ## Variables d'état
//!
//! Le service expose 24 variables d'état conformes à la spécification :
//!
//! ### État du transport
//! - [`TRANSPORTSTATE`] : État actuel (PLAYING, STOPPED, PAUSED_PLAYBACK, etc.)
//! - [`TRANSPORTSTATUS`] : Status du transport (OK, ERROR_OCCURRED)
//! - [`TRANSPORTPLAYSPEED`] : Vitesse de lecture
//!
//! ### Information sur les pistes
//! - [`CURRENTTRACK`] : Numéro de la piste actuelle
//! - [`NUMBEROFTRACKS`] : Nombre total de pistes
//! - [`CURRENTTRACKDURATION`] : Durée de la piste actuelle
//! - [`CURRENTTRACKURI`] : URI de la piste actuelle
//! - [`CURRENTTRACKMETADATA`] : Métadonnées de la piste
//!
//! ### Positionnement
//! - [`RELATIVETIMEPOSITION`] : Position relative dans la piste
//! - [`ABSOLUTETIMEPOSITION`] : Position absolue
//!
//! ### URIs et métadonnées
//! - [`AVTRANSPORTURI`] : URI de la ressource en cours
//! - [`AVTRANSPORTURIMETADATA`] : Métadonnées associées
//! - [`AVTRANSPORTNEXTURI`] : URI de la ressource suivante
//! - [`AVTRANSPORTNEXTURIMETADATA`] : Métadonnées de la ressource suivante
//!
//! ### Modes et capacités
//! - [`CURRENTPLAYMODE`] : Mode de lecture (NORMAL, SHUFFLE, REPEAT_ONE, etc.)
//! - [`PLAYBACKSTORAGEMEDIUM`] : Support de lecture (NETWORK, HDD, CD-DA, etc.)
//! - [`POSSIBLEPLAYBACKSTORAGEMEDIA`] : Supports de lecture possibles
//!
//! ## Examples
//!
//! ```rust
//! use pmoupnp::mediarenderer::avtransport::AVTTRANSPORT;
//!
//! // Accéder au service
//! let service = &*AVTTRANSPORT;
//! println!("Service: {}", service.name());
//! println!("Type: {}", service.service_type());
//!
//! // Lister les actions disponibles
//! for action in service.actions() {
//! println!(" Action: {}", action.get_name());
//! }
//!
//! // Lister les variables d'état
//! for variable in service.variables() {
//! println!(" Variable: {}", variable.get_name());
//! }
//! ```
//!
//! ## Références
//!
//! - [UPnP AVTransport:1 Service Template](https://www.upnp.org/specs/av/UPnP-av-AVTransport-v1-Service.pdf)
//! - [UPnP AV Architecture](https://upnp.org/specs/av/)
use crate::define_service;
pub mod variables;
pub mod actions;
use actions::{
GETCURRENTTRANSPORTACTIONS, GETDEVICECAPABILITIES, GETMEDIAINFO,
GETPOSITIONINFO, GETTRANSPORTINFO, GETTRANSPORTSETTINGS, NEXT, PAUSE,
PLAY, PREVIOUS, SEEK, SETNEXTAVTRANSPORTURI, SETAVTRANSPORTURI, STOP
};
use variables::{
ABSOLUTETIMEPOSITION, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA,
AVTRANSPORTURI, AVTRANSPORTURIMETADATA, A_ARG_TYPE_INSTANCE_ID,
A_ARG_TYPE_PLAY_SPEED, A_ARG_TYPE_SEEKMODE, CURRENTMEDIADURATION,
CURRENTPLAYMODE, CURRENTTRACK, CURRENTTRACKDURATION, CURRENTTRACKMETADATA,
CURRENTTRACKURI, NUMBEROFTRACKS, PLAYBACKSTORAGEMEDIUM,
POSSIBLEPLAYBACKSTORAGEMEDIA, RELATIVETIMEPOSITION, SEEKMODE,
TRANSPORTPLAYSPEED, TRANSPORTSTATE, TRANSPORTSTATUS
};
// Service AVTransport:1 conforme à la spécification UPnP AV pour MediaRenderer audio
// Voir la documentation du module pour plus de détails
define_service! {
pub static AVTTRANSPORT = "AVTransport" {
variables: [
ABSOLUTETIMEPOSITION,
A_ARG_TYPE_INSTANCE_ID,
A_ARG_TYPE_PLAY_SPEED,
A_ARG_TYPE_SEEKMODE,
AVTRANSPORTNEXTURI,
AVTRANSPORTNEXTURIMETADATA,
AVTRANSPORTURI,
AVTRANSPORTURIMETADATA,
CURRENTMEDIADURATION,
CURRENTPLAYMODE,
CURRENTTRACK,
CURRENTTRACKDURATION,
CURRENTTRACKMETADATA,
CURRENTTRACKURI,
NUMBEROFTRACKS,
PLAYBACKSTORAGEMEDIUM,
POSSIBLEPLAYBACKSTORAGEMEDIA,
RELATIVETIMEPOSITION,
SEEKMODE,
TRANSPORTPLAYSPEED,
TRANSPORTSTATE,
TRANSPORTSTATUS,
],
actions: [
GETCURRENTTRANSPORTACTIONS,
GETDEVICECAPABILITIES,
GETMEDIAINFO,
GETPOSITIONINFO,
GETTRANSPORTINFO,
GETTRANSPORTSETTINGS,
NEXT,
PAUSE,
PLAY,
PREVIOUS,
SEEK,
SETNEXTAVTRANSPORTURI,
SETAVTRANSPORTURI,
STOP,
]
}
}

View File

@@ -0,0 +1,5 @@
use crate::define_variable;
define_variable! {
pub static A_ARG_TYPE_INSTANCE_ID: UI4 = "A_ARG_TYPE_InstanceID"
}

View File

@@ -0,0 +1,5 @@
use crate::define_variable;
define_variable! {
pub static A_ARG_TYPE_PLAY_SPEED: String = "A_ARG_TYPE_PlaySpeed"
}

View File

@@ -0,0 +1,7 @@
use crate::define_variable;
define_variable! {
pub static A_ARG_TYPE_SEEKMODE: String = "A_ARG_TYPE_SeekMode" {
allowed: ["TRACK_NR", "REL_TIME", "ABS_TIME"],
}
}

View File

@@ -0,0 +1,9 @@
use crate::define_variable;
define_variable! {
pub static AVTRANSPORTURI: String = "AVTransportURI"
}
define_variable! {
pub static AVTRANSPORTNEXTURI: String = "AVTransportNextURI"
}

View File

@@ -0,0 +1,31 @@
use std::sync::Arc;
use crate::state_variables::{StateVariable, StateVariableError};
use crate::variable_types::StateVarType;
use bevy_reflect::Reflect;
use once_cell::sync::Lazy;
use pmodidl::{DIDLLite, MediaMetadataParser};
fn avtransporturimetadataparser(value: &str) -> Result<Box<dyn Reflect>, StateVariableError> {
// Parse DIDL-Lite
let didl = DIDLLite::parse(value)
.map_err(|e| StateVariableError::ParseError(format!("Failed to parse DIDL-Lite: {}", e)))?;
// Retourne le résultat sous forme de Box<dyn Reflect>
Ok(Box::new(didl) as Box<dyn Reflect>)
}
pub static AVTRANSPORTURIMETADATA: Lazy<Arc<StateVariable>> = Lazy::new(|| -> Arc<StateVariable> {
let mut sv = StateVariable::new(StateVarType::String, "AVTransportURIMetaData".to_string());
sv.set_value_parser(Arc::new(avtransporturimetadataparser)).expect("Failed to set parser");
Arc::new(sv)
});
pub static AVTRANSPORTNEXTURIMETADATA: Lazy<Arc<StateVariable>> = Lazy::new(|| -> Arc<StateVariable> {
let mut sv = StateVariable::new(StateVarType::String, "AVTransportNextURIMetaData".to_string());
sv.set_value_parser(Arc::new(avtransporturimetadataparser)).expect("Failed to set parser");
Arc::new(sv)
});

View File

@@ -0,0 +1,5 @@
use crate::define_variable;
define_variable! {
pub static CURRENTMEDIADURATION: String = "CurrentMediaDuration"
}

View File

@@ -0,0 +1,8 @@
use crate::define_variable;
define_variable! {
pub static CURRENTPLAYMODE: String = "CurrentPlayMode" {
allowed: ["NORMAL", "SHUFFLE", "REPEAT_ONE", "REPEAT_ALL", "RANDOM", "DIRECT_1", "INTRO"],
default: "NORMAL",
}
}

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