diff --git a/cmd/pmomusic/main.go b/cmd/pmomusic/main.go
index c1e1e805..e66413bc 100644
--- a/cmd/pmomusic/main.go
+++ b/cmd/pmomusic/main.go
@@ -1,7 +1,7 @@
package main
import (
- "log"
+ log "github.com/sirupsen/logrus"
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/netutils"
"gargoton.petite-maison-orange.fr/eric/pmomusic/internal/ssdp"
@@ -15,14 +15,27 @@ func main() {
log.Fatalf("Could not guess local IP: %v", err)
}
- location := "http://" + localIP + ":1400/description.xml"
- usn := "uuid:pmomusic-renderer-001"
+ usn := ssdp.GetUUID()
+ desc := upnp.NewDevice(localIP, 1400, usn, "pmomusic Fake Renderer", "pmomusic")
+
+ desc.RegisterService("urn:schemas-upnp-org:service:AVTransport:1")
+ desc.RegisterService("urn:schemas-upnp-org:service:RenderingControl:1")
+ desc.RegisterService("urn:schemas-upnp-org:service:ConnectionManager:1")
+
+ desc.RegisterService("urn:av-openhome-org:service:Product:1")
+ desc.RegisterService("urn:av-openhome-org:service:Playlist:1")
+ desc.RegisterService("urn:av-openhome-org:service:Info:1")
+
+ location := "http://" + localIP + ":1400/description.xml"
+ log.Printf("Server UUID: %s", usn)
+
+ log.Info(desc.GenerateXML())
// Démarre la diffusion SSDP
- go ssdp.AnnounceRenderer(usn, location)
- go ssdp.StartSSDPResponder(usn, location)
+ go ssdp.AnnounceRenderer(desc)
+ go ssdp.StartSSDPResponder(desc)
// Lance le serveur HTTP
log.Println("Starting HTTP server at:", location)
- upnp.StartHTTPServer(usn, localIP, 1400)
+ upnp.StartHTTPServer(desc)
}
diff --git a/go.mod b/go.mod
index ca72e2c3..82e7cc88 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,11 @@ module gargoton.petite-maison-orange.fr/eric/pmomusic
go 1.24.2
-require github.com/koron/go-ssdp v0.0.6
+require (
+ github.com/google/uuid v1.6.0
+ github.com/koron/go-ssdp v0.0.6
+ github.com/sirupsen/logrus v1.9.3
+)
require (
golang.org/x/net v0.39.0 // indirect
diff --git a/go.sum b/go.sum
index 9c51064a..fcc6ea4c 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,22 @@
+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/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/koron/go-ssdp v0.0.6 h1:Jb0h04599eq/CY7rB5YEqPS83HmRfHP2azkxMN2rFtU=
github.com/koron/go-ssdp v0.0.6/go.mod h1:0R9LfRJGek1zWTjN3JUNlm5INCDYGpRDfAptnct63fI=
+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 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.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 h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/ssdp/responder.go b/internal/ssdp/responder.go
index 19064f48..69d0d7b7 100644
--- a/internal/ssdp/responder.go
+++ b/internal/ssdp/responder.go
@@ -4,8 +4,11 @@ import (
"fmt"
"log"
"net"
+ "strconv"
"strings"
"time"
+
+ "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp"
)
const (
@@ -13,8 +16,19 @@ const (
serviceType = "urn:schemas-upnp-org:device:MediaRenderer:1"
)
+func fromUDPAddress(src *net.UDPAddr) string {
+ from := src.String()
+
+ hostnames, err := net.LookupAddr(src.IP.String())
+ if err == nil {
+ from = strings.TrimSuffix(hostnames[0], ".") + ":" + strconv.Itoa(src.Port)
+ }
+
+ return from
+}
+
// StartSSDPResponder listens for M-SEARCH requests and responds if they match
-func StartSSDPResponder(usn, location string) {
+func StartSSDPResponder(device *upnp.DeviceDescription) {
addr, err := net.ResolveUDPAddr("udp4", ssdpAddr)
if err != nil {
log.Fatal("Failed to resolve SSDP address:", err)
@@ -41,7 +55,8 @@ func StartSSDPResponder(usn, location string) {
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)
+
+ log.Printf("🔎 M-SEARCH from %s, ST=%s\n", fromUDPAddress(src), st)
if st == "ssdp:all" ||
st == serviceType ||
@@ -49,27 +64,27 @@ func StartSSDPResponder(usn, location string) {
strings.HasPrefix(st, "urn:av-openhome-org:service:") ||
strings.HasPrefix(st, "urn:bubblesoftapps-com:service:") {
- go sendSSDPResponse(src, usn, location, st)
+ go sendSSDPResponse(src, device, st)
}
}
}
}
-func sendSSDPResponse(dst *net.UDPAddr, usn, location, st string) {
+func sendSSDPResponse(dst *net.UDPAddr, device *upnp.DeviceDescription, st 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"+
+ "LOCATION: %s:%d\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,
+ device.IP, device.Port,
st,
- usn,
+ device.USN,
st,
)
diff --git a/internal/ssdp/ssdp.go b/internal/ssdp/ssdp.go
index 2f5149f6..e1b9e5a8 100644
--- a/internal/ssdp/ssdp.go
+++ b/internal/ssdp/ssdp.go
@@ -2,17 +2,19 @@ package ssdp
import (
"log"
+ "strconv"
"time"
+ "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp"
"github.com/koron/go-ssdp"
)
-func AnnounceRenderer(usn, location string) {
+func AnnounceRenderer(device *upnp.DeviceDescription) {
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)
+ _, err := ssdp.Advertise(st, device.USN+"::"+st, device.IP+":"+strconv.Itoa(int(device.Port)), server, maxAge)
if err != nil {
log.Println("SSDP advertise error:", err)
return
diff --git a/internal/ssdp/uuid.go b/internal/ssdp/uuid.go
new file mode 100644
index 00000000..67fe3c85
--- /dev/null
+++ b/internal/ssdp/uuid.go
@@ -0,0 +1,42 @@
+package ssdp
+
+import (
+ "os"
+ "path/filepath"
+
+ "github.com/google/uuid"
+)
+
+var __UUID = ""
+
+func loadOrCreateUUID() (string, error) {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return "", err
+ }
+ filePath := filepath.Join(configDir, "pmomusic", "uuid.txt")
+ os.MkdirAll(filepath.Dir(filePath), 0755)
+
+ if data, err := os.ReadFile(filePath); err == nil && len(data) > 0 {
+ return string(data), nil
+ }
+
+ id := "uuid:pmo-" + uuid.New().String()
+ if err := os.WriteFile(filePath, []byte(id), 0600); err != nil {
+ return "", err
+ }
+ return id, nil
+}
+
+func GetUUID() string {
+ if __UUID == "" {
+ u, err := loadOrCreateUUID()
+
+ if err != nil {
+ panic(err)
+ }
+
+ __UUID = u
+ }
+ return __UUID
+}
diff --git a/internal/upnp/http.go b/internal/upnp/description_xml.go
similarity index 63%
rename from internal/upnp/http.go
rename to internal/upnp/description_xml.go
index bb162595..b0c38579 100644
--- a/internal/upnp/http.go
+++ b/internal/upnp/description_xml.go
@@ -4,7 +4,7 @@ import (
"fmt"
)
-func generateDeviceDescription(usn, ip string, port uint) string {
+func generateDeviceDescription(device *DeviceDescription) string {
return fmt.Sprintf(`
@@ -45,7 +45,28 @@ func generateDeviceDescription(usn, ip string, port uint) string {
/upnp/event/ConnectionManager
/scpd/ConnectionManager.xml
+
+ urn:av-openhome-org:service:Product:1
+ urn:av-openhome-org:serviceId:Product
+ /upnp/control/Product
+ /upnp/event/Product
+ /scpd/Product.xml
+
+
+ urn:av-openhome-org:service:Playlist:1
+ urn:av-openhome-org:serviceId:Playlist
+ /upnp/control/Playlist
+ /upnp/event/Playlist
+ /scpd/Playlist.xml
+
+
+ urn:av-openhome-org:service:Info:1
+ urn:av-openhome-org:serviceId:Info
+ /upnp/control/Info
+ /upnp/event/Info
+ /scpd/Info.xml
+
-`, ip, port, usn, ip, port)
+`, device.IP, device.Port, device.USN, device.IP, device.Port)
}
diff --git a/internal/upnp/device.go b/internal/upnp/device.go
index cd2e6442..48ebe4c1 100644
--- a/internal/upnp/device.go
+++ b/internal/upnp/device.go
@@ -1 +1,93 @@
package upnp
+
+import (
+ "bytes"
+ "text/template"
+
+ log "github.com/sirupsen/logrus"
+)
+
+type DeviceDescription struct {
+ IP string
+ Port uint
+ USN string
+ Friendly string
+ ModelName string
+ Services *ServiceList
+}
+
+const deviceTemplate = `
+
+
+ 1
+ 0
+
+ http://{{.IP}}:{{.Port}}/
+
+ urn:schemas-upnp-org:device:MediaRenderer:1
+ {{.Friendly}}
+ GoDLNA
+ http://example.com
+ Fake DLNA Renderer
+ {{.ModelName}}
+ 1.0
+ http://example.com/model
+ {{.USN}}
+ http://{{.IP}}:{{.Port}}/
+ {{.ServicesXML}}
+
+`
+
+func NewDevice(ip string, port uint, usn string, friendly string, modelname string) *DeviceDescription {
+ services := NewServiceList()
+ return &DeviceDescription{
+ IP: ip,
+ Port: port,
+ USN: usn,
+ Friendly: friendly,
+ ModelName: modelname,
+ Services: services,
+ }
+}
+
+func (d *DeviceDescription) RegisterService(service string) {
+ d.Services.Append(service)
+}
+
+func (d *DeviceDescription) GenerateXML() (string, error) {
+ servicesXML, err := d.Services.GenerateServiceListXML()
+ if err != nil {
+ log.Errorf("Failed to generate service list XML: %v", err)
+ return "", err
+ }
+
+ tmpl, err := template.New("device").Parse(deviceTemplate)
+ if err != nil {
+ log.Errorf("Failed to parse device description template: %v", err)
+ return "", err
+ }
+
+ data := struct {
+ IP string
+ Port uint
+ USN string
+ Friendly string
+ ModelName string
+ ServicesXML string // trusted raw XML
+ }{
+ IP: d.IP,
+ Port: d.Port,
+ USN: d.USN,
+ Friendly: d.Friendly,
+ ModelName: d.ModelName,
+ ServicesXML: servicesXML,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ log.Errorf("Failed to execute template: %v", err)
+ return "", err
+ }
+
+ return buf.String(), nil
+}
diff --git a/web/index.html b/internal/upnp/html/index.html
similarity index 100%
rename from web/index.html
rename to internal/upnp/html/index.html
diff --git a/internal/upnp/server.go b/internal/upnp/server.go
index b69ef18c..ccfa8def 100644
--- a/internal/upnp/server.go
+++ b/internal/upnp/server.go
@@ -13,6 +13,9 @@ import (
//go:embed xml/*.xml
var embeddedXML embed.FS
+//go:embed html/*.html
+var embeddedHTML embed.FS
+
// ServeStaticXML mounts handlers for SCPD XML files.
func ServeStaticXML(mux *http.ServeMux) {
subFS, err := fs.Sub(embeddedXML, "xml")
@@ -22,13 +25,28 @@ func ServeStaticXML(mux *http.ServeMux) {
mux.Handle("/scpd/", http.StripPrefix("/scpd/", http.FileServer(http.FS(subFS))))
}
-func StartHTTPServer(usn, ip string, port uint) {
+func ServeStaticHTML(mux *http.ServeMux) {
+ subFS, err := fs.Sub(embeddedHTML, "html")
+ if err != nil {
+ panic("failed to create sub FS: " + err.Error())
+ }
+ mux.Handle("/", http.FileServer(http.FS(subFS)))
+}
+
+func StartHTTPServer(device *DeviceDescription) {
mux := http.NewServeMux()
// Device description
mux.HandleFunc("/description.xml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
- w.Write([]byte(generateDeviceDescription(usn, ip, port)))
+ xml, err := device.GenerateXML()
+ if err != nil {
+ w.WriteHeader(500)
+ w.Write([]byte(err.Error()))
+ return
+ }
+
+ w.Write([]byte(xml))
})
// SOAP control endpoints
@@ -38,8 +56,9 @@ func StartHTTPServer(usn, ip string, port uint) {
// Serve static SCPD XML files
ServeStaticXML(mux)
+ ServeStaticHTML(mux)
- addr := fmt.Sprintf("%s:%d", ip, port)
+ addr := fmt.Sprintf("%s:%d", device.IP, device.Port)
log.Printf("Serving UPnP fake renderer at http://%s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
diff --git a/internal/upnp/service.go b/internal/upnp/service.go
index cd2e6442..b655c313 100644
--- a/internal/upnp/service.go
+++ b/internal/upnp/service.go
@@ -1 +1,115 @@
package upnp
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+ "text/template"
+)
+
+type ServiceGroup string
+
+const (
+ UPnP ServiceGroup = "upnp"
+ OpenHome ServiceGroup = "openhome"
+ Other ServiceGroup = "other"
+)
+
+type ServiceDescription struct {
+ Group ServiceGroup
+ Type string
+ Name string
+ ID string
+ URLPrefix string
+ XML string // auto-rempli temporairement pour le template parent
+}
+
+const serviceTemplate = `
+
+ {{.Type}}
+ {{.ID}}
+ {{.URLPrefix}}/control
+ {{.URLPrefix}}/event
+ {{.URLPrefix}}.xml
+
+`
+
+func NewServiceDescription(serviceType string) *ServiceDescription {
+ parts := strings.Split(serviceType, ":")
+ name := parts[len(parts)-2]
+
+ var group ServiceGroup
+ switch {
+ case strings.Contains(serviceType, "schemas-upnp-org"):
+ group = UPnP
+ case strings.Contains(serviceType, "av-openhome-org"):
+ group = OpenHome
+ default:
+ group = Other
+ }
+
+ id := fmt.Sprintf("urn:%s:serviceId:%s", string(group), name)
+ urlPrefix := fmt.Sprintf("/%s/%s", group, name)
+
+ return &ServiceDescription{
+ Group: group,
+ Type: serviceType,
+ Name: name,
+ ID: id,
+ URLPrefix: urlPrefix,
+ }
+}
+
+func (desc *ServiceDescription) GenerateServiceXML() (string, error) {
+ tmpl, err := template.New("service").Parse(strings.TrimSpace(serviceTemplate))
+ if err != nil {
+ return "", err
+ }
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, desc); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
+
+type ServiceList struct {
+ Services []*ServiceDescription
+}
+
+func NewServiceList() *ServiceList {
+ return &ServiceList{
+ Services: make([]*ServiceDescription, 0),
+ }
+}
+
+func (sl *ServiceList) Append(serviceType string) {
+ svc := NewServiceDescription(serviceType)
+ sl.Services = append(sl.Services, svc)
+}
+
+func (sl *ServiceList) GenerateServiceListXML() (string, error) {
+ const listTemplate = `
+
+{{range .Services}}{{.XML}}
+{{end}}`
+
+ // Génère tous les XML individuellement
+ for _, svc := range sl.Services {
+ xml, err := svc.GenerateServiceXML()
+ if err != nil {
+ return "", err
+ }
+ svc.XML = xml
+ }
+
+ // Template principal
+ tmpl, err := template.New("list").Parse(strings.TrimSpace(listTemplate))
+ if err != nil {
+ return "", err
+ }
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, sl); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
diff --git a/internal/upnp/xml/Info.xml b/internal/upnp/xml/Info.xml
new file mode 100644
index 00000000..29e82e9c
--- /dev/null
+++ b/internal/upnp/xml/Info.xml
@@ -0,0 +1,111 @@
+
+
+
+ 1
+ 0
+
+
+
+
+ Track
+
+
+ Uri
+ out
+ TrackUri
+
+
+ Metadata
+ out
+ TrackMetadata
+
+
+
+
+
+ Details
+
+
+ Duration
+ out
+ TrackDuration
+
+
+ BitRate
+ out
+ TrackBitRate
+
+
+ BitDepth
+ out
+ TrackBitDepth
+
+
+ SampleRate
+ out
+ TrackSampleRate
+
+
+ Lossless
+ out
+ TrackLossless
+
+
+ CodecName
+ out
+ TrackCodecName
+
+
+
+
+
+ Metatext
+
+
+ Value
+ out
+ Metatext
+
+
+
+
+
+
+
+ TrackUri
+ string
+
+
+ TrackMetadata
+ string
+
+
+ TrackDuration
+ ui4
+
+
+ TrackBitRate
+ ui4
+
+
+ TrackBitDepth
+ ui4
+
+
+ TrackSampleRate
+ ui4
+
+
+ TrackLossless
+ boolean
+
+
+ TrackCodecName
+ string
+
+
+ Metatext
+ string
+
+
+
diff --git a/internal/upnp/xml/Playlist.xml b/internal/upnp/xml/Playlist.xml
new file mode 100644
index 00000000..3ee06982
--- /dev/null
+++ b/internal/upnp/xml/Playlist.xml
@@ -0,0 +1,89 @@
+
+
+
+ 1
+ 0
+
+
+
+
+ Play
+
+
+ Value
+ in
+ TransportState
+
+
+
+
+
+ Pause
+
+
+
+ Stop
+
+
+
+ Next
+
+
+
+ Previous
+
+
+
+ Insert
+
+
+ AfterId
+ in
+ TrackId
+
+
+ Uri
+ in
+ Uri
+
+
+ Metadata
+ in
+ Metadata
+
+
+ NewId
+ out
+ TrackId
+
+
+
+
+
+
+
+ TransportState
+ string
+
+ Playing
+ Paused
+ Stopped
+
+
+
+
+ Uri
+ string
+
+
+
+ Metadata
+ string
+
+
+
+ TrackId
+ ui4
+
+
+
diff --git a/internal/upnp/xml/Product.xml b/internal/upnp/xml/Product.xml
new file mode 100644
index 00000000..a56f5f4f
--- /dev/null
+++ b/internal/upnp/xml/Product.xml
@@ -0,0 +1,86 @@
+
+
+
+ 1
+ 0
+
+
+
+
+ Manufacturer
+
+
+ Value
+ out
+ Manufacturer
+
+
+
+
+
+ Model
+
+
+ Value
+ out
+ Model
+
+
+
+
+
+ Product
+
+
+ Value
+ out
+ Product
+
+
+
+
+
+ Standby
+
+
+ Value
+ in
+ Standby
+
+
+
+
+
+ SetStandby
+
+
+ Value
+ in
+ Standby
+
+
+
+
+
+
+
+ Manufacturer
+ string
+
+
+
+ Model
+ string
+
+
+
+ Product
+ string
+
+
+
+ Standby
+ boolean
+
+
+