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