2019-01-03 22:23:50 +00:00
|
|
|
package dlna
|
|
|
|
|
|
|
|
import (
|
2019-08-22 21:20:09 +00:00
|
|
|
"context"
|
2019-01-03 22:23:50 +00:00
|
|
|
"encoding/xml"
|
|
|
|
"fmt"
|
|
|
|
"log"
|
2019-08-23 10:51:50 +00:00
|
|
|
"mime"
|
2019-01-03 22:23:50 +00:00
|
|
|
"net/http"
|
|
|
|
"net/url"
|
|
|
|
"os"
|
|
|
|
"path"
|
|
|
|
"path/filepath"
|
2019-05-26 18:51:22 +00:00
|
|
|
"regexp"
|
2019-01-03 22:23:50 +00:00
|
|
|
"sort"
|
2019-08-23 10:51:50 +00:00
|
|
|
"strings"
|
2019-01-03 22:23:50 +00:00
|
|
|
|
|
|
|
"github.com/anacrolix/dms/dlna"
|
|
|
|
"github.com/anacrolix/dms/upnp"
|
|
|
|
"github.com/anacrolix/dms/upnpav"
|
|
|
|
"github.com/pkg/errors"
|
2019-07-28 17:47:38 +00:00
|
|
|
"github.com/rclone/rclone/fs"
|
|
|
|
"github.com/rclone/rclone/vfs"
|
2019-01-03 22:23:50 +00:00
|
|
|
)
|
|
|
|
|
2019-08-23 10:51:50 +00:00
|
|
|
// Add a minimal number of mime types to augment go's built in types
|
|
|
|
// for environments which don't have access to a mime.types file (eg
|
|
|
|
// Termux on android)
|
|
|
|
func init() {
|
|
|
|
for _, t := range []struct {
|
|
|
|
mimeType string
|
|
|
|
extensions string
|
|
|
|
}{
|
|
|
|
{"audio/flac", ".flac"},
|
|
|
|
{"audio/mpeg", ".mpga,.mpega,.mp2,.mp3,.m4a"},
|
|
|
|
{"audio/ogg", ".oga,.ogg,.opus,.spx"},
|
|
|
|
{"audio/x-wav", ".wav"},
|
|
|
|
{"image/tiff", ".tiff,.tif"},
|
|
|
|
{"video/dv", ".dif,.dv"},
|
|
|
|
{"video/fli", ".fli"},
|
|
|
|
{"video/mpeg", ".mpeg,.mpg,.mpe"},
|
|
|
|
{"video/MP2T", ".ts"},
|
|
|
|
{"video/mp4", ".mp4"},
|
|
|
|
{"video/quicktime", ".qt,.mov"},
|
|
|
|
{"video/ogg", ".ogv"},
|
|
|
|
{"video/webm", ".webm"},
|
|
|
|
{"video/x-msvideo", ".avi"},
|
|
|
|
{"video/x-matroska", ".mpv,.mkv"},
|
2019-09-22 23:58:24 +00:00
|
|
|
{"text/srt", ".srt"},
|
2019-08-23 10:51:50 +00:00
|
|
|
} {
|
|
|
|
for _, ext := range strings.Split(t.extensions, ",") {
|
|
|
|
err := mime.AddExtensionType(ext, t.mimeType)
|
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-01-03 22:23:50 +00:00
|
|
|
type contentDirectoryService struct {
|
|
|
|
*server
|
|
|
|
upnp.Eventing
|
|
|
|
}
|
|
|
|
|
|
|
|
func (cds *contentDirectoryService) updateIDString() string {
|
|
|
|
return fmt.Sprintf("%d", uint32(os.Getpid()))
|
|
|
|
}
|
|
|
|
|
2019-05-26 18:51:22 +00:00
|
|
|
var mediaMimeTypeRegexp = regexp.MustCompile("^(video|audio|image)/")
|
|
|
|
|
2019-01-03 22:23:50 +00:00
|
|
|
// Turns the given entry and DMS host into a UPnP object. A nil object is
|
|
|
|
// returned if the entry is not of interest.
|
2019-09-22 23:58:24 +00:00
|
|
|
func (cds *contentDirectoryService) cdsObjectToUpnpavObject(cdsObject object, fileInfo vfs.Node, resources vfs.Nodes, host string) (ret interface{}, err error) {
|
2019-01-03 22:23:50 +00:00
|
|
|
obj := upnpav.Object{
|
|
|
|
ID: cdsObject.ID(),
|
|
|
|
Restricted: 1,
|
|
|
|
ParentID: cdsObject.ParentID(),
|
|
|
|
}
|
|
|
|
|
|
|
|
if fileInfo.IsDir() {
|
2019-09-15 17:33:45 +00:00
|
|
|
children, err := cds.readContainer(cdsObject, host)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2019-01-03 22:23:50 +00:00
|
|
|
obj.Class = "object.container.storageFolder"
|
|
|
|
obj.Title = fileInfo.Name()
|
2019-09-15 17:33:45 +00:00
|
|
|
return upnpav.Container{
|
|
|
|
Object: obj,
|
|
|
|
ChildCount: len(children),
|
|
|
|
}, nil
|
2019-01-03 22:23:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if !fileInfo.Mode().IsRegular() {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2019-08-22 21:20:09 +00:00
|
|
|
// 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)
|
|
|
|
} else {
|
|
|
|
mimeType = fs.MimeTypeFromName(fileInfo.Name())
|
|
|
|
}
|
|
|
|
|
2019-05-26 18:51:22 +00:00
|
|
|
mediaType := mediaMimeTypeRegexp.FindStringSubmatch(mimeType)
|
|
|
|
if mediaType == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
obj.Class = "object.item." + mediaType[1] + "Item"
|
2019-01-03 22:23:50 +00:00
|
|
|
obj.Title = fileInfo.Name()
|
|
|
|
|
|
|
|
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: resPath,
|
|
|
|
RawQuery: url.Values{
|
|
|
|
"path": {cdsObject.Path},
|
|
|
|
}.Encode(),
|
|
|
|
}).String(),
|
2019-05-26 18:51:22 +00:00
|
|
|
ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mimeType, dlna.ContentFeatures{
|
2019-01-03 22:23:50 +00:00
|
|
|
SupportRange: true,
|
|
|
|
}.String()),
|
2019-09-22 23:58:24 +00:00
|
|
|
Size: uint64(fileInfo.Size()),
|
2019-01-03 22:23:50 +00:00
|
|
|
})
|
|
|
|
|
2019-09-22 23:58:24 +00:00
|
|
|
basePath, _ := path.Split(cdsObject.Path)
|
|
|
|
for _, resource := range resources {
|
|
|
|
subtitleURL := (&url.URL{
|
|
|
|
Scheme: "http",
|
|
|
|
Host: host,
|
|
|
|
Path: resPath,
|
|
|
|
RawQuery: url.Values{
|
|
|
|
"path": {basePath + resource.Path()},
|
|
|
|
}.Encode(),
|
|
|
|
}).String()
|
|
|
|
item.Res = append(item.Res, upnpav.Resource{
|
|
|
|
URL: subtitleURL,
|
|
|
|
ProtocolInfo: fmt.Sprintf("http-get:*:%s:*", "text/srt"),
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2019-01-03 22:23:50 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2019-09-22 23:58:24 +00:00
|
|
|
dirEntries, extraResources := partitionExtraResources(dirEntries)
|
2019-01-03 22:23:50 +00:00
|
|
|
|
|
|
|
for _, de := range dirEntries {
|
|
|
|
child := object{
|
|
|
|
path.Join(o.Path, de.Name()),
|
|
|
|
}
|
2019-09-22 23:58:24 +00:00
|
|
|
obj, err := cds.cdsObjectToUpnpavObject(child, de, extraResources[de], host)
|
2019-01-03 22:23:50 +00:00
|
|
|
if err != nil {
|
2019-05-26 18:51:22 +00:00
|
|
|
fs.Errorf(cds, "error with %s: %s", child.FilePath(), err)
|
2019-01-03 22:23:50 +00:00
|
|
|
continue
|
|
|
|
}
|
2019-05-26 18:51:22 +00:00
|
|
|
if obj == nil {
|
|
|
|
fs.Debugf(cds, "unrecognized file type: %s", de)
|
|
|
|
continue
|
2019-01-03 22:23:50 +00:00
|
|
|
}
|
2019-05-26 18:51:22 +00:00
|
|
|
ret = append(ret, obj)
|
2019-01-03 22:23:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2019-09-22 23:58:24 +00:00
|
|
|
// Given a list of nodes, separate them into potential media items and any associated resources (external subtitles,
|
|
|
|
// thumbnails, metadata, etc.)
|
|
|
|
func partitionExtraResources(nodes vfs.Nodes) (vfs.Nodes, map[vfs.Node]vfs.Nodes) {
|
|
|
|
// First, separate out the subtitles into a separate list from the media
|
|
|
|
media, subtitles := make(vfs.Nodes, 0), make(vfs.Nodes, 0)
|
|
|
|
for _, node := range nodes {
|
|
|
|
name := strings.ToLower(node.Name()) // case insensitive
|
|
|
|
switch path.Ext(name) {
|
|
|
|
case ".srt":
|
|
|
|
subtitles = append(subtitles, node)
|
|
|
|
default:
|
|
|
|
media = append(media, node)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Find the associated media file for each subtitle
|
|
|
|
extraResources := make(map[vfs.Node]vfs.Nodes)
|
|
|
|
for _, node := range subtitles {
|
|
|
|
subtitleName := strings.ToLower(node.Name())
|
|
|
|
|
|
|
|
// For a media file named "My Video.mp4", we want to associated any subtitles named like
|
|
|
|
// "My Video.srt", "My Video.en.srt", "My Video.es.srt", "My Video.forced.srt"
|
|
|
|
// note: nodes must be sorted! vfs.dir.ReadDirAll() results are already sorted ..
|
|
|
|
mediaIdx := sort.Search(len(media), func(idx int) bool {
|
|
|
|
mediaName := strings.ToLower(media[idx].Name())
|
|
|
|
basename := strings.SplitN(mediaName, ".", 2)[0]
|
|
|
|
if strings.Compare(subtitleName, basename) <= 0 {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
if strings.HasPrefix(subtitleName, basename) {
|
|
|
|
return subtitleName[len(basename)] == '.'
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
})
|
|
|
|
if mediaIdx == -1 {
|
|
|
|
fs.Infof(node, "could not find associated media for subtitle: %s", node.Name())
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
mediaNode := media[mediaIdx]
|
|
|
|
fs.Debugf(mediaNode, "associating subtitle: %s", node.Name())
|
|
|
|
extraResources[mediaNode] = append(extraResources[mediaNode], node)
|
|
|
|
}
|
|
|
|
|
|
|
|
return media, extraResources
|
|
|
|
}
|
|
|
|
|
2019-01-03 22:23:50 +00:00
|
|
|
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
|
2019-01-11 17:17:46 +00:00
|
|
|
if err := xml.Unmarshal(argsXML, &browse); err != nil {
|
2019-01-03 22:23:50 +00:00
|
|
|
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
|
|
|
|
}():]
|
2019-01-11 17:17:46 +00:00
|
|
|
if browse.RequestedCount != 0 && browse.RequestedCount < len(objs) {
|
2019-01-03 22:23:50 +00:00
|
|
|
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
|
2019-05-26 18:45:01 +00:00
|
|
|
case "BrowseMetadata":
|
2019-09-14 21:16:07 +00:00
|
|
|
node, err := cds.vfs.Stat(obj.Path)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2019-09-22 23:58:24 +00:00
|
|
|
// TODO: External subtitles won't appear in the metadata here, but probably should.
|
|
|
|
upnpObject, err := cds.cdsObjectToUpnpavObject(obj, node, vfs.Nodes{}, host)
|
2019-09-14 21:16:07 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
result, err := xml.Marshal(upnpObject)
|
2019-05-26 18:45:01 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return map[string]string{
|
|
|
|
"Result": didlLite(string(result)),
|
|
|
|
}, nil
|
2019-01-03 22:23:50 +00:00
|
|
|
default:
|
|
|
|
return nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, "unhandled browse flag: %v", browse.BrowseFlag)
|
|
|
|
}
|
|
|
|
case "GetSearchCapabilities":
|
|
|
|
return map[string]string{
|
|
|
|
"SearchCaps": "",
|
|
|
|
}, nil
|
2019-05-26 18:45:01 +00:00
|
|
|
// 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">
|
2019-09-22 23:58:24 +00:00
|
|
|
<container id="0" type="object.item.imageItem"/>
|
|
|
|
<container id="0" type="object.item.audioItem"/>
|
|
|
|
<container id="0" type="object.item.videoItem"/>
|
2019-05-26 18:45:01 +00:00
|
|
|
</Feature>
|
|
|
|
</Features>`}, nil
|
|
|
|
case "X_SetBookmark":
|
|
|
|
// just ignore
|
|
|
|
return map[string]string{}, nil
|
2019-01-03 22:23:50 +00:00
|
|
|
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()
|
|
|
|
}
|