github.com/divyam234/rclone@v1.64.1/cmd/serve/dlna/cds.go (about)

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