This commit is contained in:
Rob Emery 2025-03-30 22:46:35 -07:00 committed by GitHub
commit 12c452e9b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2012 additions and 3 deletions

View file

@ -90,10 +90,13 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
@(cd .git/hooks && ln -sf ../../git/* .)
.PHONY: setup-git
build: check_go_env buildjs ##@Build Build the project
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
build: check_go_env buildjs buildgo ##@Build Build the project
.PHONY: build
buildgo:
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
.PHONY: buildgo
buildall: deprecated build
.PHONY: buildall

View file

@ -76,6 +76,9 @@ func runNavidrome(ctx context.Context) {
g, ctx := errgroup.WithContext(ctx)
g.Go(startServer(ctx))
if conf.Server.DLNAServer.Enabled {
g.Go(startDLNAServer(ctx))
}
g.Go(startSignaller(ctx))
g.Go(startScheduler(ctx))
g.Go(startPlaybackServer(ctx))
@ -134,6 +137,13 @@ func startServer(ctx context.Context) func() error {
}
}
func startDLNAServer(ctx context.Context) func() error {
return func() error {
a := CreateDLNAServer()
return a.Run(ctx, conf.Server.Address, conf.Server.Port)
}
}
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
func schedulePeriodicScan(ctx context.Context) func() error {
return func() error {
@ -364,6 +374,8 @@ func init() {
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics")
rootCmd.Flags().Bool("dlnaserver.enabled", viper.GetBool("dlnaserver.enabled"), "enable/disable DLNA server")
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
_ = viper.BindPFlag("tlscert", rootCmd.Flags().Lookup("tlscert"))
@ -378,6 +390,8 @@ func init() {
_ = viper.BindPFlag("prometheus.enabled", rootCmd.Flags().Lookup("prometheus.enabled"))
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
_ = viper.BindPFlag("dlnaserver.enabled", rootCmd.Flags().Lookup("dlnaserver.enabled"))
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))

View file

@ -19,6 +19,7 @@ import (
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/dlna"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
@ -50,6 +51,21 @@ func CreateServer() *server.Server {
return serverServer
}
func CreateDLNAServer() *dlna.DLNAServer {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
fFmpeg := ffmpeg.New()
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
fileCache := artwork.GetImageCache()
agentsAgents := agents.GetAgents(dataStore)
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
dlnaServer := dlna.New(dataStore, broker, mediaStreamer, artworkArtwork)
return dlnaServer
}
func CreateNativeAPIRouter() *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
@ -170,4 +186,4 @@ func GetPlaybackServer() playback.PlaybackServer {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, metrics.NewPrometheusInstance, db.Db)
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, dlna.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, metrics.NewPrometheusInstance, db.Db)

View file

@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/dlna"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
@ -27,6 +28,7 @@ var allProviders = wire.NewSet(
core.Set,
artwork.Set,
server.New,
dlna.New,
subsonic.New,
nativeapi.New,
public.New,
@ -52,6 +54,12 @@ func CreateServer() *server.Server {
))
}
func CreateDLNAServer() *dlna.DLNAServer {
panic(wire.Build(
allProviders,
))
}
func CreateNativeAPIRouter() *nativeapi.Router {
panic(wire.Build(
allProviders,

View file

@ -92,6 +92,7 @@ type configOptions struct {
Backup backupOptions
PID pidOptions
Inspect inspectOptions
DLNAServer dlnaServerOptions
Subsonic subsonicOptions
Agents string
@ -204,6 +205,10 @@ type inspectOptions struct {
BacklogTimeout int
}
type dlnaServerOptions struct {
Enabled bool
}
var (
Server = &configOptions{}
hooks []func()
@ -528,6 +533,8 @@ func init() {
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("dlnaserver.enabled", false)
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
viper.SetDefault("devenableprofiler", false)

View file

@ -0,0 +1,28 @@
//go:build go1.21
package dlna
import (
"net/http"
"github.com/anacrolix/dms/upnp"
)
const defaultProtocolInfo = "http-get:*:video/mpeg:*,http-get:*:video/mp4:*,http-get:*:video/vnd.dlna.mpeg-tts:*,http-get:*:video/avi:*,http-get:*:video/x-matroska:*,http-get:*:video/x-ms-wmv:*,http-get:*:video/wtv:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-ms-wma*,http-get:*:audio/wav:*,http-get:*:audio/L16:*,http-get:*image/jpeg:*,http-get:*image/png:*,http-get:*image/gif:*,http-get:*image/tiff:*"
type connectionManagerService struct {
*DLNAServer
upnp.Eventing
}
func (cms *connectionManagerService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) {
switch action {
case "GetProtocolInfo":
return map[string]string{
"Source": defaultProtocolInfo,
"Sink": "",
}, nil
default:
return nil, upnp.InvalidActionError
}
}

View file

@ -0,0 +1,511 @@
//go:build go1.21
package dlna
import (
"encoding/xml"
"fmt"
"math"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"slices"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/anacrolix/dms/dlna"
"github.com/anacrolix/dms/upnp"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/dlna/upnpav"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/oriser/regroup"
)
type contentDirectoryService struct {
*DLNAServer
upnp.Eventing
}
var filesRegex *regroup.ReGroup
var artistRegex *regroup.ReGroup
var albumRegex *regroup.ReGroup
var genresRegex *regroup.ReGroup
var recentRegex *regroup.ReGroup
var playlistRegex *regroup.ReGroup
func init() {
filesRegex = regroup.MustCompile("\\/Music\\/Files[\\/]?((?P<Path>.+))?")
artistRegex = regroup.MustCompile("\\/Music\\/Artists[\\/]?(?P<Artist>[^\\/]+)?[\\/]?(?<ArtistAlbum>[^\\/]+)?[\\/]?")
albumRegex = regroup.MustCompile("\\/Music\\/Albums[\\/]?(?P<AlbumTitle>[^\\/]+)?[\\/]?")
genresRegex = regroup.MustCompile("\\/Music\\/Genres[\\/]?(?P<Genre>[^\\/]+)?[\\/]?(?P<GenreArtist>[^/]+)?[\\/]?")
recentRegex = regroup.MustCompile("\\/Music\\/Recently Added[\\/]?(?P<RecentAlbum>[^\\/]+)?")
playlistRegex = regroup.MustCompile("\\/Music\\/Playlists[\\/]?(?P<Playlist>[^\\/]+)?[\\/]?")
}
func (cds *contentDirectoryService) updateIDString() string {
return fmt.Sprintf("%d", uint32(os.Getpid()))
}
// Turns the given entry and DMS host into a UPnP object. A nil object is
// returned if the entry is not of interest.
func (cds *contentDirectoryService) cdsObjectToUpnpavObject(cdsObject object, isContainer bool, host string, filesize int64) (ret interface{}) {
obj := upnpav.Object{
ID: cdsObject.ID(),
Restricted: 1,
ParentID: cdsObject.ParentID(),
Title: filepath.Base(cdsObject.Path),
}
if isContainer {
defaultChildCount := 1
obj.Class = "object.container.storageFolder"
return upnpav.Container{
Object: obj,
ChildCount: &defaultChildCount,
}
}
var mimeType = "audio/mp3" //TODO
obj.Class = "object.item.audioItem.musicTrack"
obj.Date = upnpav.Timestamp{Time: time.Now()}
item := upnpav.Item{
Object: obj,
Res: make([]upnpav.Resource, 0, 1),
}
item.Res = append(item.Res, upnpav.Resource{
URL: (&url.URL{
Scheme: "http",
Host: host,
Path: path.Join(resourcePath, resourceFilePath, cdsObject.Path),
}).String(),
ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mimeType, dlna.ContentFeatures{
SupportRange: true,
}.String()),
Size: uint64(filesize),
})
ret = item
return ret
}
// Returns all the upnpav objects in a directory.
func (cds *contentDirectoryService) readContainer(o object, host string) (ret []interface{}, err error) {
log.Debug(fmt.Sprintf("ReadContainer called '%s'", o))
if o.Path == "/" || o.Path == "" {
log.Debug("ReadContainer default route")
newObject := object{Path: "/Music"}
ret = append(ret, cds.cdsObjectToUpnpavObject(newObject, true, host, -1))
return ret, nil
}
if o.Path == "/Music" {
return handleDefault(ret, cds, o, host)
} else if _, err := filesRegex.Groups(o.Path); err == nil {
return cds.doFiles(ret, o.Path, host)
} else if matchResults, err := artistRegex.Groups(o.Path); err == nil {
return handleArtist(matchResults, ret, cds, o, host)
} else if matchResults, err := albumRegex.Groups(o.Path); err == nil {
return handleAlbum(matchResults, ret, cds, o, host)
} else if matchResults, err := genresRegex.Groups(o.Path); err == nil {
return handleGenre(matchResults, ret, cds, o, host)
} else if matchResults, err := recentRegex.Groups(o.Path); err == nil {
return handleRecent(matchResults, ret, cds, o, host)
} else if matchResults, err := playlistRegex.Groups(o.Path); err == nil {
return handlePlaylists(matchResults, ret, cds, o, host)
}
return ret, nil
}
func handleDefault(ret []interface{}, cds *contentDirectoryService, o object, host string) ([]interface{}, error) {
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Files"}, true, host, -1))
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Artists"}, true, host, -1))
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Albums"}, true, host, -1))
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Genres"}, true, host, -1))
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Recently Added"}, true, host, -1))
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Playlists"}, true, host, -1))
return ret, nil
}
func handleArtist(matchResults map[string]string, ret []interface{}, cds *contentDirectoryService, o object, host string) ([]interface{}, error) {
if matchResults["ArtistAlbum"] != "" {
tracks, _ := cds.ds.MediaFile(cds.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": matchResults["ArtistAlbum"]}})
return cds.doMediaFiles(tracks, o.Path, ret, host)
} else if matchResults["Artist"] != "" {
allAlbumsForThisArtist, _ := cds.ds.Album(cds.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_artist_id": matchResults["Artist"]}})
return cds.doAlbums(allAlbumsForThisArtist, o.Path, ret, host)
}
indexes, err := cds.ds.Artist(cds.ctx).GetIndex()
if err != nil {
fmt.Printf("Error retrieving Indexes: %+v", err)
return nil, err
}
for letterIndex := range indexes {
for artist := range indexes[letterIndex].Artists {
artistId := indexes[letterIndex].Artists[artist].ID
child := object{
Path: path.Join(o.Path, indexes[letterIndex].Artists[artist].Name),
Id: path.Join(o.Path, artistId),
}
ret = append(ret, cds.cdsObjectToUpnpavObject(child, true, host, -1))
}
}
return ret, nil
}
func handleAlbum(matchResults map[string]string, ret []interface{}, cds *contentDirectoryService, o object, host string) ([]interface{}, error) {
if matchResults["AlbumTitle"] != "" {
tracks, _ := cds.ds.MediaFile(cds.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": matchResults["AlbumTitle"]}})
return cds.doMediaFiles(tracks, o.Path, ret, host)
}
indexes, err := cds.ds.Album(cds.ctx).GetAll()
if err != nil {
fmt.Printf("Error retrieving Indexes: %+v", err)
return nil, err
}
for indexItem := range indexes {
child := object{
Path: path.Join(o.Path, indexes[indexItem].Name),
Id: path.Join(o.Path, indexes[indexItem].ID),
}
ret = append(ret, cds.cdsObjectToUpnpavObject(child, true, host, -1))
}
return ret, nil
}
func handleGenre(matchResults map[string]string, ret []interface{}, cds *contentDirectoryService, o object, host string) ([]interface{}, error) {
if matchResults["GenreArtist"] != "" {
tracks, err := cds.ds.MediaFile(cds.ctx).GetAll(model.QueryOptions{Filters: squirrel.And{
squirrel.Eq{"genre.id": matchResults["Genre"]},
squirrel.Eq{"artist_id": matchResults["GenreArtist"]},
},
})
if err != nil {
fmt.Printf("Error retrieving tracks for artist and genre: %+v", err)
return nil, err
}
return cds.doMediaFiles(tracks, o.Path, ret, host)
} else if matchResults["Genre"] != "" {
if matchResults["GenreArtist"] == "" {
artists, err := cds.ds.Artist(cds.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"genre.id": matchResults["Genre"]}})
if err != nil {
fmt.Printf("Error retrieving artists for genre: %+v", err)
return nil, err
}
for artistIndex := range artists {
child := object{
Path: path.Join(o.Path, artists[artistIndex].Name),
Id: path.Join(o.Path, artists[artistIndex].ID),
}
ret = append(ret, cds.cdsObjectToUpnpavObject(child, true, host, -1))
}
}
}
indexes, err := cds.ds.Genre(cds.ctx).GetAll()
if err != nil {
fmt.Printf("Error retrieving Indexes: %+v", err)
return nil, err
}
for indexItem := range indexes {
child := object{
Path: path.Join(o.Path, indexes[indexItem].Name),
Id: path.Join(o.Path, indexes[indexItem].ID),
}
ret = append(ret, cds.cdsObjectToUpnpavObject(child, true, host, -1))
}
return ret, nil
}
func handleRecent(matchResults map[string]string, ret []interface{}, cds *contentDirectoryService, o object, host string) ([]interface{}, error) {
if matchResults["RecentAlbum"] != "" {
tracks, _ := cds.ds.MediaFile(cds.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": matchResults["RecentAlbum"]}})
return cds.doMediaFiles(tracks, o.Path, ret, host)
}
indexes, err := cds.ds.Album(cds.ctx).GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc", Max: 25})
if err != nil {
fmt.Printf("Error retrieving Indexes: %+v", err)
return nil, err
}
for indexItem := range indexes {
child := object{
Path: path.Join(o.Path, indexes[indexItem].Name),
Id: path.Join(o.Path, indexes[indexItem].ID),
}
ret = append(ret, cds.cdsObjectToUpnpavObject(child, true, host, -1))
}
return ret, nil
}
func handlePlaylists(matchResults map[string]string, ret []interface{}, cds *contentDirectoryService, o object, host string) ([]interface{}, error) {
if matchResults["Playlist"] != "" {
x, err := cds.ds.Playlist(cds.ctx).GetWithTracks(matchResults["Playlist"], false, false)
if err != nil {
log.Error("Error fetching playlist", "playlist", matchResults["Playlist"], err)
return ret, nil
}
return cds.doMediaFiles(x.MediaFiles(), o.Path, ret, host)
}
indexes, err := cds.ds.Playlist(cds.ctx).GetAll()
if err != nil {
fmt.Printf("Error retrieving Indexes: %+v", err)
return nil, err
}
for indexItem := range indexes {
child := object{
Path: path.Join(o.Path, indexes[indexItem].Name),
Id: path.Join(o.Path, indexes[indexItem].ID),
}
ret = append(ret, cds.cdsObjectToUpnpavObject(child, true, host, -1))
}
return ret, nil
}
func (cds *contentDirectoryService) doMediaFiles(tracks model.MediaFiles, basePath string, ret []interface{}, host string) ([]interface{}, error) {
for _, track := range tracks {
trackDateAsTimeObject, _ := time.Parse(time.DateOnly, track.Date)
obj := upnpav.Object{
ID: path.Join(basePath, track.ID),
Restricted: 1,
ParentID: basePath,
Title: track.Title,
Class: "object.item.audioItem.musicTrack",
Artist: track.Artist,
Album: track.Album,
Genre: track.Genre,
OriginalTrackNumber: track.TrackNumber,
Date: upnpav.Timestamp{Time: trackDateAsTimeObject},
}
if track.HasCoverArt {
obj.AlbumArtURI = (&url.URL{
Scheme: "http",
Host: host,
Path: path.Join(resourcePath, resourceArtPath, track.CoverArtID().String()),
}).String()
}
//TODO figure out how this fits with transcoding etc
var mimeType = "audio/mp3"
item := upnpav.Item{
Object: obj,
Res: make([]upnpav.Resource, 0, 1),
}
streamAccessPath := path.Join(resourcePath, resourceStreamPath, track.ID)
item.Res = append(item.Res, upnpav.Resource{
URL: (&url.URL{
Scheme: "http",
Host: host,
Path: streamAccessPath,
}).String(),
ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mimeType, dlna.ContentFeatures{
SupportRange: false,
}.String()),
Size: uint64(track.Size),
Duration: floatToDurationString(track.Duration),
})
ret = append(ret, item)
}
return ret, nil
}
func floatToDurationString(totalSeconds32 float32) string {
totalSeconds := float64(totalSeconds32)
secondsInAnHour := float64(60 * 60)
secondsInAMinute := float64(60)
hours := int(math.Floor(totalSeconds / secondsInAnHour))
minutes := int(math.Floor(math.Mod(totalSeconds, secondsInAnHour) / secondsInAMinute))
seconds := int(math.Floor(math.Mod(totalSeconds, secondsInAMinute)))
ms := int(math.Floor(math.Mod(totalSeconds, 1) * 1000))
return fmt.Sprintf("%02d:%02d:%02d.%03d", hours, minutes, seconds, ms)
}
func (cds *contentDirectoryService) doAlbums(albums model.Albums, basepath string, ret []interface{}, host string) ([]interface{}, error) {
for _, album := range albums {
child := object{
Path: path.Join(basepath, album.Name),
Id: path.Join(basepath, album.ID),
}
ret = append(ret, cds.cdsObjectToUpnpavObject(child, true, host, -1))
}
return ret, nil
}
func (cds *contentDirectoryService) doFiles(ret []interface{}, oPath string, host string) ([]interface{}, error) {
pathComponents := strings.Split(strings.TrimPrefix(oPath, "/Music/Files"), "/")
if slices.Contains(pathComponents, "..") || slices.Contains(pathComponents, ".") {
log.Error("Attempt to use .. or . detected", oPath, host)
return ret, nil
}
totalPathArrayBits := append([]string{conf.Server.MusicFolder}, pathComponents...)
localFilePath := filepath.Join(totalPathArrayBits...)
files, _ := os.ReadDir(localFilePath)
for _, file := range files {
child := object{
Path: path.Join(oPath, file.Name()),
Id: path.Join(oPath, file.Name()),
}
fileInfo, _ := file.Info()
ret = append(ret, cds.cdsObjectToUpnpavObject(child, file.IsDir(), host, fileInfo.Size()))
}
return ret, nil
}
type browse struct {
ObjectID string
BrowseFlag string
Filter string
StartingIndex int
RequestedCount int
}
// ContentDirectory object from ObjectID.
func (cds *contentDirectoryService) objectFromID(id string) (o object, err error) {
log.Debug("objectFromID called", "id", id)
o.Path, err = url.QueryUnescape(id)
if err != nil {
return
}
if o.Path == "0" {
o.Path = "/"
}
o.Path = path.Clean(o.Path)
if !path.IsAbs(o.Path) {
err = fmt.Errorf("bad ObjectID %v", o.Path)
return
}
return
}
func (cds *contentDirectoryService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) {
host := r.Host
log.Info(fmt.Sprintf("Handle called with action: %s", action))
switch action {
case "GetSystemUpdateID":
return map[string]string{
"Id": cds.updateIDString(),
}, nil
case "GetSortCapabilities":
return map[string]string{
"SortCaps": "dc:title",
}, nil
case "Browse":
var browse browse
if err := xml.Unmarshal(argsXML, &browse); err != nil {
return nil, err
}
obj, err := cds.objectFromID(browse.ObjectID)
if err != nil {
return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, "%s", err.Error())
}
switch browse.BrowseFlag {
case "BrowseDirectChildren":
objs, err := cds.readContainer(obj, host)
if err != nil {
return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, "%s", err.Error())
}
totalMatches := len(objs)
objs = objs[func() (low int) {
low = browse.StartingIndex
if low > len(objs) {
low = len(objs)
}
return
}():]
if browse.RequestedCount != 0 && browse.RequestedCount < len(objs) {
objs = objs[:browse.RequestedCount]
}
result, err := xml.Marshal(objs)
log.Debug(fmt.Sprintf("XMLResponse: '%s'", result))
if err != nil {
return nil, err
}
return map[string]string{
"TotalMatches": fmt.Sprint(totalMatches),
"NumberReturned": fmt.Sprint(len(objs)),
"Result": didlLite(string(result)),
"UpdateID": cds.updateIDString(),
}, nil
case "BrowseMetadata":
//TODO
return map[string]string{
"Result": didlLite(string("result")),
}, nil
default:
return nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, "unhandled browse flag: %v", browse.BrowseFlag)
}
case "GetSearchCapabilities":
return map[string]string{
"SearchCaps": "",
}, nil
// Samsung Extensions
case "X_GetFeatureList":
return map[string]string{
"FeatureList": `<Features xmlns="urn:schemas-upnp-org:av:avs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:schemas-upnp-org:av:avs http://www.upnp.org/schemas/av/avs.xsd">
<Feature name="samsung.com_BASICVIEW" version="1">
<container id="0" type="object.item.imageItem"/>
<container id="0" type="object.item.audioItem"/>
<container id="0" type="object.item.videoItem"/>
</Feature>
</Features>`}, nil
case "X_SetBookmark":
// just ignore
return map[string]string{}, nil
default:
return nil, upnp.InvalidActionError
}
}
// Represents a ContentDirectory object.
type object struct {
Path string // The cleaned, absolute path for the object relative to the server.
Id string
}
// Returns the actual local filesystem path for the object.
func (o *object) FilePath() string {
return filepath.FromSlash(o.Path)
}
// Returns the ObjectID for the object. This is used in various ContentDirectory actions.
func (o object) ID() string {
if o.Id != "" {
return o.Id
}
if !path.IsAbs(o.Path) {
log.Fatal("Relative object path used", "path", o.Path)
return "-1"
}
if len(o.Path) == 1 {
return "0"
}
return url.QueryEscape(o.Path)
}
func (o *object) IsRoot() bool {
return o.Path == "/"
}
// Returns the object's parent ObjectID. Fortunately it can be deduced from the
// ObjectID (for now).
func (o object) ParentID() string {
if o.IsRoot() {
return "-1"
}
o.Path = path.Dir(o.Path)
return o.ID()
}

542
dlna/dlnaserver.go Normal file
View file

@ -0,0 +1,542 @@
package dlna
import (
"bytes"
"context"
"crypto/md5"
"embed"
"encoding/xml"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"text/template"
"time"
dms_dlna "github.com/anacrolix/dms/dlna"
"github.com/anacrolix/dms/soap"
"github.com/anacrolix/dms/ssdp"
"github.com/anacrolix/dms/upnp"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/events"
)
const (
serverField = "Linux/3.4 DLNADOC/1.50 UPnP/1.0 DMS/1.0"
rootDescPath = "/rootDesc.xml"
resourcePath = "/r/"
resourceFilePath = "f"
resourceStreamPath = "s"
resourceArtPath = "a"
serviceControlURL = "/ctl"
)
//go:embed static/*
var staticContent embed.FS
type DLNAServer struct {
ds model.DataStore
broker events.Broker
ssdp SSDPServer
ctx context.Context
ms core.MediaStreamer
art artwork.Artwork
}
type SSDPServer struct {
// The service SOAP handler keyed by service URN.
services map[string]UPnPService
Interfaces []net.Interface
HTTPConn net.Listener
httpListenAddr string
handler http.Handler
RootDeviceUUID string
FriendlyName string
ModelNumber string
// For waiting on the listener to close
waitChan chan struct{}
// Time interval between SSPD announces
AnnounceInterval time.Duration
ms core.MediaStreamer
art artwork.Artwork
}
func New(ds model.DataStore, broker events.Broker, mediastreamer core.MediaStreamer, artwork artwork.Artwork) *DLNAServer {
s := &DLNAServer{
ds: ds,
broker: broker,
ssdp: SSDPServer{
AnnounceInterval: time.Duration(30) * time.Second,
Interfaces: listInterfaces(),
FriendlyName: "Navidrome",
ModelNumber: consts.Version,
RootDeviceUUID: makeDeviceUUID("Navidrome"),
waitChan: make(chan struct{}),
ms: mediastreamer,
art: artwork,
},
ms: mediastreamer,
art: artwork,
}
s.ssdp.services = map[string]UPnPService{
"ContentDirectory": &contentDirectoryService{
DLNAServer: s,
},
"ConnectionManager": &connectionManagerService{
DLNAServer: s,
},
"X_MS_MediaReceiverRegistrar": &mediaReceiverRegistrarService{
DLNAServer: s,
},
}
//setup dedicated HTTP server for UPNP
r := http.NewServeMux()
r.Handle(resourcePath, http.StripPrefix(resourcePath, http.HandlerFunc(s.ssdp.resourceHandler)))
r.Handle("/static/", http.FileServer(http.FS(staticContent)))
r.HandleFunc(rootDescPath, s.ssdp.rootDescHandler)
r.HandleFunc(serviceControlURL, s.ssdp.serviceControlHandler)
s.ssdp.handler = r
return s
}
// Run starts the DLNA server (both SSDP and HTTP) with the given address
func (s *DLNAServer) Run(ctx context.Context, addr string, port int) (err error) {
log.Warn("Starting DLNA Server")
s.ctx = ctx
if s.ssdp.HTTPConn == nil {
network := "tcp4"
if strings.Count(s.ssdp.httpListenAddr, ":") > 1 {
network = "tcp"
}
s.ssdp.HTTPConn, err = net.Listen(network, s.ssdp.httpListenAddr)
if err != nil {
return
}
}
go func() {
s.ssdp.startSSDP()
}()
go func() {
err := s.ssdp.serveHTTP()
if err != nil {
log.Error("Error starting ssdp HTTP server", err)
}
}()
return nil
}
type UPnPService interface {
Handle(action string, argsXML []byte, r *http.Request) (respArgs map[string]string, err error)
Subscribe(callback []*url.URL, timeoutSeconds int) (sid string, actualTimeout int, err error)
Unsubscribe(sid string) error
}
func (s *SSDPServer) startSSDP() {
active := 0
stopped := make(chan struct{})
for _, intf := range s.Interfaces {
active++
go func(intf2 net.Interface) {
defer func() {
stopped <- struct{}{}
}()
s.ssdpInterface(intf2)
}(intf)
}
for active > 0 {
<-stopped
active--
}
}
// Run SSDP server on an interface.
func (s *SSDPServer) ssdpInterface(intf net.Interface) {
// Figure out whether should an ip be announced
ipfilterFn := func(ip net.IP) bool {
listenaddr := s.HTTPConn.Addr().String()
listenip := listenaddr[:strings.LastIndex(listenaddr, ":")]
switch listenip {
case "0.0.0.0":
if strings.Contains(ip.String(), ":") {
// Any IPv6 address should not be announced
// because SSDP only listen on IPv4 multicast address
return false
}
return true
case "[::]":
// In the @Serve() section, the default settings have been made to not listen on IPv6 addresses.
// If actually still listening on [::], then allow to announce any address.
return true
default:
if listenip == ip.String() {
return true
}
return false
}
}
// Figure out which HTTP location to advertise based on the interface IP.
advertiseLocationFn := func(ip net.IP) string {
url := url.URL{
Scheme: "http",
Host: (&net.TCPAddr{
IP: ip,
Port: s.HTTPConn.Addr().(*net.TCPAddr).Port,
}).String(),
Path: rootDescPath,
}
return url.String()
}
_, err := intf.Addrs()
if err != nil {
panic(err)
}
log.Info(fmt.Sprintf("Started SSDP on %v", intf.Name))
// Note that the devices and services advertised here via SSDP should be
// in agreement with the rootDesc XML descriptor that is defined above.
ssdpServer := ssdp.Server{
Interface: intf,
Devices: []string{
"urn:schemas-upnp-org:device:MediaServer:1"},
Services: []string{
"urn:schemas-upnp-org:service:ContentDirectory:1",
"urn:schemas-upnp-org:service:ConnectionManager:1",
"urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1"},
IPFilter: ipfilterFn,
Location: advertiseLocationFn,
Server: serverField,
UUID: s.RootDeviceUUID,
NotifyInterval: s.AnnounceInterval,
}
// An interface with these flags should be valid for SSDP.
const ssdpInterfaceFlags = net.FlagUp | net.FlagMulticast
if err := ssdpServer.Init(); err != nil {
if intf.Flags&ssdpInterfaceFlags != ssdpInterfaceFlags {
// Didn't expect it to work anyway.
return
}
if strings.Contains(err.Error(), "listen") {
// OSX has a lot of dud interfaces. Failure to create a socket on
// the interface are what we're expecting if the interface is no
// good.
return
}
log.Error("Error creating ssdp server", "intf.Name", intf.Name, err)
return
}
defer ssdpServer.Close()
log.Info("Started SSDP", "intf.Name", intf.Name)
stopped := make(chan struct{})
go func() {
defer close(stopped)
if err := ssdpServer.Serve(); err != nil {
log.Error("Error in ssdpServer Serve", "intf.Name", intf.Name, err)
}
}()
select {
case <-s.waitChan:
// Returning will close the server.
case <-stopped:
}
}
// Get all available active network interfaces.
func listInterfaces() []net.Interface {
ifs, err := net.Interfaces()
if err != nil {
return []net.Interface{}
}
var active []net.Interface
for _, intf := range ifs {
if isAppropriatelyConfigured(intf) {
active = append(active, intf)
}
}
return active
}
func isAppropriatelyConfigured(intf net.Interface) bool {
return intf.Flags&net.FlagUp != 0 && intf.Flags&net.FlagMulticast != 0 && intf.MTU > 0
}
// handler for all paths under `/r`
func (s *SSDPServer) resourceHandler(w http.ResponseWriter, r *http.Request) {
remotePath := r.URL.Path
components := strings.Split(remotePath, "/")
switch components[0] {
case resourceFilePath:
localFile, _ := strings.CutPrefix(remotePath, path.Join(resourceFilePath, "Music/Files"))
localFilePath := path.Join(conf.Server.MusicFolder, localFile)
log.Info(fmt.Sprintf("resource handler Executed with remote path: %s, localpath: %s", remotePath, localFilePath))
fileStats, err := os.Stat(localFilePath)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Length", strconv.FormatInt(fileStats.Size(), 10))
// add some DLNA specific headers
if r.Header.Get("getContentFeatures.dlna.org") != "" {
w.Header().Set("contentFeatures.dlna.org", dms_dlna.ContentFeatures{
SupportRange: true,
}.String())
}
w.Header().Set("transferMode.dlna.org", "Streaming")
fileHandle, err := os.Open(localFilePath)
if err != nil {
fmt.Printf("file streaming error: %+v\n", err)
return
}
defer fileHandle.Close()
http.ServeContent(w, r, remotePath, time.Now(), fileHandle)
case resourceStreamPath: //TODO refactor this with stream.go:52?
fileId := components[1]
//TODO figure out format, bitrate
stream, err := s.ms.NewStream(r.Context(), fileId, "mp3", 0, 0)
if err != nil {
log.Error("Error streaming file", "id", fileId, err)
return
}
defer func() {
if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Error("Error closing stream", "id", fileId, "file", stream.Name(), err)
}
}()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
case resourceArtPath: //TODO refactor this with handle_images.go:39?
artId, err := model.ParseArtworkID(components[1])
if err != nil {
log.Error("Failure to parse ArtworkId", "inputString", components[1], err)
return
}
//TODO size (250)
imgReader, lastUpdate, err := s.art.Get(r.Context(), artId, 250, true)
if err != nil {
log.Error("Failure to retrieve artwork", "artid", artId, err)
return
}
defer imgReader.Close()
w.Header().Set("Cache-Control", "public, max-age=315360000")
w.Header().Set("Last-Modified", lastUpdate.Format(time.RFC1123))
_, err = io.Copy(w, imgReader)
if err != nil {
log.Error("Error writing Artwork Response stream", err)
return
}
}
}
// returns /rootDesc.xml templated
func (s *SSDPServer) rootDescHandler(w http.ResponseWriter, r *http.Request) {
tmpl, _ := GetTemplate()
buffer := new(bytes.Buffer)
_ = tmpl.Execute(buffer, s)
w.Header().Set("content-type", `text/xml; charset="utf-8"`)
w.Header().Set("cache-control", "private, max-age=60")
w.Header().Set("content-length", strconv.FormatInt(int64(buffer.Len()), 10))
_, err := buffer.WriteTo(w)
if err != nil {
log.Error("Error writing rootDesc to responsebuffer", err)
}
}
// Handle a service control HTTP request.
func (s *SSDPServer) serviceControlHandler(w http.ResponseWriter, r *http.Request) {
soapActionString := r.Header.Get("SOAPACTION")
soapAction, err := upnp.ParseActionHTTPHeader(soapActionString)
if err != nil {
serveError(s, w, "Could not parse SOAPACTION header", err)
return
}
var env soap.Envelope
if err := xml.NewDecoder(r.Body).Decode(&env); err != nil {
serveError(s, w, "Could not parse SOAP request body", err)
return
}
w.Header().Set("Content-Type", `text/xml; charset="utf-8"`)
w.Header().Set("Ext", "")
soapRespXML, code := func() ([]byte, int) {
respArgs, err := s.soapActionResponse(soapAction, env.Body.Action, r)
if err != nil {
fmt.Printf("Error invoking %v: %v", soapAction, err)
upnpErr := upnp.ConvertError(err)
return mustMarshalXML(soap.NewFault("UPnPError", upnpErr)), http.StatusInternalServerError
}
return marshalSOAPResponse(soapAction, respArgs), http.StatusOK
}()
bodyStr := fmt.Sprintf(`<?xml version="1.0" encoding="utf-8" standalone="yes"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>%s</s:Body></s:Envelope>`, soapRespXML)
w.WriteHeader(code)
if _, err := w.Write([]byte(bodyStr)); err != nil {
fmt.Printf("Error writing response: %v", err)
}
}
// Handle a SOAP request and return the response arguments or UPnP error.
func (s *SSDPServer) soapActionResponse(sa upnp.SoapAction, actionRequestXML []byte, r *http.Request) (map[string]string, error) {
service, ok := s.services[sa.Type]
if !ok {
// TODO: What's the invalid service error?
return nil, upnp.Errorf(upnp.InvalidActionErrorCode, "Invalid service: %s", sa.Type)
}
return service.Handle(sa.Action, actionRequestXML, r)
}
func (s *SSDPServer) serveHTTP() error {
srv := &http.Server{
Handler: s.handler,
ReadHeaderTimeout: 10 * time.Second,
}
err := srv.Serve(s.HTTPConn)
select {
case <-s.waitChan:
return nil
default:
return err
}
}
func didlLite(chardata string) string {
return `<DIDL-Lite` +
` xmlns:dc="http://purl.org/dc/elements/1.1/"` +
` xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"` +
` xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"` +
` xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">` +
chardata +
`</DIDL-Lite>`
}
func mustMarshalXML(value interface{}) []byte {
ret, err := xml.MarshalIndent(value, "", " ")
if err != nil {
log.Fatal(fmt.Sprintf("mustMarshalXML failed to marshal %v: %s $s", value, err))
}
return ret
}
// Marshal SOAP response arguments into a response XML snippet.
func marshalSOAPResponse(sa upnp.SoapAction, args map[string]string) []byte {
soapArgs := make([]soap.Arg, 0, len(args))
for argName, value := range args {
soapArgs = append(soapArgs, soap.Arg{
XMLName: xml.Name{Local: argName},
Value: value,
})
}
return []byte(fmt.Sprintf(`<u:%[1]sResponse xmlns:u="%[2]s">%[3]s</u:%[1]sResponse>`,
sa.Action, sa.ServiceURN.String(), mustMarshalXML(soapArgs)))
}
func makeDeviceUUID(unique string) string {
h := md5.New()
if _, err := io.WriteString(h, unique); err != nil {
log.Fatal(fmt.Sprintf("makeDeviceUUID write failed: %s", err))
}
buf := h.Sum(nil)
return upnp.FormatUUID(buf)
}
// serveError returns an http.StatusInternalServerError and logs the error
func serveError(what interface{}, w http.ResponseWriter, text string, err error) {
http.Error(w, text+".", http.StatusInternalServerError)
log.Error("serveError", "what", what, "text", text, err)
}
func GetTemplate() (tpl *template.Template, err error) {
templateBytes := `<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0"
xmlns:dlna="urn:schemas-dlna-org:device-1-0"
xmlns:sec="http://www.sec.co.kr/dlna">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
<friendlyName>{{.FriendlyName}}</friendlyName>
<manufacturer>Navidrome</manufacturer>
<manufacturerURL>https://www.navidrome.org/</manufacturerURL>
<modelDescription>Navidrome</modelDescription>
<modelName>Navidrome</modelName>
<modelNumber>{{.ModelNumber}}</modelNumber>
<modelURL>https://www.navidrome.org/</modelURL>
<serialNumber>00000000</serialNumber>
<UDN>{{.RootDeviceUUID}}</UDN>
<dlna:X_DLNACAP/>
<dlna:X_DLNADOC>DMS-1.50</dlna:X_DLNADOC>
<dlna:X_DLNADOC>M-DMS-1.50</dlna:X_DLNADOC>
<sec:ProductCap>smi,DCM10,getMediaInfo.sec,getCaptionInfo.sec</sec:ProductCap>
<sec:X_ProductCap>smi,DCM10,getMediaInfo.sec,getCaptionInfo.sec</sec:X_ProductCap>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
<SCPDURL>/static/ContentDirectory.xml</SCPDURL>
<controlURL>/ctl</controlURL>
<eventSubURL></eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
<SCPDURL>/static/ConnectionManager.xml</SCPDURL>
<controlURL>/ctl</controlURL>
<eventSubURL></eventSubURL>
</service>
<service>
<serviceType>urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1</serviceType>
<serviceId>urn:microsoft.com:serviceId:X_MS_MediaReceiverRegistrar</serviceId>
<SCPDURL>/static/X_MS_MediaReceiverRegistrar.xml</SCPDURL>
<controlURL>/ctl</controlURL>
<eventSubURL></eventSubURL>
</service>
</serviceList>
<presentationURL>/</presentationURL>
</device>
</root>`
tpl, err = template.New("rootDesc").Parse(templateBytes)
if err != nil {
return nil, fmt.Errorf("get template parse: %w", err)
}
return tpl, nil
}

View file

@ -0,0 +1,29 @@
//go:build go1.21
package dlna
import (
"net/http"
"github.com/anacrolix/dms/upnp"
)
type mediaReceiverRegistrarService struct {
*DLNAServer
upnp.Eventing
}
func (mrrs *mediaReceiverRegistrarService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) {
switch action {
case "IsAuthorized", "IsValidated":
return map[string]string{
"Result": "1",
}, nil
case "RegisterDevice":
return map[string]string{
"RegistrationRespMsg": mrrs.ssdp.RootDeviceUUID,
}, nil
default:
return nil, upnp.InvalidActionError
}
}

View file

@ -0,0 +1,182 @@
<?xml version="1.0" encoding="UTF-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>GetProtocolInfo</name>
<argumentList>
<argument>
<name>Source</name>
<direction>out</direction>
<relatedStateVariable>SourceProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>Sink</name>
<direction>out</direction>
<relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>PrepareForConnection</name>
<argumentList>
<argument>
<name>RemoteProtocolInfo</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionManager</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>Direction</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
</argument>
<argument>
<name>ConnectionID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>AVTransportID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
</argument>
<argument>
<name>RcsID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>ConnectionComplete</name>
<argumentList>
<argument>
<name>ConnectionID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetCurrentConnectionIDs</name>
<argumentList>
<argument>
<name>ConnectionIDs</name>
<direction>out</direction>
<relatedStateVariable>CurrentConnectionIDs</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetCurrentConnectionInfo</name>
<argumentList>
<argument>
<name>ConnectionID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>RcsID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
</argument>
<argument>
<name>AVTransportID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
</argument>
<argument>
<name>ProtocolInfo</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionManager</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>Direction</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
</argument>
<argument>
<name>Status</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="yes">
<name>SourceProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>SinkProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>CurrentConnectionIDs</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionStatus</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>OK</allowedValue>
<allowedValue>ContentFormatMismatch</allowedValue>
<allowedValue>InsufficientBandwidth</allowedValue>
<allowedValue>UnreliableChannel</allowedValue>
<allowedValue>Unknown</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionManager</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Direction</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>Input</allowedValue>
<allowedValue>Output</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionID</name>
<dataType>i4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_AVTransportID</name>
<dataType>i4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_RcsID</name>
<dataType>i4</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

View file

@ -0,0 +1,504 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>GetSearchCapabilities</name>
<argumentList>
<argument>
<name>SearchCaps</name>
<direction>out</direction>
<relatedStateVariable>SearchCapabilities</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetSortCapabilities</name>
<argumentList>
<argument>
<name>SortCaps</name>
<direction>out</direction>
<relatedStateVariable>SortCapabilities</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetSortExtensionCapabilities</name>
<argumentList>
<argument>
<name>SortExtensionCaps</name>
<direction>out</direction>
<relatedStateVariable>SortExtensionCapabilities</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetFeatureList</name>
<argumentList>
<argument>
<name>FeatureList</name>
<direction>out</direction>
<relatedStateVariable>FeatureList</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetSystemUpdateID</name>
<argumentList>
<argument>
<name>Id</name>
<direction>out</direction>
<relatedStateVariable>SystemUpdateID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Browse</name>
<argumentList>
<argument>
<name>ObjectID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>BrowseFlag</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable>
</argument>
<argument>
<name>Filter</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>
</argument>
<argument>
<name>StartingIndex</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>
</argument>
<argument>
<name>RequestedCount</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>SortCriteria</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>
</argument>
<argument>
<name>Result</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
</argument>
<argument>
<name>NumberReturned</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>TotalMatches</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>UpdateID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Search</name>
<argumentList>
<argument>
<name>ContainerID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>SearchCriteria</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_SearchCriteria</relatedStateVariable>
</argument>
<argument>
<name>Filter</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>
</argument>
<argument>
<name>StartingIndex</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>
</argument>
<argument>
<name>RequestedCount</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>SortCriteria</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>
</argument>
<argument>
<name>Result</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
</argument>
<argument>
<name>NumberReturned</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>TotalMatches</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>UpdateID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>CreateObject</name>
<argumentList>
<argument>
<name>ContainerID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>Elements</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
</argument>
<argument>
<name>ObjectID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>Result</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>DestroyObject</name>
<argumentList>
<argument>
<name>ObjectID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>UpdateObject</name>
<argumentList>
<argument>
<name>ObjectID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>CurrentTagValue</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_TagValueList</relatedStateVariable>
</argument>
<argument>
<name>NewTagValue</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_TagValueList</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>MoveObject</name>
<argumentList>
<argument>
<name>ObjectID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>NewParentID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>NewObjectID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>ImportResource</name>
<argumentList>
<argument>
<name>SourceURI</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_URI</relatedStateVariable>
</argument>
<argument>
<name>DestinationURI</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_URI</relatedStateVariable>
</argument>
<argument>
<name>TransferID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_TransferID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>ExportResource</name>
<argumentList>
<argument>
<name>SourceURI</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_URI</relatedStateVariable>
</argument>
<argument>
<name>DestinationURI</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_URI</relatedStateVariable>
</argument>
<argument>
<name>TransferID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_TransferID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>StopTransferResource</name>
<argumentList>
<argument>
<name>TransferID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_TransferID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>DeleteResource</name>
<argumentList>
<argument>
<name>ResourceURI</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_URI</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetTransferProgress</name>
<argumentList>
<argument>
<name>TransferID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_TransferID</relatedStateVariable>
</argument>
<argument>
<name>TransferStatus</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_TransferStatus</relatedStateVariable>
</argument>
<argument>
<name>TransferLength</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_TransferLength</relatedStateVariable>
</argument>
<argument>
<name>TransferTotal</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_TransferTotal</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>CreateReference</name>
<argumentList>
<argument>
<name>ContainerID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>ObjectID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>NewID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>X_GetFeatureList</name>
<argumentList>
<argument>
<name>FeatureList</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Featurelist</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>X_SetBookmark</name>
<argumentList>
<argument>
<name>CategoryType</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_CategoryType</relatedStateVariable>
</argument>
<argument>
<name>RID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_RID</relatedStateVariable>
</argument>
<argument>
<name>ObjectID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>PosSecond</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_PosSec</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>SearchCapabilities</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>SortCapabilities</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>SortExtensionCapabilities</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>SystemUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>ContainerUpdateIDs</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TransferIDs</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>FeatureList</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ObjectID</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Result</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_SearchCriteria</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_BrowseFlag</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>BrowseMetadata</allowedValue>
<allowedValue>BrowseDirectChildren</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Filter</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_SortCriteria</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Index</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Count</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_UpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_TransferID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_TransferStatus</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>COMPLETED</allowedValue>
<allowedValue>ERROR</allowedValue>
<allowedValue>IN_PROGRESS</allowedValue>
<allowedValue>STOPPED</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_TransferLength</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_TransferTotal</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_TagValueList</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_URI</name>
<dataType>uri</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_CategoryType</name>
<dataType>ui4</dataType>
<defaultValue />
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_RID</name>
<dataType>ui4</dataType>
<defaultValue />
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_PosSec</name>
<dataType>ui4</dataType>
<defaultValue />
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Featurelist</name>
<dataType>string</dataType>
<defaultValue />
</stateVariable>
</serviceStateTable>
</scpd>

View file

@ -0,0 +1,88 @@
<?xml version="1.0" ?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>IsAuthorized</name>
<argumentList>
<argument>
<name>DeviceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_DeviceID</relatedStateVariable>
</argument>
<argument>
<name>Result</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>RegisterDevice</name>
<argumentList>
<argument>
<name>RegistrationReqMsg</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_RegistrationReqMsg</relatedStateVariable>
</argument>
<argument>
<name>RegistrationRespMsg</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_RegistrationRespMsg</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>IsValidated</name>
<argumentList>
<argument>
<name>DeviceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_DeviceID</relatedStateVariable>
</argument>
<argument>
<name>Result</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_DeviceID</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Result</name>
<dataType>int</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_RegistrationReqMsg</name>
<dataType>bin.base64</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_RegistrationRespMsg</name>
<dataType>bin.base64</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>AuthorizationGrantedUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>AuthorizationDeniedUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>ValidationSucceededUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>ValidationRevokedUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

65
dlna/upnpav/upnpav.go Normal file
View file

@ -0,0 +1,65 @@
// Package upnpav provides utilities for DLNA server.
package upnpav
import (
"encoding/xml"
"time"
)
const (
// NoSuchObjectErrorCode : The specified ObjectID is invalid.
NoSuchObjectErrorCode = 701
)
// Resource description
type Resource struct {
XMLName xml.Name `xml:"res"`
ProtocolInfo string `xml:"protocolInfo,attr"`
URL string `xml:",chardata"`
Size uint64 `xml:"size,attr,omitempty"`
Bitrate uint `xml:"bitrate,attr,omitempty"`
Duration string `xml:"duration,attr,omitempty"`
Resolution string `xml:"resolution,attr,omitempty"`
}
// Container description
type Container struct {
Object
XMLName xml.Name `xml:"container"`
ChildCount *int `xml:"childCount,attr"`
}
// Item description
type Item struct {
Object
XMLName xml.Name `xml:"item"`
Res []Resource
InnerXML string `xml:",innerxml"`
}
// Object description
type Object struct {
ID string `xml:"id,attr"`
ParentID string `xml:"parentID,attr"`
Restricted int `xml:"restricted,attr"` // indicates whether the object is modifiable
Class string `xml:"upnp:class"`
Icon string `xml:"upnp:icon,omitempty"`
Title string `xml:"dc:title"`
Date Timestamp `xml:"dc:date"`
Artist string `xml:"upnp:artist,omitempty"`
Album string `xml:"upnp:album,omitempty"`
Genre string `xml:"upnp:genre,omitempty"`
AlbumArtURI string `xml:"upnp:albumArtURI,omitempty"`
OriginalTrackNumber int `xml:"upnp:originalTrackNumber,omitempty"`
Searchable int `xml:"searchable,attr"`
}
// Timestamp wraps time.Time for formatting purposes
type Timestamp struct {
time.Time
}
// MarshalXML formats the Timestamp per DIDL-Lite spec
func (t Timestamp) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return e.EncodeElement(t.Format("2006-01-02"), start)
}

4
go.mod
View file

@ -8,6 +8,7 @@ replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/
require (
github.com/Masterminds/squirrel v1.5.4
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
github.com/anacrolix/dms v1.7.1
github.com/andybalholm/cascadia v1.3.3
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
@ -42,6 +43,7 @@ require (
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.23.0
github.com/onsi/gomega v1.36.2
github.com/oriser/regroup v0.0.0-20240925165441-f6bb0e08289e
github.com/pelletier/go-toml/v2 v2.2.3
github.com/pocketbase/dbx v1.11.0
github.com/pressly/goose/v3 v3.24.1
@ -67,6 +69,8 @@ require (
)
require (
github.com/anacrolix/generics v0.0.1 // indirect
github.com/anacrolix/log v0.15.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/reflex v0.3.1 // indirect

8
go.sum
View file

@ -4,6 +4,12 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/anacrolix/dms v1.7.1 h1:XVOpT3eoO5Ds34B1X+TE3R2ApfqGGeqotEoCVNP8BaI=
github.com/anacrolix/dms v1.7.1/go.mod h1:excFJW5MKBhn5yt5ZMyeE9iFVqnO6tEGQl7YG/2tUoQ=
github.com/anacrolix/generics v0.0.1 h1:4WVhK6iLb3UAAAQP6I3uYlMOHcp9FqJC9j4n81Wv9Ks=
github.com/anacrolix/generics v0.0.1/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8=
github.com/anacrolix/log v0.15.2 h1:LTSf5Wm6Q4GNWPFMBP7NPYV6UBVZzZLKckL+/Lj72Oo=
github.com/anacrolix/log v0.15.2/go.mod h1:m0poRtlr41mriZlXBQ9SOVZ8yZBkLjOkDhd5Li5pITA=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@ -164,6 +170,8 @@ github.com/onsi/ginkgo/v2 v2.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ
github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/oriser/regroup v0.0.0-20240925165441-f6bb0e08289e h1:cL0lMYYEbfEUBghQd4ytnl8B8Ktdm+JremTyAagegZ0=
github.com/oriser/regroup v0.0.0-20240925165441-f6bb0e08289e/go.mod h1:tUOeYZJlwO7jSmM5ko1jTCiQaWQMvh58IENEfjwYzh8=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=