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) +}