on avance

This commit is contained in:
2025-09-07 07:59:26 +02:00
parent f6caa6f6b3
commit 9b1592430a
13 changed files with 845 additions and 30 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"os/signal"
"syscall"
"time"
log "github.com/sirupsen/logrus"
@@ -28,4 +29,7 @@ func main() {
if err := server.Run(ctx); err != nil {
log.Fatalf("server error: %v", err)
}
time.Sleep(2 * time.Second)
}

129
didl/markdown.go Normal file
View File

@@ -0,0 +1,129 @@
package didl
import (
"fmt"
"strings"
)
func (d *DIDLLite) ToMarkdown() string {
var buf strings.Builder
buf.WriteString("# DIDL-Lite Document\n\n")
if len(d.Containers) > 0 {
buf.WriteString("## Containers\n\n")
for _, c := range d.Containers {
c.markdown(&buf, 0)
}
}
if len(d.Items) > 0 {
buf.WriteString("## Items\n\n")
for _, i := range d.Items {
i.markdown(&buf, 0)
}
}
return buf.String()
}
func (c *Container) markdown(buf *strings.Builder, depth int) {
indent := strings.Repeat(" ", depth)
buf.WriteString(fmt.Sprintf("%s- **Container**: %s\n", indent, c.Title))
buf.WriteString(fmt.Sprintf("%s - ID: `%s`\n", indent, c.ID))
buf.WriteString(fmt.Sprintf("%s - ParentID: `%s`\n", indent, c.ParentID))
buf.WriteString(fmt.Sprintf("%s - Class: `%s`\n", indent, c.Class))
if c.Restricted != "" {
buf.WriteString(fmt.Sprintf("%s - Restricted: `%s`\n", indent, c.Restricted))
}
if c.ChildCount != "" {
buf.WriteString(fmt.Sprintf("%s - ChildCount: `%s`\n", indent, c.ChildCount))
}
// Sous-conteneurs
if len(c.Containers) > 0 {
buf.WriteString(fmt.Sprintf("%s - Subcontainers:\n", indent))
for _, sub := range c.Containers {
sub.markdown(buf, depth+2)
}
}
// Items du conteneur
if len(c.Items) > 0 {
buf.WriteString(fmt.Sprintf("%s - Items:\n", indent))
for _, item := range c.Items {
item.markdown(buf, depth+2)
}
}
buf.WriteString("\n")
}
func (i *Item) markdown(buf *strings.Builder, depth int) {
indent := strings.Repeat(" ", depth)
buf.WriteString(fmt.Sprintf("%s- **Item**: %s\n", indent, i.Title))
buf.WriteString(fmt.Sprintf("%s - ID: `%s`\n", indent, i.ID))
buf.WriteString(fmt.Sprintf("%s - ParentID: `%s`\n", indent, i.ParentID))
buf.WriteString(fmt.Sprintf("%s - Class: `%s`\n", indent, i.Class))
if i.Creator != "" {
buf.WriteString(fmt.Sprintf("%s - Creator: %s\n", indent, i.Creator))
}
if i.Artist != "" {
buf.WriteString(fmt.Sprintf("%s - Artist: %s\n", indent, i.Artist))
}
if i.Album != "" {
buf.WriteString(fmt.Sprintf("%s - Album: %s\n", indent, i.Album))
}
if i.Genre != "" {
buf.WriteString(fmt.Sprintf("%s - Genre: %s\n", indent, i.Genre))
}
if i.AlbumArt != "" {
buf.WriteString(fmt.Sprintf("%s - Album Art: ![Cover](%s)\n", indent, i.AlbumArt))
}
if i.Date != "" {
buf.WriteString(fmt.Sprintf("%s - Date: %s\n", indent, i.Date))
}
if i.OriginalTrackNumber != "" {
buf.WriteString(fmt.Sprintf("%s - Track: %s\n", indent, i.OriginalTrackNumber))
}
// Ressources
if len(i.Ress) > 0 {
buf.WriteString(fmt.Sprintf("%s - Resources:\n", indent))
for _, res := range i.Ress {
buf.WriteString(fmt.Sprintf("%s - URL: %s\n", indent, res.URL))
buf.WriteString(fmt.Sprintf("%s - Protocol: `%s`\n", indent, res.ProtocolInfo))
if res.Duration != "" {
buf.WriteString(fmt.Sprintf("%s - Duration: `%s`\n", indent, res.Duration))
}
if res.BitsPerSample != "" {
buf.WriteString(fmt.Sprintf("%s - BitsPerSample: `%s`\n", indent, res.BitsPerSample))
}
if res.SampleFrequency != "" {
buf.WriteString(fmt.Sprintf("%s - SampleFrequency: `%s`\n", indent, res.SampleFrequency))
}
if res.NrAudioChannels != "" {
buf.WriteString(fmt.Sprintf("%s - Channels: `%s`\n", indent, res.NrAudioChannels))
}
}
}
// Descriptions
if len(i.Descs) > 0 {
buf.WriteString(fmt.Sprintf("%s - Descriptions:\n", indent))
for _, desc := range i.Descs {
buf.WriteString(fmt.Sprintf("%s - Namespace: `%s`\n", indent, desc.NameSpace))
if desc.TrackGain != "" {
buf.WriteString(fmt.Sprintf("%s - Track Gain: `%s`\n", indent, desc.TrackGain))
}
if desc.TrackPeak != "" {
buf.WriteString(fmt.Sprintf("%s - Track Peak: `%s`\n", indent, desc.TrackPeak))
}
}
}
buf.WriteString("\n")
}

66
didl/model.go Normal file
View File

@@ -0,0 +1,66 @@
package didl
import "encoding/xml"
// DIDLLite représente la racine <DIDL-Lite>
type DIDLLite struct {
XMLName xml.Name `xml:"DIDL-Lite"`
Xmlns string `xml:"xmlns,attr"`
XmlnsUpnp string `xml:"xmlns:upnp,attr,omitempty"`
XmlnsDc string `xml:"xmlns:dc,attr,omitempty"`
XmlnsDlna string `xml:"xmlns:dlna,attr,omitempty"`
XmlnsSec string `xml:"xmlns:sec,attr,omitempty"`
XmlnsPv string `xml:"xmlns:pv,attr,omitempty"`
Containers []Container `xml:"container"`
Items []Item `xml:"item"`
}
// Container peut contenir d'autres containers ou des items audio
type Container struct {
ID string `xml:"id,attr"`
ParentID string `xml:"parentID,attr"`
Restricted string `xml:"restricted,attr,omitempty"`
ChildCount string `xml:"childCount,attr,omitempty"`
Title string `xml:"http://purl.org/dc/elements/1.1/ title"`
Class string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ class"`
Containers []Container `xml:"container"`
Items []Item `xml:"item"`
}
// Item représente un objet audio
type Item struct {
ID string `xml:"id,attr"`
ParentID string `xml:"parentID,attr"`
Restricted string `xml:"restricted,attr,omitempty"`
Title string `xml:"http://purl.org/dc/elements/1.1/ title"`
Creator string `xml:"http://purl.org/dc/elements/1.1/ creator,omitempty"`
Class string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ class"`
Artist string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ artist,omitempty"`
Album string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ album,omitempty"`
Genre string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ genre,omitempty"`
AlbumArt string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ albumArtURI,omitempty"`
Date string `xml:"http://purl.org/dc/elements/1.1/ date,omitempty"`
OriginalTrackNumber string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ originalTrackNumber,omitempty"`
Ress []Res `xml:"res"`
Descs []Desc `xml:"desc"`
}
// Res correspond aux fichiers média
type Res struct {
ProtocolInfo string `xml:"protocolInfo,attr"`
BitsPerSample string `xml:"bitsPerSample,attr,omitempty"`
SampleFrequency string `xml:"sampleFrequency,attr,omitempty"`
NrAudioChannels string `xml:"nrAudioChannels,attr,omitempty"`
Duration string `xml:"duration,attr,omitempty"`
URL string `xml:",chardata"`
}
// Desc correspond aux métadonnées optionnelles comme replaygain
type Desc struct {
ID string `xml:"id,attr,omitempty"`
NameSpace string `xml:"nameSpace,attr,omitempty"`
TrackGain string `xml:"track_gain,omitempty"`
TrackPeak string `xml:"track_peak,omitempty"`
}

17
didl/parser.go Normal file
View File

@@ -0,0 +1,17 @@
package didl
import (
"encoding/xml"
"fmt"
)
func Parse(metadata string) (*DIDLLite, error) {
var didl DIDLLite
err := xml.Unmarshal([]byte(metadata), &didl)
if err != nil {
return nil, fmt.Errorf("failed to parse DIDL-Lite: %v", err)
}
return &didl, nil
}

View File

@@ -27,13 +27,12 @@ func (hook *SSELogHook) Levels() []logrus.Level {
}
func (hook *SSELogHook) Fire(entry *logrus.Entry) error {
// Formater le log
// Formater le log avec le niveau et le message
logLine := fmt.Sprintf("[%s] %s", entry.Level.String(), entry.Message)
// Envoyer le log à tous les clients connectés
broker.mutex.Lock()
for client := range broker.clients {
// Non-bloquant pour éviter qu'un client lent ne bloque tout
select {
case client <- logLine:
default:
@@ -62,14 +61,29 @@ func sseHandler(w http.ResponseWriter, r *http.Request) {
broker.mutex.Unlock()
// Envoyer un message de bienvenue
fmt.Fprintf(w, "data: %s\n\n", "Connexion établie. Attente des logs...")
fmt.Fprintf(w, "event: message\ndata: %s\n\n", "{\"content\": \"Connexion établie. Attente des logs...\", \"level\": \"info\"}")
w.(http.Flusher).Flush()
// Envoyer les logs au client au fur et à mesure
for {
select {
case msg := <-messageChan:
fmt.Fprintf(w, "data: %s\n\n", msg)
// Déterminer le niveau de log pour le style CSS
level := "info"
if len(msg) > 7 {
switch msg[1:6] {
case "ERROR":
level = "error"
case "WARNI":
level = "warning"
case "DEBUG":
level = "debug"
}
}
// Formater le message en JSON pour inclure le niveau
jsonMsg := fmt.Sprintf("{\"content\": \"%s\", \"level\": \"%s\"}", escapeJSONString(msg), level)
fmt.Fprintf(w, "event: message\ndata: %s\n\n", jsonMsg)
w.(http.Flusher).Flush()
case <-r.Context().Done():
// Supprimer le client quand la connexion est fermée
@@ -82,43 +96,168 @@ func sseHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Page HTML pour afficher les logs
// Fonction pour échapper les chaînes JSON
func escapeJSONString(s string) string {
// Échapper les guillemets et les antislashes
escaped := ""
for _, c := range s {
switch c {
case '"':
escaped += "\\\""
case '\\':
escaped += "\\\\"
case '\n':
escaped += "\\n"
case '\r':
escaped += "\\r"
case '\t':
escaped += "\\t"
default:
escaped += string(c)
}
}
return escaped
}
// Page HTML pour afficher les logs avec support Markdown
var indexHTML = `
<!DOCTYPE html>
<html>
<head>
<title>Logs en temps réel</title>
<style>
body { font-family: monospace; background: #000; color: #0f0; }
.log-line { margin: 2px 0; }
.error { color: #f00; }
.warning { color: #ff0; }
.info { color: #0f0; }
.debug { color: #0af; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #0d1117;
color: #e6edf3;
margin: 0;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #58a6ff;
border-bottom: 1px solid #30363d;
padding-bottom: 10px;
}
#logs {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 15px;
height: 70vh;
overflow-y: auto;
font-size: 14px;
line-height: 1.5;
}
.log-line {
margin: 8px 0;
padding: 8px 12px;
border-radius: 6px;
border-left: 4px solid #58a6ff;
}
.log-line.error {
border-left-color: #f85149;
background-color: rgba(248, 81, 73, 0.1);
}
.log-line.warning {
border-left-color: #d29922;
background-color: rgba(210, 153, 34, 0.1);
}
.log-line.info {
border-left-color: #58a6ff;
background-color: rgba(56, 139, 253, 0.1);
}
.log-line.debug {
border-left-color: #8957e5;
background-color: rgba(137, 87, 229, 0.1);
}
.timestamp {
color: #7d8590;
font-size: 12px;
margin-right: 10px;
}
/* Styles Markdown */
.markdown-body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.markdown-body code {
background-color: rgba(240, 246, 252, 0.15);
border-radius: 6px;
padding: 0.2em 0.4em;
font-family: ui-monospace, SFMono-Regular, SF Mono, Consolas, Liberation Mono, Menlo, monospace;
}
.markdown-body pre {
background-color: rgba(240, 246, 252, 0.15);
border-radius: 6px;
padding: 16px;
overflow: auto;
}
.markdown-body pre code {
background: none;
padding: 0;
}
.markdown-body blockquote {
border-left: 4px solid #30363d;
padding-left: 16px;
margin-left: 0;
color: #7d8590;
}
.markdown-body a {
color: #58a6ff;
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
</style>
<!-- Marked.js pour le rendu Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body>
<h1>Logs en temps réel</h1>
<div id="logs"></div>
<div class="container">
<h1>📝 Logs en temps réel</h1>
<div id="logs"></div>
</div>
<script>
const eventSource = new EventSource('/log-sse');
const logsContainer = document.getElementById('logs');
eventSource.onmessage = function(event) {
// Configuration de Marked.js
marked.setOptions({
breaks: true,
highlight: function(code, lang) {
// Simplement retourner le code non highlighté pour l'instant
return code;
}
});
eventSource.addEventListener('message', function(event) {
const data = JSON.parse(event.data);
const logLine = document.createElement('div');
logLine.className = 'log-line';
logLine.textContent = event.data;
logLine.className = 'log-line ' + data.level;
// Ajouter des classes CSS en fonction du niveau de log
if (event.data.includes('[error]')) logLine.classList.add('error');
else if (event.data.includes('[warning]')) logLine.classList.add('warning');
else if (event.data.includes('[info]')) logLine.classList.add('info');
else if (event.data.includes('[debug]')) logLine.classList.add('debug');
// Ajouter un timestamp
const timestamp = new Date().toLocaleTimeString();
const timestampSpan = document.createElement('span');
timestampSpan.className = 'timestamp';
timestampSpan.textContent = timestamp;
logLine.appendChild(timestampSpan);
// Traiter le contenu Markdown
const contentDiv = document.createElement('div');
contentDiv.className = 'markdown-body';
contentDiv.innerHTML = marked.parse(data.content);
logLine.appendChild(contentDiv);
logsContainer.appendChild(logLine);
// Défilement automatique
logsContainer.scrollTop = logsContainer.scrollHeight;
};
});
eventSource.onerror = function(error) {
console.error('Erreur SSE:', error);

86
soap/parseSoap.go Normal file
View File

@@ -0,0 +1,86 @@
package soap
import (
"bytes"
"encoding/xml"
"io"
"gargoton.petite-maison-orange.fr/eric/pmomusic/didl"
log "github.com/sirupsen/logrus"
)
type Envelope struct {
XMLName xml.Name `xml:"Envelope"`
Body Body `xml:"Body"`
}
type Body struct {
Content []byte `xml:",innerxml"` // <- capture tout le contenu du Body sous forme de XML brut
}
func prettyPrintXML(raw string) string {
var out bytes.Buffer
decoder := xml.NewDecoder(bytes.NewReader([]byte(raw)))
encoder := xml.NewEncoder(&out)
encoder.Indent("", " ") // définit l'indentation
for {
t, err := decoder.Token()
if err != nil {
break
}
if err := encoder.EncodeToken(t); err != nil {
break
}
}
encoder.Flush()
return out.String()
}
func ParseSOAPGeneric(body []byte) {
var env Envelope
if err := xml.Unmarshal(body, &env); err != nil {
log.Warnf("❌ Failed to unmarshal SOAP Envelope: %v", err)
return
}
decoder := xml.NewDecoder(bytes.NewReader(env.Body.Content))
var currentAction string
args := make(map[string]interface{})
for {
tok, err := decoder.Token()
if err != nil {
if err != io.EOF {
log.Warnf("❌ SOAP parse error: %v", err)
}
break
}
switch t := tok.(type) {
case xml.StartElement:
if currentAction == "" {
currentAction = t.Name.Local // nom de l'action
} else {
var value string
decoder.DecodeElement(&value, &t)
var ival interface{}
ival, err = didl.Parse(value)
if err == nil {
ival = ival.(*didl.DIDLLite).ToMarkdown()
} else {
ival = prettyPrintXML(value)
}
args[t.Name.Local] = ival
}
}
}
log.Infof("📡 SOAP Action: %s", currentAction)
for k, v := range args {
log.Infof(" %s = %v", k, v)
}
}

246
ssdp/server.go Normal file
View File

@@ -0,0 +1,246 @@
package ssdp
import (
"bufio"
"context"
"fmt"
"net"
"slices"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
const (
SsdpAddr = "239.255.255.250"
Port = 1900
MaxAge = 1800
)
type Device struct {
UUID string
DeviceType string
Location string
Server string
NTs []string
}
// GetNTs retourne la liste des NT à annoncer pour ce périphérique
func (d *Device) GetNTs() []string {
return d.NTs
}
type SSDPServer struct {
Devices map[string]*Device
mu sync.RWMutex
conn *net.UDPConn
}
// NewSSDPServer crée un serveur SSDP
func NewSSDPServer() *SSDPServer {
return &SSDPServer{
Devices: make(map[string]*Device),
}
}
// AddDevice ajoute un périphérique et envoie un alive initial
func (s *SSDPServer) AddDevice(d *Device) {
s.mu.Lock()
defer s.mu.Unlock()
s.Devices[d.UUID] = d
for _, nt := range d.GetNTs() {
s.SendAlive(d.UUID, nt, d.Location, d.Server)
}
}
// RemoveDevice supprime un périphérique et envoie un byebye
func (s *SSDPServer) RemoveDevice(uuid string) {
s.mu.Lock()
defer s.mu.Unlock()
d, ok := s.Devices[uuid]
if !ok {
return
}
for _, nt := range d.GetNTs() {
s.SendByeBye(d.UUID, nt)
}
delete(s.Devices, uuid)
}
// Start démarre l'écoute SSDP et envoie les alive périodiques
func (s *SSDPServer) Start(ctx context.Context) error {
addr := &net.UDPAddr{IP: net.ParseIP(SsdpAddr), Port: Port}
log.Infof("✅ Starting SSDP listener")
conn, err := net.ListenMulticastUDP("udp4", nil, addr)
if err != nil {
return err
}
conn.SetReadBuffer(8192)
s.conn = conn
// Alive périodique
go func() {
ticker := time.NewTicker(time.Duration(MaxAge/2) * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.mu.RLock()
for _, d := range s.Devices {
for _, nt := range d.GetNTs() {
s.SendAlive(d.UUID, nt, d.Location, d.Server)
}
}
s.mu.RUnlock()
}
}
}()
// Écoute des M-SEARCH
go func() {
buf := make([]byte, 8192)
for {
select {
case <-ctx.Done():
log.Infof("✅ Stopping SSDP listener, sending byebye for all devices")
s.mu.RLock()
for _, d := range s.Devices {
for _, nt := range d.GetNTs() {
s.SendByeBye(d.UUID, nt)
}
}
s.mu.RUnlock()
conn.Close()
return
default:
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
n, src, err := conn.ReadFromUDP(buf)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
log.Warnf("❌ SSDP read error: %v", err)
continue
}
data := string(buf[:n])
if strings.HasPrefix(data, "M-SEARCH") {
s.mu.RLock()
for _, d := range s.Devices {
s.handleMSearch(src, data, d)
}
s.mu.RUnlock()
}
}
}
}()
return nil
}
// SendSSDP envoie un NOTIFY multicast
func (s *SSDPServer) SendSSDP(msg string) error {
addr := &net.UDPAddr{IP: net.ParseIP(SsdpAddr), Port: Port}
_, err := s.conn.WriteToUDP([]byte(msg), addr)
return err
}
// SendAlive envoie un NOTIFY ssdp:alive
func (s *SSDPServer) SendAlive(usn, nt, location, server string) {
msg := fmt.Sprintf(`NOTIFY * HTTP/1.1
HOST: %s:%d
CACHE-CONTROL: max-age=%d
LOCATION: %s
NT: %s
NTS: ssdp:alive
SERVER: %s
USN: uuid:%s::%s
`, SsdpAddr, Port, MaxAge, location, nt, server, usn, nt)
if err := s.SendSSDP(msg); err != nil {
log.Warnf("❌ Failed to notify alive: USN %s: %v", usn, err)
} else {
log.Infof("✅ Notify alive: USN %s (NT=%s)", usn, nt)
}
}
// SendByeBye envoie un NOTIFY ssdp:byebye
func (s *SSDPServer) SendByeBye(usn, nt string) {
msg := fmt.Sprintf(`NOTIFY * HTTP/1.1
HOST: %s:%d
NT: %s
NTS: ssdp:byebye
USN: uuid:%s::%s
`, SsdpAddr, Port, nt, usn, nt)
msg = strings.ReplaceAll(msg, "\n", "\r\n")
if err := s.SendSSDP(msg); err != nil {
log.Warnf("❌ Failed to notify byebye: USN %s: %v", usn, err)
} else {
log.Infof("👋 Notify byebye: USN %s (NT=%s)", usn, nt)
}
}
// handleMSearch répond à un M-SEARCH en unicast
func (s *SSDPServer) handleMSearch(src *net.UDPAddr, req string, d *Device) {
st := parseST(req)
if st == "" {
return
}
valid := st == "ssdp:all" ||
slices.Contains(d.GetNTs(), st)
if !valid {
return
}
log.Infof("M-Search response on a valid ST: %s", st)
nts := []string{st}
if st == "ssdp:all" {
nts = d.GetNTs()
}
nts = d.GetNTs()
for _, st := range nts {
resp := fmt.Sprintf(`HTTP/1.1 200 OK
CACHE-CONTROL: max-age=%d
DATE: %s
EXT:
LOCATION: %s
SERVER: %s
ST: %s
USN: uuid:%s::%s
`, MaxAge, time.Now().UTC().Format(time.RFC1123), d.Location, d.Server, st, d.UUID, st)
resp = strings.ReplaceAll(resp, "\n", "\r\n")
if _, err := s.conn.WriteToUDP([]byte(resp), src); err != nil {
log.Warnf("❌ Failed to send M-SEARCH response to %v: %v", src, err)
} else {
// log.Warnf("✅ M-Search response sent : %x", resp)
// log.Warnf("✅ M-Search response sent : %s", resp)
log.Infof("📡 Responded to M-SEARCH from %v with ST=%s", src, st)
}
}
}
// parseST extrait le ST dun M-SEARCH
func parseST(req string) string {
scanner := bufio.NewScanner(strings.NewReader(req))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(strings.ToUpper(line), "ST:") {
st := strings.TrimSpace(line[3:])
log.Infof("✅ Found ST=%s in M-SEARCH response", st)
return st
}
}
return ""
}

View File

@@ -6,6 +6,7 @@ import (
"os"
"os/user"
"path"
"strconv"
"strings"
"sync"
@@ -346,12 +347,23 @@ func (conf *Config) GetBaseURL() string {
func (conf *Config) GetHTTPPort() int {
port, _ := conf.GetValue([]string{"host", "http_port"})
iport, ok := port.(int)
if !ok {
switch val := port.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case string:
i, err := strconv.Atoi(val)
if err == nil {
return i
}
default:
return 1900
}
return iport
return 1900
}
func (conf *Config) GetDeviceUDN(devtype DeviceType, name string) string {

View File

@@ -3,7 +3,9 @@ package upnp
import (
"fmt"
"net/http"
"runtime"
"gargoton.petite-maison-orange.fr/eric/pmomusic/ssdp"
"github.com/beevik/etree"
log "github.com/sirupsen/logrus"
)
@@ -107,6 +109,43 @@ func (di *DeviceInstance) RegisterURLs() error {
return nil
}
func (di *DeviceInstance) NT() string {
return fmt.Sprintf("uuid:%s::urn:%s", di.UDN(), di.ServiceType())
}
func (di *DeviceInstance) RegisterSSPD() {
osName := runtime.GOOS
arch := runtime.GOARCH
dev := &ssdp.Device{
UUID: di.UDN(),
DeviceType: di.ServiceType(),
Location: fmt.Sprintf("%s%s", di.server.BaseURL(), di.DescriptionURL()),
Server: fmt.Sprintf(
"%s/%s UPnP/1.1 PMOMusic/1.0",
osName, arch,
),
NTs: make([]string, 0, 2+len(di.services)),
}
dev.NTs = append(
dev.NTs,
"upnp:rootdevice",
di.ServiceType(),
)
for s := range di.services.All() {
dev.NTs = append(dev.NTs, s.ServiceType())
}
di.server.sspd.AddDevice(dev)
}
func (di *DeviceInstance) UnregisterSSPD() {
di.server.sspd.RemoveDevice(di.UDN())
}
func (di *DeviceInstance) ToXMLElement() *etree.Element {
elem := etree.NewElement("root")
elem.CreateAttr("xmlns", "urn:schemas-upnp-org:device-1-0")
@@ -120,7 +159,7 @@ func (di *DeviceInstance) ToXMLElement() *etree.Element {
device.CreateElement("friendlyName").SetText(di.FriendlyName())
device.CreateElement("manufacturer").SetText(di.Manufacturer())
device.CreateElement("modelName").SetText(di.ModelName())
device.CreateElement("UDN").SetText(di.UDN())
device.CreateElement("UDN").SetText("uuid:" + di.UDN())
if len(di.services) > 0 {
device.AddChild(di.services.ToXMLElement())

View File

@@ -3,7 +3,7 @@ package renderingcontrol
import "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp"
var RenderingControl = func() *upnp.Service {
svc := upnp.NewService("RenderingControl.")
svc := upnp.NewService("RenderingControl")
svc.AddVariable(InstanceID)
svc.AddVariable(Channel)

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
"runtime"
"sync"
"time"
@@ -13,6 +14,8 @@ import (
log "github.com/sirupsen/logrus"
"gargoton.petite-maison-orange.fr/eric/pmomusic/netutils"
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmolog"
"gargoton.petite-maison-orange.fr/eric/pmomusic/ssdp"
)
type Server struct {
@@ -22,6 +25,7 @@ type Server struct {
Logger *log.Logger
httpSrv *http.Server
sspd *ssdp.SSDPServer
devices DeviceInstanceSet
mu sync.RWMutex
@@ -74,6 +78,8 @@ func (s *Server) Start() error {
s.mu.RLock()
pmolog.LoggerWeb(mux)
mux.HandleFunc("/", s.ServeDebugIndex)
s.httpSrv = &http.Server{
@@ -116,7 +122,17 @@ func (s *Server) Stop(ctx context.Context) error {
func (s *Server) Run(ctx context.Context) error {
if err := s.Start(); err != nil {
return fmt.Errorf("failed to start server: %w", err)
return fmt.Errorf("failed to start server: %w", err)
}
s.sspd = ssdp.NewSSDPServer()
if err := s.sspd.Start(ctx); err != nil {
return fmt.Errorf("❌ failed to start SSDP server: %w", err)
}
for d := range s.devices.All() {
log.Infof("coucou from %s", d.Name())
d.RegisterSSPD()
}
// attente dannulation du contexte
@@ -156,7 +172,18 @@ func (s *Server) ServeXML(gen func() *etree.Element) func(w http.ResponseWriter,
http.Error(w, "failed to generate XML", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", `text/xml; charset="utf-8"`)
osName := runtime.GOOS
arch := runtime.GOARCH
w.Header().Set("Server", fmt.Sprintf(
"%s/%s UPnP/1.1 PMOMusic/1.0",
osName, arch,
))
w.Header().Set("Connection", "close")
w.Header().Set("Cache-Control", "max-age=1800")
w.Header().Set("EXT", "")
w.Header().Set("Content-Type", "text/xml; charset=\"utf-8\"")
w.WriteHeader(http.StatusOK)
w.Write([]byte(xmlStr))
}

View File

@@ -2,8 +2,10 @@ package upnp
import (
"fmt"
"io"
"net/http"
"gargoton.petite-maison-orange.fr/eric/pmomusic/soap"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/actions"
"gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
"github.com/beevik/etree"
@@ -68,6 +70,16 @@ func (svc *ServiceInstance) RegisterURLs() error {
svc.device.server.ServeXML(svc.SPCDElement),
)
mux.HandleFunc(
svc.ControlURL(),
svc.ControlHandler(),
)
mux.HandleFunc(
svc.EventSubURL(),
svc.EventSubHandler(),
)
log.Infof(
"✅ Service description for %s:%s available at : %s%s",
svc.device.Name(),
@@ -79,6 +91,10 @@ func (svc *ServiceInstance) RegisterURLs() error {
return nil
}
func (svc *ServiceInstance) USN() string {
return fmt.Sprintf("uuid:%s::urn:%s", svc.device.UDN(), svc.ServiceType())
}
func (svc *ServiceInstance) SPCDElement() *etree.Element {
elem := etree.NewElement("scpd")
@@ -119,3 +135,37 @@ func (svc *ServiceInstance) ToXMLElement() *etree.Element {
return elem
}
func (svc *ServiceInstance) EventSubHandler() func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
log.Infof("Event Subscription handler for %s:%s", svc.device.Name(), svc.Name())
// corps vide volontairement
}
}
func (svc *ServiceInstance) ControlHandler() func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
log.Infof("📡 Control request for %s:%s", svc.device.Name(), svc.Name())
log.Infof("➡️ Method: %s URL: %s", r.Method, r.URL.Path)
log.Infof("Header SOAPACTION: %s", r.Header.Get("SOAPACTION"))
log.Infof("Header Content-Type: %s", r.Header.Get("Content-Type"))
body, err := io.ReadAll(r.Body)
if err != nil {
log.Errorf("❌ Failed to read body: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
soap.ParseSOAPGeneric(body)
// Réponse minimale SOAP
w.Header().Set("Content-Type", `text/xml; charset="utf-8"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body/>
</s:Envelope>`))
}
}

View File

@@ -26,7 +26,7 @@ func (m *ServiceInstanceSet) All() iter.Seq[*ServiceInstance] {
}
func (m *ServiceInstanceSet) ToXMLElement() *etree.Element {
elem := etree.NewElement("ServiceList")
elem := etree.NewElement("serviceList")
for sv := range m.All() {
elem.AddChild(sv.ToXMLElement())