From c80f49f615987b66ba06885112921d530d393b01 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 8 Jun 2025 13:25:01 +0200 Subject: [PATCH] Premier commit --- .gitignore | 4 ++ Makefile | 12 +++++ cmd/pmomusic/main.go | 22 ++++++++ go.mod | 10 ++++ go.sum | 6 +++ internal/renderer/renderer.go | 1 + internal/soap/soap.go | 18 +++++++ internal/ssdp/responder.go | 94 +++++++++++++++++++++++++++++++++++ internal/ssdp/ssdp.go | 43 ++++++++++++++++ internal/upnp/device.go | 1 + internal/upnp/http.go | 65 ++++++++++++++++++++++++ internal/upnp/service.go | 1 + web/index.html | 0 13 files changed, 277 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/pmomusic/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/renderer/renderer.go create mode 100644 internal/soap/soap.go create mode 100644 internal/ssdp/responder.go create mode 100644 internal/ssdp/ssdp.go create mode 100644 internal/upnp/device.go create mode 100644 internal/upnp/http.go create mode 100644 internal/upnp/service.go create mode 100644 web/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..1fd16302 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/bin/ +/pkg/ +/vendor/ +*.log diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..132f1b20 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +APP_NAME=pmomusic + +build: + go build -o bin/$(APP_NAME) ./cmd/$(APP_NAME) + +run: + go run ./cmd/$(APP_NAME) + +clean: + rm -rf bin/ + +all: build \ No newline at end of file diff --git a/cmd/pmomusic/main.go b/cmd/pmomusic/main.go new file mode 100644 index 00000000..7cfe12a6 --- /dev/null +++ b/cmd/pmomusic/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "log" + + "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/ssdp" + "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp" +) + +func main() { + localIP := ssdp.GetLocalIP() + location := "http://" + localIP + ":1400/description.xml" + usn := "uuid:pmomusic-renderer-001" + + // Démarre la diffusion SSDP + go ssdp.AnnounceRenderer(usn, location) + go ssdp.StartSSDPResponder(usn, location) + + // Lance le serveur HTTP + log.Println("Starting HTTP server at:", location) + upnp.StartHTTPServer(usn) +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..ca72e2c3 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module gargoton.petite-maison-orange.fr/eric/pmomusic + +go 1.24.2 + +require github.com/koron/go-ssdp v0.0.6 + +require ( + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..9c51064a --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/koron/go-ssdp v0.0.6 h1:Jb0h04599eq/CY7rB5YEqPS83HmRfHP2azkxMN2rFtU= +github.com/koron/go-ssdp v0.0.6/go.mod h1:0R9LfRJGek1zWTjN3JUNlm5INCDYGpRDfAptnct63fI= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go new file mode 100644 index 00000000..35803f64 --- /dev/null +++ b/internal/renderer/renderer.go @@ -0,0 +1 @@ +package renderer diff --git a/internal/soap/soap.go b/internal/soap/soap.go new file mode 100644 index 00000000..d7f2d016 --- /dev/null +++ b/internal/soap/soap.go @@ -0,0 +1,18 @@ +package soap + +import ( + "io" + "log" + "net/http" +) + +func HandleSOAP(w http.ResponseWriter, r *http.Request) { + action := r.Header.Get("SOAPACTION") + body, _ := io.ReadAll(r.Body) + + log.Println("----------") + log.Println("SOAPAction:", action) + log.Println("Body:\n", string(body)) + + w.WriteHeader(http.StatusOK) +} diff --git a/internal/ssdp/responder.go b/internal/ssdp/responder.go new file mode 100644 index 00000000..2dece7ec --- /dev/null +++ b/internal/ssdp/responder.go @@ -0,0 +1,94 @@ +package ssdp + +import ( + "fmt" + "log" + "net" + "strings" + "time" +) + +const ( + ssdpAddr = "239.255.255.250:1900" + serviceType = "urn:schemas-upnp-org:device:MediaRenderer:1" +) + +// StartSSDPResponder listens for M-SEARCH requests and responds if they match +func StartSSDPResponder(usn, location string) { + addr, err := net.ResolveUDPAddr("udp4", ssdpAddr) + if err != nil { + log.Fatal("Failed to resolve SSDP address:", err) + } + + conn, err := net.ListenMulticastUDP("udp4", nil, addr) + if err != nil { + log.Fatal("Failed to listen for SSDP:", err) + } + defer conn.Close() + + conn.SetReadBuffer(2048) + + buf := make([]byte, 2048) + log.Println("🔍 Listening for M-SEARCH...") + + for { + n, src, err := conn.ReadFromUDP(buf) + if err != nil { + log.Println("ReadFromUDP error:", err) + continue + } + + data := string(buf[:n]) + if strings.HasPrefix(data, "M-SEARCH * HTTP/1.1") { + st := extractHeader(data, "ST") + log.Printf("🔎 M-SEARCH from %s, ST=%s\n", src.String(), st) + + if st == "ssdp:all" || st == serviceType { + go sendSSDPResponse(src, usn, location) + } + } + } +} + +func sendSSDPResponse(dst *net.UDPAddr, usn, location string) { + resp := fmt.Sprintf( + "HTTP/1.1 200 OK\r\n"+ + "CACHE-CONTROL: max-age=1800\r\n"+ + "DATE: %s\r\n"+ + "EXT:\r\n"+ + "LOCATION: %s\r\n"+ + "SERVER: pmomusic/1.0 UPnP/1.1 DLNARenderer/1.0\r\n"+ + "ST: %s\r\n"+ + "USN: %s::%s\r\n"+ + "\r\n", + time.Now().Format(time.RFC1123), + location, + serviceType, + usn, + serviceType, + ) + + conn, err := net.DialUDP("udp4", nil, dst) + if err != nil { + log.Println("Failed to dial UDP to respond:", err) + return + } + defer conn.Close() + + _, err = conn.Write([]byte(resp)) + if err != nil { + log.Println("Failed to send SSDP response:", err) + } +} + +func extractHeader(data string, key string) string { + lines := strings.Split(data, "\r\n") + key = strings.ToLower(key) + for _, line := range lines { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 && strings.ToLower(strings.TrimSpace(parts[0])) == key { + return strings.TrimSpace(parts[1]) + } + } + return "" +} diff --git a/internal/ssdp/ssdp.go b/internal/ssdp/ssdp.go new file mode 100644 index 00000000..4d340135 --- /dev/null +++ b/internal/ssdp/ssdp.go @@ -0,0 +1,43 @@ +package ssdp + +import ( + "log" + "net" + "time" + + "github.com/koron/go-ssdp" +) + +func AnnounceRenderer(usn, location string) { + st := "urn:schemas-upnp-org:device:MediaRenderer:1" + server := "pmomusic/1.0 UPnP/1.1 DLNARenderer/1.0" + maxAge := 1800 + + _, err := ssdp.Advertise(st, usn+"::"+st, location, server, maxAge) + if err != nil { + log.Println("SSDP advertise error:", err) + return + } + + log.Println("✅ SSDP advertisement started") + + // Ce `Advertiser` fait automatiquement le NOTIFY loop. + // Il est censé continuer à diffuser les `alive` tous les MaxAge / 2. + + // Exemple : on attend indéfiniment + for { + time.Sleep(time.Hour) + } + + // Un jour on voudra faire ça à l'arrêt : + // adv.Close() // envoie le ssdp:byebye +} +func GetLocalIP() string { + conn, err := net.Dial("udp", "239.255.255.250:1900") + if err != nil { + return "127.0.0.1" + } + defer conn.Close() + localAddr := conn.LocalAddr().(*net.UDPAddr) + return localAddr.IP.String() +} diff --git a/internal/upnp/device.go b/internal/upnp/device.go new file mode 100644 index 00000000..cd2e6442 --- /dev/null +++ b/internal/upnp/device.go @@ -0,0 +1 @@ +package upnp diff --git a/internal/upnp/http.go b/internal/upnp/http.go new file mode 100644 index 00000000..58a9c859 --- /dev/null +++ b/internal/upnp/http.go @@ -0,0 +1,65 @@ +package upnp + +import ( + "fmt" + "log" + "net/http" + + "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/soap" +) + +func StartHTTPServer(usn string) { + http.HandleFunc("/description.xml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + w.Write([]byte(generateDeviceDescription(usn))) + }) + + http.HandleFunc("/upnp/control/AVTransport", soap.HandleSOAP) + + log.Fatal(http.ListenAndServe(":1400", nil)) +} + +func generateDeviceDescription(usn string) string { + return fmt.Sprintf(` + + + 1 + 0 + + + urn:schemas-upnp-org:device:MediaRenderer:1 + pmomusic Fake Renderer + GoDLNA + http://example.com + Fake DLNA Renderer + pmomusic + 1.0 + http://example.com/model + %s + http://%s + + + urn:schemas-upnp-org:service:AVTransport:1 + urn:upnp-org:serviceId:AVTransport + /upnp/control/AVTransport + /upnp/event/AVTransport + /scpd/AVTransport.xml + + + urn:schemas-upnp-org:service:RenderingControl:1 + urn:upnp-org:serviceId:RenderingControl + /upnp/control/RenderingControl + /upnp/event/RenderingControl + /scpd/RenderingControl.xml + + + urn:schemas-upnp-org:service:ConnectionManager:1 + urn:upnp-org:serviceId:ConnectionManager + /upnp/control/ConnectionManager + /upnp/event/ConnectionManager + /scpd/ConnectionManager.xml + + + +`, usn, "127.0.0.1:1400") // à adapter à ton IP:port réel +} diff --git a/internal/upnp/service.go b/internal/upnp/service.go new file mode 100644 index 00000000..cd2e6442 --- /dev/null +++ b/internal/upnp/service.go @@ -0,0 +1 @@ +package upnp diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..e69de29b