Premier commit

This commit is contained in:
2025-06-08 13:25:01 +02:00
parent f0581234c0
commit c80f49f615
13 changed files with 277 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/bin/
/pkg/
/vendor/
*.log

12
Makefile Normal file
View File

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

22
cmd/pmomusic/main.go Normal file
View File

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

10
go.mod Normal file
View File

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

6
go.sum Normal file
View File

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

View File

@@ -0,0 +1 @@
package renderer

18
internal/soap/soap.go Normal file
View File

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

View File

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

43
internal/ssdp/ssdp.go Normal file
View File

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

1
internal/upnp/device.go Normal file
View File

@@ -0,0 +1 @@
package upnp

65
internal/upnp/http.go Normal file
View File

@@ -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(`<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<friendlyName>pmomusic Fake Renderer</friendlyName>
<manufacturer>GoDLNA</manufacturer>
<manufacturerURL>http://example.com</manufacturerURL>
<modelDescription>Fake DLNA Renderer</modelDescription>
<modelName>pmomusic</modelName>
<modelNumber>1.0</modelNumber>
<modelURL>http://example.com/model</modelURL>
<UDN>%s</UDN>
<presentationURL>http://%s</presentationURL>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
<controlURL>/upnp/control/AVTransport</controlURL>
<eventSubURL>/upnp/event/AVTransport</eventSubURL>
<SCPDURL>/scpd/AVTransport.xml</SCPDURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
<serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
<controlURL>/upnp/control/RenderingControl</controlURL>
<eventSubURL>/upnp/event/RenderingControl</eventSubURL>
<SCPDURL>/scpd/RenderingControl.xml</SCPDURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
<controlURL>/upnp/control/ConnectionManager</controlURL>
<eventSubURL>/upnp/event/ConnectionManager</eventSubURL>
<SCPDURL>/scpd/ConnectionManager.xml</SCPDURL>
</service>
</serviceList>
</device>
</root>`, usn, "127.0.0.1:1400") // à adapter à ton IP:port réel
}

1
internal/upnp/service.go Normal file
View File

@@ -0,0 +1 @@
package upnp

0
web/index.html Normal file
View File