navidrome/dlna/contenddirectoryservice.go

530 lines
17 KiB
Go

//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>[^\\/]+)?[\\/]?(?<ArtistAlbumTrack>[^\\/]+)?")
albumRegex = regroup.MustCompile("\\/Music\\/Albums[\\/]?(?P<AlbumTitle>[^\\/]+)?[\\/]?(?<AlbumTrack>[^\\/]+)?")
genresRegex = regroup.MustCompile("\\/Music\\/Genres[\\/]?(?P<Genre>[^\\/]+)?[\\/]?(?P<GenreArtist>[^/]+)?[\\/]?(?P<GenreTrack>[^\\/]+)?")
recentRegex = regroup.MustCompile("\\/Music\\/Recently Added[\\/]?(?P<RecentTrack>[^\\/]+)?")
playlistRegex = regroup.MustCompile("\\/Music\\/Playlist[\\/]?(?P<Playlist>[^\\/]+)?[\\/]?(?P<PlaylistTrack>[^\\/]+)?")
}
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) (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,
}
}
// Read the mime type from the fs.Object if possible,
// otherwise fall back to working out what it is from the file path.
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(resPath, cdsObject.Path),
}).String(),
ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mimeType, dlna.ContentFeatures{
SupportRange: true,
}.String()),
Size: uint64(1048576), //TODO
})
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))
return ret, nil
}
if o.Path == "/Music" {
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Files"}, true, host))
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Artists"}, true, host))
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Albums"}, true, host))
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Genres"}, true, host))
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Recently Added"}, true, host))
ret = append(ret, cds.cdsObjectToUpnpavObject(object{Path: "/Music/Playlists"}, true, host))
return ret, nil
} 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 {
if matchResults["ArtistAlbumTrack"] != "" {
//TODO
log.Debug("Artist Get a track ")
} else if matchResults["ArtistAlbum"] != "" {
basePath := path.Join("/Music/Artists", matchResults["Artist"], matchResults["ArtistAlbum"])
//albumResponse, _ := cds.ds.Album(cds.ctx).Get(matchResults["ArtistAlbum"])
tracks, _ := cds.ds.MediaFile(cds.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": matchResults["ArtistAlbum"]}})
return cds.doMediaFiles(tracks, basePath, ret, host)
} else if matchResults["Artist"] != "" {
basePath := path.Join("/Music/Artists", matchResults["Artist"])
allAlbumsForThisArtist, _ := cds.ds.Album(cds.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_artist_id": matchResults["Artist"]}})
return cds.doAlbums(allAlbumsForThisArtist, basePath, ret, host)
} else {
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))
}
}
return ret, nil
}
} else if matchResults, err := albumRegex.Groups(o.Path); err == nil {
if matchResults["AlbumTrack"] != "" {
log.Debug("TODO AlbumTrack MATCH")
} else if matchResults["AlbumTitle"] != "" {
basePath := path.Join("/Music/Albums", matchResults["AlbumTitle"])
tracks, _ := cds.ds.MediaFile(cds.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": matchResults["AlbumTitle"]}})
return cds.doMediaFiles(tracks, basePath, ret, host)
} else {
indexes, err := cds.ds.Album(cds.ctx).GetAllWithoutGenres()
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))
}
return ret, nil
}
} else if matchResults, err := genresRegex.Groups(o.Path); err == nil {
log.Debug("Genre MATCH")
if matchResults["GenreTrack"] != "" {
log.Debug("TODO GenreTrack MATCH")
} else 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)
/*
//TODO do the metadata and stuff here
for trackIndex := range tracks {
child := object {
Path: path.Join(o.Path, tracks[trackIndex].Title),
Id: path.Join(o.Path, tracks[trackIndex].ID),
}
ret = append(ret, cds.cdsObjectToUpnpavObject(child, false, 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))
}
}
} else {
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))
}
return ret, nil
}
} else if matchResults, err := recentRegex.Groups(o.Path); err == nil {
log.Debug("TODO recent MATCH")
fmt.Printf("%+v", matchResults)
} else if matchResults, err := playlistRegex.Groups(o.Path); err == nil {
log.Debug("TODO Playlist MATCH")
if matchResults["PlaylistTrack"] != "" {
log.Debug("TODO PlaylistTrack MATCH")
} else if matchResults["Playlist"] != "" {
log.Debug("Playlist only MATCH")
x, xerr := cds.ds.Playlist(cds.ctx).Get(matchResults["Playlist"])
log.Debug(fmt.Sprintf("TODO Playlist: %+v", x), xerr)
} else {
log.Debug("Playlist else MATCH")
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))
}
return ret, nil
}
}
return
}
func (cds *contentDirectoryService) doMediaFiles(tracks model.MediaFiles, basePath string, ret []interface{}, host string) ([]interface{}, error) {
//TODO flesh object out with actually useful metadata about the track
/*
<item id="1$7$455$1" parentID="1$7$455" restricted="1" refID="64$15A$0$1">
<dc:title>Love Takes Time</dc:title>
<dc:description/>
<dc:creator>Mariah Carey</dc:creator>
<dc:date>2000-01-01</dc:date>
<upnp:class>object.item.audioItem.musicTrack</upnp:class>
<upnp:artist>Mariah Carey</upnp:artist>
<upnp:album>#1's</upnp:album>
<upnp:genre>Pop</upnp:genre>
<upnp:originalTrackNumber>2</upnp:originalTrackNumber>
<upnp:albumArtURI xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/" dlna:profileID="JPEG_TN">http://172.30.0.4:8200/AlbumArt/24179-17759.jpg</upnp:albumArtURI>
<res size="8861869" duration="0:04:36.160" bitrate="256000" sampleFrequency="44100" nrAudioChannels="2" protocolInfo="http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000">http://172.30.0.4:8200/MediaItems/17759.mp3</res>
</item>
*/
for _, track := range tracks {
child := object{
Path: path.Join(basePath, track.ID),
Id: path.Join(basePath, track.ID),
}
title := track.Title
artist := track.Artist
album := track.Album
genre := track.Genre
trackNo := track.TrackNumber
trackDurStr := floatToDurationString(track.Duration)
obj := upnpav.Object{
ID: child.Id,
Restricted: 1,
ParentID: child.ParentID(),
Title: title,
}
var mimeType = "audio/mp3" //TODO
obj.Class = "object.item.audioItem.musicTrack"
obj.Date = upnpav.Timestamp{Time:time.Now()} //TODO
obj.Artist = artist
obj.Album = album
obj.Genre = genre
obj.OriginalTrackNumber = trackNo
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(resPath, child.Path),
}).String(),
ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mimeType, dlna.ContentFeatures{
SupportRange: false,
}.String()),
Size: uint64(track.Size),
Duration: trackDurStr,
})
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) doAlbum(album *model.Album, basepath string, ret []interface{}, host string) ([]interface{}, error) {
log.Debug(fmt.Sprintf("TODO: doAlbum Called with : '%+v', '%s'", album, basepath))
panic("doAlbum Called!")
return ret, nil
}
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))
}
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()),
}
ret = append(ret, cds.cdsObjectToUpnpavObject(child, file.IsDir(), host))
}
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(fmt.Sprintf("Relative object path used with ID: $s", o.Path))
}
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()
}