From ae3400a9b16cf5873d4c0a59c5894bf37fc8a923 Mon Sep 17 00:00:00 2001
From: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
Date: Tue, 22 Jun 2021 18:56:16 +1000
Subject: [PATCH] DLNA refactor and support browse folder objects (#1517)
---
pkg/dlna/cds.go | 339 +++++++++++++++++++++++--------------------
pkg/dlna/cds_test.go | 28 ++++
2 files changed, 210 insertions(+), 157 deletions(-)
diff --git a/pkg/dlna/cds.go b/pkg/dlna/cds.go
index dc45bb336..e99f63be8 100644
--- a/pkg/dlna/cds.go
+++ b/pkg/dlna/cds.go
@@ -180,7 +180,7 @@ func (me *contentDirectoryService) Handle(action string, argsXML []byte, r *http
case "Browse":
var browse browse
if err := xml.Unmarshal([]byte(argsXML), &browse); err != nil {
- return nil, err
+ return nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, "cannot unmarshal browse argument: %s", err.Error())
}
obj, err := me.objectFromID(browse.ObjectID)
@@ -190,163 +190,9 @@ func (me *contentDirectoryService) Handle(action string, argsXML []byte, r *http
switch browse.BrowseFlag {
case "BrowseDirectChildren":
- // Read folder and return children
- // TODO: check if obj == 0 and return root objects
- // TODO: check if special path and return files
-
- var objs []interface{}
-
- if obj.IsRoot() {
- objs = getRootObjects()
- }
-
- paths := strings.Split(obj.Path, "/")
-
- // All videos
- if obj.Path == "all" {
- objs = me.getAllScenes(host)
- }
-
- if strings.HasPrefix(obj.Path, "all/") {
- page := getPageFromID(paths)
- if page != nil {
- objs = me.getPageVideos(&models.SceneFilterType{}, "all", *page, host)
- }
- }
-
- // Saved searches
- // if obj.Path == "saved-searches" {
- // var savedPlaylists []models.Playlist
- // db, _ := models.GetDB()
- // db.Where("is_deo_enabled = ?", true).Order("ordering asc").Find(&savedPlaylists)
- // db.Close()
-
- // for _, playlist := range savedPlaylists {
- // objs = append(objs, upnpav.Container{Object: upnpav.Object{
- // ID: "saved-searches/" + strconv.Itoa(int(playlist.ID)),
- // Restricted: 1,
- // ParentID: "saved-searches",
- // Class: "object.container.storageFolder",
- // Title: playlist.Name,
- // }})
- // }
- // }
-
- // if strings.HasPrefix(obj.Path, "saved-searches/") {
- // id := strings.Split(obj.Path, "/")
-
- // var savedPlaylist models.Playlist
- // db, _ := models.GetDB()
- // db.Where("id = ?", id[1]).First(&savedPlaylist)
- // db.Close()
-
- // var r models.RequestSceneList
- // if err := json.Unmarshal([]byte(savedPlaylist.SearchParams), &r); err == nil {
- // r.IsAccessible = optional.NewBool(true)
- // r.IsAvailable = optional.NewBool(true)
- // data := models.QueryScenesFull(r)
-
- // for i := range data.Scenes {
- // objs = append(objs, me.sceneToContainer(data.Scenes[i], "sites/"+id[1], host))
- // }
- // }
- // }
-
- // Studios
- if obj.Path == "studios" {
- objs = me.getStudios()
- }
-
- if strings.HasPrefix(obj.Path, "studios/") {
- objs = me.getStudioScenes(childPath(paths), host)
- }
-
- // Tags
- if obj.Path == "tags" {
- objs = me.getTags()
- }
-
- if strings.HasPrefix(obj.Path, "tags/") {
- objs = me.getTagScenes(childPath(paths), host)
- }
-
- // Performers
- if obj.Path == "performers" {
- objs = me.getPerformers()
- }
-
- if strings.HasPrefix(obj.Path, "performers/") {
- objs = me.getPerformerScenes(childPath(paths), host)
- }
-
- // Movies
- if obj.Path == "movies" {
- objs = me.getMovies()
- }
-
- if strings.HasPrefix(obj.Path, "movies/") {
- objs = me.getMovieScenes(childPath(paths), host)
- }
-
- // Rating
- if obj.Path == "rating" {
- objs = me.getRating()
- }
-
- if strings.HasPrefix(obj.Path, "rating/") {
- objs = me.getRatingScenes(childPath(paths), host)
- }
-
- result, err := xml.Marshal(objs)
- if err != nil {
- return nil, err
- }
-
- return map[string]string{
- "TotalMatches": fmt.Sprint(len(objs)),
- "NumberReturned": fmt.Sprint(len(objs)),
- "Result": didl_lite(string(result)),
- "UpdateID": me.updateIDString(),
- }, nil
+ return me.handleBrowseDirectChildren(obj, host)
case "BrowseMetadata":
- var scene *models.Scene
-
- if err := me.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
- sceneID, err := strconv.Atoi(obj.Path)
- if err != nil {
- return err
- }
-
- scene, err = r.Scene().Find(sceneID)
- if err != nil {
- return err
- }
-
- return nil
- }); err != nil {
- logger.Error(err.Error())
- }
-
- if scene != nil {
- upnpObject := sceneToContainer(scene, "-1", host)
- result, err := xml.Marshal(upnpObject)
- if err != nil {
- return nil, err
- }
-
- // http://upnp.org/specs/av/UPnP-av-ContentDirectory-v1-Service.pdf
- // maximum update ID is 2**32, then rolls back to 0
- const maxUpdateID int64 = 1 << 32
- updateID := scene.UpdatedAt.Timestamp.Unix() % maxUpdateID
- return map[string]string{
- "Result": didl_lite(string(result)),
- "NumberReturned": "1",
- "TotalMatches": "1",
- "UpdateID": fmt.Sprint(updateID),
- }, nil
- } else {
- return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, "scene not found")
- }
+ return me.handleBrowseMetadata(obj, host)
default:
return nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, "unhandled browse flag: %v", browse.BrowseFlag)
}
@@ -373,6 +219,179 @@ func (me *contentDirectoryService) Handle(action string, argsXML []byte, r *http
}
}
+func (me *contentDirectoryService) handleBrowseDirectChildren(obj object, host string) (map[string]string, error) {
+ // Read folder and return children
+ // TODO: check if obj == 0 and return root objects
+ // TODO: check if special path and return files
+
+ var objs []interface{}
+
+ if obj.IsRoot() {
+ objs = getRootObjects()
+ }
+
+ paths := strings.Split(obj.Path, "/")
+
+ // All videos
+ if obj.Path == "all" {
+ objs = me.getAllScenes(host)
+ }
+
+ if strings.HasPrefix(obj.Path, "all/") {
+ page := getPageFromID(paths)
+ if page != nil {
+ objs = me.getPageVideos(&models.SceneFilterType{}, "all", *page, host)
+ }
+ }
+
+ // Saved searches
+ // if obj.Path == "saved-searches" {
+ // var savedPlaylists []models.Playlist
+ // db, _ := models.GetDB()
+ // db.Where("is_deo_enabled = ?", true).Order("ordering asc").Find(&savedPlaylists)
+ // db.Close()
+
+ // for _, playlist := range savedPlaylists {
+ // objs = append(objs, upnpav.Container{Object: upnpav.Object{
+ // ID: "saved-searches/" + strconv.Itoa(int(playlist.ID)),
+ // Restricted: 1,
+ // ParentID: "saved-searches",
+ // Class: "object.container.storageFolder",
+ // Title: playlist.Name,
+ // }})
+ // }
+ // }
+
+ // if strings.HasPrefix(obj.Path, "saved-searches/") {
+ // id := strings.Split(obj.Path, "/")
+
+ // var savedPlaylist models.Playlist
+ // db, _ := models.GetDB()
+ // db.Where("id = ?", id[1]).First(&savedPlaylist)
+ // db.Close()
+
+ // var r models.RequestSceneList
+ // if err := json.Unmarshal([]byte(savedPlaylist.SearchParams), &r); err == nil {
+ // r.IsAccessible = optional.NewBool(true)
+ // r.IsAvailable = optional.NewBool(true)
+ // data := models.QueryScenesFull(r)
+
+ // for i := range data.Scenes {
+ // objs = append(objs, me.sceneToContainer(data.Scenes[i], "sites/"+id[1], host))
+ // }
+ // }
+ // }
+
+ // Studios
+ if obj.Path == "studios" {
+ objs = me.getStudios()
+ }
+
+ if strings.HasPrefix(obj.Path, "studios/") {
+ objs = me.getStudioScenes(childPath(paths), host)
+ }
+
+ // Tags
+ if obj.Path == "tags" {
+ objs = me.getTags()
+ }
+
+ if strings.HasPrefix(obj.Path, "tags/") {
+ objs = me.getTagScenes(childPath(paths), host)
+ }
+
+ // Performers
+ if obj.Path == "performers" {
+ objs = me.getPerformers()
+ }
+
+ if strings.HasPrefix(obj.Path, "performers/") {
+ objs = me.getPerformerScenes(childPath(paths), host)
+ }
+
+ // Movies
+ if obj.Path == "movies" {
+ objs = me.getMovies()
+ }
+
+ if strings.HasPrefix(obj.Path, "movies/") {
+ objs = me.getMovieScenes(childPath(paths), host)
+ }
+
+ // Rating
+ if obj.Path == "rating" {
+ objs = me.getRating()
+ }
+
+ if strings.HasPrefix(obj.Path, "rating/") {
+ objs = me.getRatingScenes(childPath(paths), host)
+ }
+
+ return makeBrowseResult(objs, me.updateIDString())
+}
+
+func (me *contentDirectoryService) handleBrowseMetadata(obj object, host string) (map[string]string, error) {
+ var objs []interface{}
+ var updateID string
+
+ // if numeric, then must be scene, otherwise handle as if path
+ sceneID, err := strconv.Atoi(obj.Path)
+ if err != nil {
+ // #1465 - handle root object
+ if obj.IsRoot() {
+ objs = getRootObject()
+ } else {
+ // HACK: just create a fake storage folder to return. The name won't
+ // be correct, but hopefully the names returned from handleBrowseDirectChildren
+ // will be used instead.
+ objs = []interface{}{makeStorageFolder(obj.ID(), obj.ID(), obj.ParentID())}
+ }
+
+ updateID = me.updateIDString()
+ } else {
+ var scene *models.Scene
+
+ if err := me.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
+ scene, err = r.Scene().Find(sceneID)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ }); err != nil {
+ logger.Error(err.Error())
+ }
+
+ if scene != nil {
+ upnpObject := sceneToContainer(scene, "-1", host)
+ objs = []interface{}{upnpObject}
+
+ // http://upnp.org/specs/av/UPnP-av-ContentDirectory-v1-Service.pdf
+ // maximum update ID is 2**32, then rolls back to 0
+ const maxUpdateID int64 = 1 << 32
+ updateID = fmt.Sprint(scene.UpdatedAt.Timestamp.Unix() % maxUpdateID)
+ } else {
+ return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, "scene not found")
+ }
+ }
+
+ return makeBrowseResult(objs, updateID)
+}
+
+func makeBrowseResult(objs []interface{}, updateID string) (map[string]string, error) {
+ result, err := xml.Marshal(objs)
+ if err != nil {
+ return nil, upnp.Errorf(upnp.ActionFailedErrorCode, "could not marshal objects: %s", err.Error())
+ }
+
+ return map[string]string{
+ "TotalMatches": fmt.Sprint(len(objs)),
+ "NumberReturned": fmt.Sprint(len(objs)),
+ "Result": didl_lite(string(result)),
+ "UpdateID": updateID,
+ }, nil
+}
+
func makeStorageFolder(id, title, parentID string) upnpav.Container {
defaultChildCount := 1
return upnpav.Container{
@@ -387,6 +406,12 @@ func makeStorageFolder(id, title, parentID string) upnpav.Container {
}
}
+func getRootObject() []interface{} {
+ const rootID = "0"
+
+ return []interface{}{makeStorageFolder(rootID, "stash", "-1")}
+}
+
func getRootObjects() []interface{} {
const rootID = "0"
diff --git a/pkg/dlna/cds_test.go b/pkg/dlna/cds_test.go
index 5280b0128..b52ca3b88 100644
--- a/pkg/dlna/cds_test.go
+++ b/pkg/dlna/cds_test.go
@@ -27,8 +27,12 @@ package dlna
// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import (
+ "net/http"
"strings"
"testing"
+
+ "github.com/stashapp/stash/pkg/models/mocks"
+ "github.com/stretchr/testify/assert"
)
func TestEscapeObjectID(t *testing.T) {
@@ -52,3 +56,27 @@ func TestRootParentObjectID(t *testing.T) {
t.FailNow()
}
}
+
+func testHandleBrowse(argsXML string) (map[string]string, error) {
+ cds := contentDirectoryService{
+ Server: &Server{},
+ txnManager: mocks.NewTransactionManager(),
+ }
+
+ r := &http.Request{}
+ return cds.Handle("Browse", []byte(argsXML), r)
+}
+
+func TestBrowseMetadataRoot(t *testing.T) {
+ argsXML := `0BrowseMetadata*00`
+ _, err := testHandleBrowse(argsXML)
+
+ assert.Nil(t, err)
+}
+
+func TestBrowseMetadataTags(t *testing.T) {
+ argsXML := `tagsBrowseMetadata*00`
+ _, err := testHandleBrowse(argsXML)
+
+ assert.Nil(t, err)
+}