diff --git a/cmd/serve/dlna/cds.go b/cmd/serve/dlna/cds.go index 716d3140e..2c83b0626 100644 --- a/cmd/serve/dlna/cds.go +++ b/cmd/serve/dlna/cds.go @@ -12,7 +12,6 @@ import ( "path" "path/filepath" "regexp" - "sort" "strings" "github.com/anacrolix/dms/dlna" @@ -121,10 +120,7 @@ func (cds *contentDirectoryService) cdsObjectToUpnpavObject(cdsObject object, fi URL: (&url.URL{ Scheme: "http", Host: host, - Path: resPath, - RawQuery: url.Values{ - "path": {cdsObject.Path}, - }.Encode(), + Path: path.Join(resPath, cdsObject.Path), }).String(), ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mimeType, dlna.ContentFeatures{ SupportRange: true, @@ -132,15 +128,11 @@ func (cds *contentDirectoryService) cdsObjectToUpnpavObject(cdsObject object, fi Size: uint64(fileInfo.Size()), }) - 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(), + Path: path.Join(resPath, resource.Path()), }).String() item.Res = append(item.Res, upnpav.Resource{ URL: subtitleURL, @@ -171,13 +163,12 @@ func (cds *contentDirectoryService) readContainer(o object, host string) (ret [] return } - dirEntries, extraResources := partitionExtraResources(dirEntries) - + dirEntries, mediaResources := mediaWithResources(dirEntries) for _, de := range dirEntries { child := object{ path.Join(o.Path, de.Name()), } - obj, err := cds.cdsObjectToUpnpavObject(child, de, extraResources[de], host) + obj, err := cds.cdsObjectToUpnpavObject(child, de, mediaResources[de], host) if err != nil { fs.Errorf(cds, "error with %s: %s", child.FilePath(), err) continue @@ -193,50 +184,47 @@ func (cds *contentDirectoryService) readContainer(o object, host string) (ret [] } // 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 example.) +// +// The result is a 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.Node), make(map[string]vfs.Node) for _, node := range nodes { - name := strings.ToLower(node.Name()) // case insensitive - switch path.Ext(name) { + baseName, ext := splitExt(strings.ToLower(node.Name())) + switch ext { case ".srt": - subtitles = append(subtitles, node) + subtitlesByName[baseName] = node default: + mediaByName[baseName] = node 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 baseName, node := range subtitlesByName { + // Find a media file with the same basename (video.mp4 for video.srt) + mediaNode, found := mediaByName[baseName] + if !found { + // Or basename of the basename (video.mp4 for video.en.srt) + baseName, _ = splitExt(baseName) + mediaNode, found = mediaByName[baseName] + } - // 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 { + // Just advise if no match found + if !found { 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) + mediaResources[mediaNode] = append(mediaResources[mediaNode], node) } - return media, extraResources + return media, mediaResources } type browse struct { diff --git a/cmd/serve/dlna/dlna.go b/cmd/serve/dlna/dlna.go index 6aca7c2b2..3030837ea 100644 --- a/cmd/serve/dlna/dlna.go +++ b/cmd/serve/dlna/dlna.go @@ -62,7 +62,7 @@ players might show files that they are not able to play back correctly. const ( serverField = "Linux/3.4 DLNADOC/1.50 UPnP/1.0 DMS/1.0" rootDescPath = "/rootDesc.xml" - resPath = "/res" + resPath = "/r/" serviceControlURL = "/ctl" ) @@ -122,7 +122,8 @@ func newServer(f fs.Fs, opt *dlnaflags.Options) *server { // Setup the various http routes. r := http.NewServeMux() - r.HandleFunc(resPath, s.resourceHandler) + r.Handle(resPath, http.StripPrefix(resPath, + http.HandlerFunc(s.resourceHandler))) if opt.LogTrace { r.Handle(rootDescPath, traceLogging(http.HandlerFunc(s.rootDescHandler))) r.Handle(serviceControlURL, traceLogging(http.HandlerFunc(s.serviceControlHandler))) @@ -224,8 +225,8 @@ func (s *server) soapActionResponse(sa upnp.SoapAction, actionRequestXML []byte, // Serves actual resources (media files). func (s *server) resourceHandler(w http.ResponseWriter, r *http.Request) { - remotePath := r.URL.Query().Get("path") - node, err := s.vfs.Stat(remotePath) + remotePath := r.URL.Path + node, err := s.vfs.Stat(r.URL.Path) if err != nil { http.NotFound(w, r) return diff --git a/cmd/serve/dlna/dlna_test.go b/cmd/serve/dlna/dlna_test.go index 630058bc1..cca76d877 100644 --- a/cmd/serve/dlna/dlna_test.go +++ b/cmd/serve/dlna/dlna_test.go @@ -7,7 +7,6 @@ import ( "html" "io/ioutil" "net/http" - "net/url" "os" "strings" "testing" @@ -26,7 +25,7 @@ import ( var ( dlnaServer *server - testURL string + baseURL string ) const ( @@ -38,7 +37,7 @@ func startServer(t *testing.T, f fs.Fs) { opt.ListenAddr = testBindAddress dlnaServer = newServer(f, &opt) assert.NoError(t, dlnaServer.Serve()) - testURL = "http://" + dlnaServer.HTTPConn.Addr().String() + "/" + baseURL = "http://" + dlnaServer.HTTPConn.Addr().String() } func TestInit(t *testing.T) { @@ -54,7 +53,7 @@ func TestInit(t *testing.T) { // Make sure that it serves rootDesc.xml (SCPD in uPnP parlance). func TestRootSCPD(t *testing.T) { - req, err := http.NewRequest("GET", testURL+"rootDesc.xml", nil) + req, err := http.NewRequest("GET", baseURL+rootDescPath, nil) require.NoError(t, err) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) @@ -73,9 +72,7 @@ func TestRootSCPD(t *testing.T) { // Make sure that it serves content from the remote. func TestServeContent(t *testing.T) { - itemPath := "/small_jpeg.jpg" - pathQuery := url.QueryEscape(itemPath) - req, err := http.NewRequest("GET", testURL+"res?path="+pathQuery, nil) + req, err := http.NewRequest("GET", baseURL+resPath+"video.mp4", nil) require.NoError(t, err) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) @@ -85,7 +82,7 @@ func TestServeContent(t *testing.T) { assert.NoError(t, err) // Now compare the contents with the golden file. - node, err := dlnaServer.vfs.Stat(itemPath) + node, err := dlnaServer.vfs.Stat("/video.mp4") assert.NoError(t, err) goldenFile := node.(*vfs.File) goldenReader, err := goldenFile.Open(os.O_RDONLY) @@ -100,7 +97,7 @@ func TestServeContent(t *testing.T) { // Check that ContentDirectory#Browse returns appropriate metadata on the root container. func TestContentDirectoryBrowseMetadata(t *testing.T) { // Sample from: https://github.com/rclone/rclone/issues/3253#issuecomment-524317469 - req, err := http.NewRequest("POST", testURL+"ctl", strings.NewReader(` + req, err := http.NewRequest("POST", baseURL+serviceControlURL, strings.NewReader(` @@ -126,7 +123,7 @@ func TestContentDirectoryBrowseMetadata(t *testing.T) { require.Contains(t, string(body), html.EscapeString("") } + +// Check that ContentDirectory#Browse returns the expected items. +func TestContentDirectoryBrowseDirectChildren(t *testing.T) { + // First the root... + req, err := http.NewRequest("POST", baseURL+serviceControlURL, strings.NewReader(` + + + + + 0 + BrowseDirectChildren + * + 0 + 0 + + + +`)) + require.NoError(t, err) + req.Header.Set("SOAPACTION", `"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"`) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + // expect video.mp4, video.srt, video.en.srt URLs to be in the DIDL + require.Contains(t, string(body), "/r/video.mp4") + require.Contains(t, string(body), "/r/video.srt") + require.Contains(t, string(body), "/r/video.en.srt") + + // Then a subdirectory + req, err = http.NewRequest("POST", baseURL+serviceControlURL, strings.NewReader(` + + + + + %2Fsubdir + BrowseDirectChildren + * + 0 + 0 + + + +`)) + require.NoError(t, err) + req.Header.Set("SOAPACTION", `"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"`) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, err = ioutil.ReadAll(resp.Body) + require.NoError(t, err) + // expect video.mp4, video.srt, URLs to be in the DIDL + require.Contains(t, string(body), "/r/subdir/video.mp4") + require.Contains(t, string(body), "/r/subdir/video.srt") +} diff --git a/cmd/serve/dlna/dlna_util.go b/cmd/serve/dlna/dlna_util.go index c46654d48..e8a0b6aa3 100644 --- a/cmd/serve/dlna/dlna_util.go +++ b/cmd/serve/dlna/dlna_util.go @@ -218,3 +218,14 @@ func serveError(what interface{}, w http.ResponseWriter, text string, err error) fs.Errorf(what, "%s: %v", text, err) http.Error(w, text+".", http.StatusInternalServerError) } + +// Splits a path into (root, ext) such that root + ext == path, and ext is empty +// or begins with a period. Extended version of path.Ext(). +func splitExt(path string) (string, string) { + for i := len(path) - 1; i >= 0 && path[i] != '/'; i-- { + if path[i] == '.' { + return path[:i], path[i:] + } + } + return path, "" +} diff --git a/cmd/serve/dlna/testdata/files/subdir/video.mp4 b/cmd/serve/dlna/testdata/files/subdir/video.mp4 new file mode 100644 index 000000000..7f6eeace9 Binary files /dev/null and b/cmd/serve/dlna/testdata/files/subdir/video.mp4 differ diff --git a/cmd/serve/dlna/testdata/files/subdir/video.srt b/cmd/serve/dlna/testdata/files/subdir/video.srt new file mode 100644 index 000000000..1663efe84 --- /dev/null +++ b/cmd/serve/dlna/testdata/files/subdir/video.srt @@ -0,0 +1,3 @@ +1 +00:00:00,000 --> 00:02:00,000 +Test diff --git a/cmd/serve/dlna/testdata/files/video.en.srt b/cmd/serve/dlna/testdata/files/video.en.srt new file mode 100644 index 000000000..1663efe84 --- /dev/null +++ b/cmd/serve/dlna/testdata/files/video.en.srt @@ -0,0 +1,3 @@ +1 +00:00:00,000 --> 00:02:00,000 +Test diff --git a/cmd/serve/dlna/testdata/files/video.mp4 b/cmd/serve/dlna/testdata/files/video.mp4 new file mode 100644 index 000000000..7f6eeace9 Binary files /dev/null and b/cmd/serve/dlna/testdata/files/video.mp4 differ diff --git a/cmd/serve/dlna/testdata/files/video.srt b/cmd/serve/dlna/testdata/files/video.srt new file mode 100644 index 000000000..1663efe84 --- /dev/null +++ b/cmd/serve/dlna/testdata/files/video.srt @@ -0,0 +1,3 @@ +1 +00:00:00,000 --> 00:02:00,000 +Test