mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Raw copies from rclone
This commit is contained in:
parent
4755bdc5a8
commit
b3d030249c
28
dlna/connectionmanagerservice.go
Normal file
28
dlna/connectionmanagerservice.go
Normal 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
|
||||
}
|
||||
}
|
||||
349
dlna/contenddirectoryservice.go
Normal file
349
dlna/contenddirectoryservice.go
Normal file
@ -0,0 +1,349 @@
|
||||
//go:build go1.21
|
||||
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/anacrolix/dms/dlna"
|
||||
"github.com/anacrolix/dms/upnp"
|
||||
)
|
||||
|
||||
type contentDirectoryService struct {
|
||||
*DLNAServer
|
||||
upnp.Eventing
|
||||
}
|
||||
|
||||
func (cds *contentDirectoryService) updateIDString() string {
|
||||
return fmt.Sprintf("%d", uint32(os.Getpid()))
|
||||
}
|
||||
|
||||
var mediaMimeTypeRegexp = regexp.MustCompile("^(video|audio|image)/")
|
||||
|
||||
// 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, fileInfo vfs.Node, resources vfs.Nodes, host string) (ret interface{}, err error) {
|
||||
obj := upnpav.Object{
|
||||
ID: cdsObject.ID(),
|
||||
Restricted: 1,
|
||||
ParentID: cdsObject.ParentID(),
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
defaultChildCount := 1
|
||||
obj.Class = "object.container.storageFolder"
|
||||
obj.Title = fileInfo.Name()
|
||||
return upnpav.Container{
|
||||
Object: obj,
|
||||
ChildCount: &defaultChildCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return
|
||||
}
|
||||
|
||||
// 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 string
|
||||
if o, ok := fileInfo.DirEntry().(fs.Object); ok {
|
||||
mimeType = fs.MimeType(context.TODO(), o)
|
||||
// If backend doesn't know what the mime type is then
|
||||
// try getting it from the file name
|
||||
if mimeType == "application/octet-stream" {
|
||||
mimeType = fs.MimeTypeFromName(fileInfo.Name())
|
||||
}
|
||||
} else {
|
||||
mimeType = fs.MimeTypeFromName(fileInfo.Name())
|
||||
}
|
||||
|
||||
mediaType := mediaMimeTypeRegexp.FindStringSubmatch(mimeType)
|
||||
if mediaType == nil {
|
||||
return
|
||||
}
|
||||
|
||||
obj.Class = "object.item." + mediaType[1] + "Item"
|
||||
obj.Title = fileInfo.Name()
|
||||
obj.Date = upnpav.Timestamp{Time: fileInfo.ModTime()}
|
||||
|
||||
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(fileInfo.Size()),
|
||||
})
|
||||
|
||||
for _, resource := range resources {
|
||||
subtitleURL := (&url.URL{
|
||||
Scheme: "http",
|
||||
Host: host,
|
||||
Path: path.Join(resPath, resource.Path()),
|
||||
}).String()
|
||||
item.Res = append(item.Res, upnpav.Resource{
|
||||
URL: subtitleURL,
|
||||
ProtocolInfo: fmt.Sprintf("http-get:*:%s:*", "text/srt"),
|
||||
})
|
||||
}
|
||||
|
||||
ret = item
|
||||
return
|
||||
}
|
||||
|
||||
// Returns all the upnpav objects in a directory.
|
||||
func (cds *contentDirectoryService) readContainer(o object, host string) (ret []interface{}, err error) {
|
||||
node, err := cds.vfs.Stat(o.Path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !node.IsDir() {
|
||||
err = errors.New("not a directory")
|
||||
return
|
||||
}
|
||||
|
||||
dir := node.(*vfs.Dir)
|
||||
dirEntries, err := dir.ReadDirAll()
|
||||
if err != nil {
|
||||
err = errors.New("failed to list directory")
|
||||
return
|
||||
}
|
||||
|
||||
dirEntries, mediaResources := mediaWithResources(dirEntries)
|
||||
for _, de := range dirEntries {
|
||||
child := object{
|
||||
path.Join(o.Path, de.Name()),
|
||||
}
|
||||
obj, err := cds.cdsObjectToUpnpavObject(child, de, mediaResources[de], host)
|
||||
if err != nil {
|
||||
fs.Errorf(cds, "error with %s: %s", child.FilePath(), err)
|
||||
continue
|
||||
}
|
||||
if obj == nil {
|
||||
fs.Debugf(cds, "unrecognized file type: %s", de)
|
||||
continue
|
||||
}
|
||||
ret = append(ret, obj)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Given a list of nodes, separate them into potential media items and any associated resources (external subtitles,
|
||||
// for example.)
|
||||
//
|
||||
// The result is a slice of potential media nodes (in their original order) and a map containing associated
|
||||
// resources nodes of each media node, if any.
|
||||
func mediaWithResources(nodes vfs.Nodes) (vfs.Nodes, map[vfs.Node]vfs.Nodes) {
|
||||
media, mediaResources := vfs.Nodes{}, make(map[vfs.Node]vfs.Nodes)
|
||||
|
||||
// First, separate out the subtitles and media into maps, keyed by their lowercase base names.
|
||||
mediaByName, subtitlesByName := make(map[string]vfs.Nodes), make(map[string]vfs.Node)
|
||||
for _, node := range nodes {
|
||||
baseName, ext := splitExt(strings.ToLower(node.Name()))
|
||||
switch ext {
|
||||
case ".srt", ".ass", ".ssa", ".sub", ".idx", ".sup", ".jss", ".txt", ".usf", ".cue", ".vtt", ".css":
|
||||
// .idx should be with .sub, .css should be with vtt otherwise they should be culled,
|
||||
// and their mimeTypes are not consistent, but anyway these negatives don't throw errors.
|
||||
subtitlesByName[baseName] = node
|
||||
default:
|
||||
mediaByName[baseName] = append(mediaByName[baseName], node)
|
||||
media = append(media, node)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the associated media file for each subtitle
|
||||
for baseName, node := range subtitlesByName {
|
||||
// Find a media file with the same basename (video.mp4 for video.srt)
|
||||
mediaNodes, found := mediaByName[baseName]
|
||||
if !found {
|
||||
// Or basename of the basename (video.mp4 for video.en.srt)
|
||||
baseName, _ = splitExt(baseName)
|
||||
mediaNodes, found = mediaByName[baseName]
|
||||
}
|
||||
|
||||
// Just advise if no match found
|
||||
if !found {
|
||||
fs.Infof(node, "could not find associated media for subtitle: %s", node.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
// Associate with all potential media nodes
|
||||
fs.Debugf(mediaNodes, "associating subtitle: %s", node.Name())
|
||||
for _, mediaNode := range mediaNodes {
|
||||
mediaResources[mediaNode] = append(mediaResources[mediaNode], node)
|
||||
}
|
||||
}
|
||||
|
||||
return media, mediaResources
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
|
||||
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, err.Error())
|
||||
}
|
||||
switch browse.BrowseFlag {
|
||||
case "BrowseDirectChildren":
|
||||
objs, err := cds.readContainer(obj, host)
|
||||
if err != nil {
|
||||
return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, 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)
|
||||
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":
|
||||
node, err := cds.vfs.Stat(obj.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TODO: External subtitles won't appear in the metadata here, but probably should.
|
||||
upnpObject, err := cds.cdsObjectToUpnpavObject(obj, node, vfs.Nodes{}, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := xml.Marshal(upnpObject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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.
|
||||
}
|
||||
|
||||
// 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 !path.IsAbs(o.Path) {
|
||||
log.Panicf("Relative object path: %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()
|
||||
}
|
||||
@ -57,6 +57,18 @@ func New(ds model.DataStore, broker events.Broker) *DLNAServer {
|
||||
s.ssdp.Interfaces = listInterfaces()
|
||||
s.ssdp.AnnounceInterval = time.Duration(30) * time.Second
|
||||
|
||||
s.ssdp.services = map[string]UPnPService {
|
||||
"ContentDirectory": &contentDirectoryService{
|
||||
server: s,
|
||||
},
|
||||
"ConnectionManager": &connectionManagerService{
|
||||
server: s,
|
||||
},
|
||||
"X_MS_MediaReceiverRegistrar": &mediaReceiverRegistrarService{
|
||||
server: s,
|
||||
},
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
29
dlna/mediareceiverregistrarservice.go
Normal file
29
dlna/mediareceiverregistrarservice.go
Normal 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.RootDeviceUUID,
|
||||
}, nil
|
||||
default:
|
||||
return nil, upnp.InvalidActionError
|
||||
}
|
||||
}
|
||||
64
dlna/upnpav/upnpav.go
Normal file
64
dlna/upnpav/upnpav.go
Normal file
@ -0,0 +1,64 @@
|
||||
// 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"`
|
||||
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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user