github.com/10XDev/rclone@v1.52.3-0.20200626220027-16af9ab76b2a/cmd/serve/dlna/cds.go (about)

     1  package dlna
     2  
     3  import (
     4  	"context"
     5  	"encoding/xml"
     6  	"fmt"
     7  	"log"
     8  	"mime"
     9  	"net/http"
    10  	"net/url"
    11  	"os"
    12  	"path"
    13  	"path/filepath"
    14  	"regexp"
    15  	"strings"
    16  
    17  	"github.com/anacrolix/dms/dlna"
    18  	"github.com/anacrolix/dms/upnp"
    19  	"github.com/pkg/errors"
    20  	"github.com/rclone/rclone/cmd/serve/dlna/upnpav"
    21  	"github.com/rclone/rclone/fs"
    22  	"github.com/rclone/rclone/vfs"
    23  )
    24  
    25  // Add a minimal number of mime types to augment go's built in types
    26  // for environments which don't have access to a mime.types file (eg
    27  // Termux on android)
    28  func init() {
    29  	for _, t := range []struct {
    30  		mimeType   string
    31  		extensions string
    32  	}{
    33  		{"audio/flac", ".flac"},
    34  		{"audio/mpeg", ".mpga,.mpega,.mp2,.mp3,.m4a"},
    35  		{"audio/ogg", ".oga,.ogg,.opus,.spx"},
    36  		{"audio/x-wav", ".wav"},
    37  		{"image/tiff", ".tiff,.tif"},
    38  		{"video/dv", ".dif,.dv"},
    39  		{"video/fli", ".fli"},
    40  		{"video/mpeg", ".mpeg,.mpg,.mpe"},
    41  		{"video/MP2T", ".ts"},
    42  		{"video/mp4", ".mp4"},
    43  		{"video/quicktime", ".qt,.mov"},
    44  		{"video/ogg", ".ogv"},
    45  		{"video/webm", ".webm"},
    46  		{"video/x-msvideo", ".avi"},
    47  		{"video/x-matroska", ".mpv,.mkv"},
    48  		{"text/srt", ".srt"},
    49  	} {
    50  		for _, ext := range strings.Split(t.extensions, ",") {
    51  			err := mime.AddExtensionType(ext, t.mimeType)
    52  			if err != nil {
    53  				panic(err)
    54  			}
    55  		}
    56  	}
    57  }
    58  
    59  type contentDirectoryService struct {
    60  	*server
    61  	upnp.Eventing
    62  }
    63  
    64  func (cds *contentDirectoryService) updateIDString() string {
    65  	return fmt.Sprintf("%d", uint32(os.Getpid()))
    66  }
    67  
    68  var mediaMimeTypeRegexp = regexp.MustCompile("^(video|audio|image)/")
    69  
    70  // Turns the given entry and DMS host into a UPnP object. A nil object is
    71  // returned if the entry is not of interest.
    72  func (cds *contentDirectoryService) cdsObjectToUpnpavObject(cdsObject object, fileInfo vfs.Node, resources vfs.Nodes, host string) (ret interface{}, err error) {
    73  	obj := upnpav.Object{
    74  		ID:         cdsObject.ID(),
    75  		Restricted: 1,
    76  		ParentID:   cdsObject.ParentID(),
    77  	}
    78  
    79  	if fileInfo.IsDir() {
    80  		obj.Class = "object.container.storageFolder"
    81  		obj.Title = fileInfo.Name()
    82  		return upnpav.Container{
    83  			Object: obj,
    84  		}, nil
    85  	}
    86  
    87  	if !fileInfo.Mode().IsRegular() {
    88  		return
    89  	}
    90  
    91  	// Read the mime type from the fs.Object if possible,
    92  	// otherwise fall back to working out what it is from the file path.
    93  	var mimeType string
    94  	if o, ok := fileInfo.DirEntry().(fs.Object); ok {
    95  		mimeType = fs.MimeType(context.TODO(), o)
    96  	} else {
    97  		mimeType = fs.MimeTypeFromName(fileInfo.Name())
    98  	}
    99  
   100  	mediaType := mediaMimeTypeRegexp.FindStringSubmatch(mimeType)
   101  	if mediaType == nil {
   102  		return
   103  	}
   104  
   105  	obj.Class = "object.item." + mediaType[1] + "Item"
   106  	obj.Title = fileInfo.Name()
   107  	obj.Date = upnpav.Timestamp{Time: fileInfo.ModTime()}
   108  
   109  	item := upnpav.Item{
   110  		Object: obj,
   111  		Res:    make([]upnpav.Resource, 0, 1),
   112  	}
   113  
   114  	item.Res = append(item.Res, upnpav.Resource{
   115  		URL: (&url.URL{
   116  			Scheme: "http",
   117  			Host:   host,
   118  			Path:   path.Join(resPath, cdsObject.Path),
   119  		}).String(),
   120  		ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mimeType, dlna.ContentFeatures{
   121  			SupportRange: true,
   122  		}.String()),
   123  		Size: uint64(fileInfo.Size()),
   124  	})
   125  
   126  	for _, resource := range resources {
   127  		subtitleURL := (&url.URL{
   128  			Scheme: "http",
   129  			Host:   host,
   130  			Path:   path.Join(resPath, resource.Path()),
   131  		}).String()
   132  		item.Res = append(item.Res, upnpav.Resource{
   133  			URL:          subtitleURL,
   134  			ProtocolInfo: fmt.Sprintf("http-get:*:%s:*", "text/srt"),
   135  		})
   136  	}
   137  
   138  	ret = item
   139  	return
   140  }
   141  
   142  // Returns all the upnpav objects in a directory.
   143  func (cds *contentDirectoryService) readContainer(o object, host string) (ret []interface{}, err error) {
   144  	node, err := cds.vfs.Stat(o.Path)
   145  	if err != nil {
   146  		return
   147  	}
   148  
   149  	if !node.IsDir() {
   150  		err = errors.New("not a directory")
   151  		return
   152  	}
   153  
   154  	dir := node.(*vfs.Dir)
   155  	dirEntries, err := dir.ReadDirAll()
   156  	if err != nil {
   157  		err = errors.New("failed to list directory")
   158  		return
   159  	}
   160  
   161  	dirEntries, mediaResources := mediaWithResources(dirEntries)
   162  	for _, de := range dirEntries {
   163  		child := object{
   164  			path.Join(o.Path, de.Name()),
   165  		}
   166  		obj, err := cds.cdsObjectToUpnpavObject(child, de, mediaResources[de], host)
   167  		if err != nil {
   168  			fs.Errorf(cds, "error with %s: %s", child.FilePath(), err)
   169  			continue
   170  		}
   171  		if obj == nil {
   172  			fs.Debugf(cds, "unrecognized file type: %s", de)
   173  			continue
   174  		}
   175  		ret = append(ret, obj)
   176  	}
   177  
   178  	return
   179  }
   180  
   181  // Given a list of nodes, separate them into potential media items and any associated resources (external subtitles,
   182  // for example.)
   183  //
   184  // The result is a slice of potential media nodes (in their original order) and a map containing associated
   185  // resources nodes of each media node, if any.
   186  func mediaWithResources(nodes vfs.Nodes) (vfs.Nodes, map[vfs.Node]vfs.Nodes) {
   187  	media, mediaResources := vfs.Nodes{}, make(map[vfs.Node]vfs.Nodes)
   188  
   189  	// First, separate out the subtitles and media into maps, keyed by their lowercase base names.
   190  	mediaByName, subtitlesByName := make(map[string]vfs.Nodes), make(map[string]vfs.Node)
   191  	for _, node := range nodes {
   192  		baseName, ext := splitExt(strings.ToLower(node.Name()))
   193  		switch ext {
   194  		case ".srt":
   195  			subtitlesByName[baseName] = node
   196  		default:
   197  			mediaByName[baseName] = append(mediaByName[baseName], node)
   198  			media = append(media, node)
   199  		}
   200  	}
   201  
   202  	// Find the associated media file for each subtitle
   203  	for baseName, node := range subtitlesByName {
   204  		// Find a media file with the same basename (video.mp4 for video.srt)
   205  		mediaNodes, found := mediaByName[baseName]
   206  		if !found {
   207  			// Or basename of the basename (video.mp4 for video.en.srt)
   208  			baseName, _ = splitExt(baseName)
   209  			mediaNodes, found = mediaByName[baseName]
   210  		}
   211  
   212  		// Just advise if no match found
   213  		if !found {
   214  			fs.Infof(node, "could not find associated media for subtitle: %s", node.Name())
   215  			continue
   216  		}
   217  
   218  		// Associate with all potential media nodes
   219  		fs.Debugf(mediaNodes, "associating subtitle: %s", node.Name())
   220  		for _, mediaNode := range mediaNodes {
   221  			mediaResources[mediaNode] = append(mediaResources[mediaNode], node)
   222  		}
   223  	}
   224  
   225  	return media, mediaResources
   226  }
   227  
   228  type browse struct {
   229  	ObjectID       string
   230  	BrowseFlag     string
   231  	Filter         string
   232  	StartingIndex  int
   233  	RequestedCount int
   234  }
   235  
   236  // ContentDirectory object from ObjectID.
   237  func (cds *contentDirectoryService) objectFromID(id string) (o object, err error) {
   238  	o.Path, err = url.QueryUnescape(id)
   239  	if err != nil {
   240  		return
   241  	}
   242  	if o.Path == "0" {
   243  		o.Path = "/"
   244  	}
   245  	o.Path = path.Clean(o.Path)
   246  	if !path.IsAbs(o.Path) {
   247  		err = fmt.Errorf("bad ObjectID %v", o.Path)
   248  		return
   249  	}
   250  	return
   251  }
   252  
   253  func (cds *contentDirectoryService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) {
   254  	host := r.Host
   255  
   256  	switch action {
   257  	case "GetSystemUpdateID":
   258  		return map[string]string{
   259  			"Id": cds.updateIDString(),
   260  		}, nil
   261  	case "GetSortCapabilities":
   262  		return map[string]string{
   263  			"SortCaps": "dc:title",
   264  		}, nil
   265  	case "Browse":
   266  		var browse browse
   267  		if err := xml.Unmarshal(argsXML, &browse); err != nil {
   268  			return nil, err
   269  		}
   270  		obj, err := cds.objectFromID(browse.ObjectID)
   271  		if err != nil {
   272  			return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, err.Error())
   273  		}
   274  		switch browse.BrowseFlag {
   275  		case "BrowseDirectChildren":
   276  			objs, err := cds.readContainer(obj, host)
   277  			if err != nil {
   278  				return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, err.Error())
   279  			}
   280  			totalMatches := len(objs)
   281  			objs = objs[func() (low int) {
   282  				low = browse.StartingIndex
   283  				if low > len(objs) {
   284  					low = len(objs)
   285  				}
   286  				return
   287  			}():]
   288  			if browse.RequestedCount != 0 && browse.RequestedCount < len(objs) {
   289  				objs = objs[:browse.RequestedCount]
   290  			}
   291  			result, err := xml.Marshal(objs)
   292  			if err != nil {
   293  				return nil, err
   294  			}
   295  			return map[string]string{
   296  				"TotalMatches":   fmt.Sprint(totalMatches),
   297  				"NumberReturned": fmt.Sprint(len(objs)),
   298  				"Result":         didlLite(string(result)),
   299  				"UpdateID":       cds.updateIDString(),
   300  			}, nil
   301  		case "BrowseMetadata":
   302  			node, err := cds.vfs.Stat(obj.Path)
   303  			if err != nil {
   304  				return nil, err
   305  			}
   306  			// TODO: External subtitles won't appear in the metadata here, but probably should.
   307  			upnpObject, err := cds.cdsObjectToUpnpavObject(obj, node, vfs.Nodes{}, host)
   308  			if err != nil {
   309  				return nil, err
   310  			}
   311  			result, err := xml.Marshal(upnpObject)
   312  			if err != nil {
   313  				return nil, err
   314  			}
   315  			return map[string]string{
   316  				"Result": didlLite(string(result)),
   317  			}, nil
   318  		default:
   319  			return nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, "unhandled browse flag: %v", browse.BrowseFlag)
   320  		}
   321  	case "GetSearchCapabilities":
   322  		return map[string]string{
   323  			"SearchCaps": "",
   324  		}, nil
   325  	// Samsung Extensions
   326  	case "X_GetFeatureList":
   327  		return map[string]string{
   328  			"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">
   329  	<Feature name="samsung.com_BASICVIEW" version="1">
   330  		<container id="0" type="object.item.imageItem"/>
   331  		<container id="0" type="object.item.audioItem"/>
   332  		<container id="0" type="object.item.videoItem"/>
   333  	</Feature>
   334  </Features>`}, nil
   335  	case "X_SetBookmark":
   336  		// just ignore
   337  		return map[string]string{}, nil
   338  	default:
   339  		return nil, upnp.InvalidActionError
   340  	}
   341  }
   342  
   343  // Represents a ContentDirectory object.
   344  type object struct {
   345  	Path string // The cleaned, absolute path for the object relative to the server.
   346  }
   347  
   348  // Returns the actual local filesystem path for the object.
   349  func (o *object) FilePath() string {
   350  	return filepath.FromSlash(o.Path)
   351  }
   352  
   353  // Returns the ObjectID for the object. This is used in various ContentDirectory actions.
   354  func (o object) ID() string {
   355  	if !path.IsAbs(o.Path) {
   356  		log.Panicf("Relative object path: %s", o.Path)
   357  	}
   358  	if len(o.Path) == 1 {
   359  		return "0"
   360  	}
   361  	return url.QueryEscape(o.Path)
   362  }
   363  
   364  func (o *object) IsRoot() bool {
   365  	return o.Path == "/"
   366  }
   367  
   368  // Returns the object's parent ObjectID. Fortunately it can be deduced from the
   369  // ObjectID (for now).
   370  func (o object) ParentID() string {
   371  	if o.IsRoot() {
   372  		return "-1"
   373  	}
   374  	o.Path = path.Dir(o.Path)
   375  	return o.ID()
   376  }