Premier commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/bin/
|
||||
/pkg/
|
||||
/vendor/
|
||||
*.log
|
||||
12
Makefile
Normal file
12
Makefile
Normal 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
22
cmd/pmomusic/main.go
Normal 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
10
go.mod
Normal 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
6
go.sum
Normal 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=
|
||||
1
internal/renderer/renderer.go
Normal file
1
internal/renderer/renderer.go
Normal file
@@ -0,0 +1 @@
|
||||
package renderer
|
||||
18
internal/soap/soap.go
Normal file
18
internal/soap/soap.go
Normal 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)
|
||||
}
|
||||
94
internal/ssdp/responder.go
Normal file
94
internal/ssdp/responder.go
Normal 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
43
internal/ssdp/ssdp.go
Normal 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
1
internal/upnp/device.go
Normal file
@@ -0,0 +1 @@
|
||||
package upnp
|
||||
65
internal/upnp/http.go
Normal file
65
internal/upnp/http.go
Normal 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
1
internal/upnp/service.go
Normal file
@@ -0,0 +1 @@
|
||||
package upnp
|
||||
0
web/index.html
Normal file
0
web/index.html
Normal file
Reference in New Issue
Block a user