on avance
This commit is contained in:
@@ -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
129
didl/markdown.go
Normal 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: \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
66
didl/model.go
Normal 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
17
didl/parser.go
Normal 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
|
||||
}
|
||||
@@ -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
86
soap/parseSoap.go
Normal 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
246
ssdp/server.go
Normal 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 d’un 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 ""
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 d’annulation 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))
|
||||
}
|
||||
|
||||
@@ -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>`))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user