From 8f222120a84a10ad823ca5a54f6d406fae53b47d Mon Sep 17 00:00:00 2001 From: mugu <94156510@qq.com> Date: Fri, 4 Jul 2025 22:59:47 +0800 Subject: [PATCH] feat: dlna server support --- cmd/server.go | 8 + go.mod | 6 +- go.sum | 12 + internal/conf/config.go | 18 + server/dlna.go | 24 + server/dlna/LICENSE.anacrolix | 27 + server/dlna/cds.go | 370 +++++++++++++ server/dlna/cms.go | 28 + server/dlna/data/assets_generate.go | 25 + server/dlna/data/assets_vfsdata.go | 257 +++++++++ server/dlna/data/data.go | 42 ++ server/dlna/data/static/ConnectionManager.xml | 182 +++++++ server/dlna/data/static/ContentDirectory.xml | 504 ++++++++++++++++++ .../static/X_MS_MediaReceiverRegistrar.xml | 88 +++ server/dlna/data/static/openlist-120x120.png | Bin 0 -> 1612 bytes server/dlna/data/static/openlist-48x48.png | Bin 0 -> 815 bytes server/dlna/data/static/rootDesc.xml.tmpl | 66 +++ server/dlna/dlna.go | 429 +++++++++++++++ server/dlna/dlna_util.go | 206 +++++++ server/dlna/dlnaflags/dlnaflags.go | 16 + server/dlna/mrrs.go | 29 + server/dlna/upnpav/upnpav.go | 64 +++ 22 files changed, 2400 insertions(+), 1 deletion(-) create mode 100644 server/dlna.go create mode 100644 server/dlna/LICENSE.anacrolix create mode 100644 server/dlna/cds.go create mode 100644 server/dlna/cms.go create mode 100644 server/dlna/data/assets_generate.go create mode 100644 server/dlna/data/assets_vfsdata.go create mode 100644 server/dlna/data/data.go create mode 100644 server/dlna/data/static/ConnectionManager.xml create mode 100644 server/dlna/data/static/ContentDirectory.xml create mode 100644 server/dlna/data/static/X_MS_MediaReceiverRegistrar.xml create mode 100644 server/dlna/data/static/openlist-120x120.png create mode 100644 server/dlna/data/static/openlist-48x48.png create mode 100644 server/dlna/data/static/rootDesc.xml.tmpl create mode 100644 server/dlna/dlna.go create mode 100644 server/dlna/dlna_util.go create mode 100644 server/dlna/dlnaflags/dlnaflags.go create mode 100644 server/dlna/mrrs.go create mode 100644 server/dlna/upnpav/upnpav.go diff --git a/cmd/server.go b/cmd/server.go index 3bba2e1ba..7e77409ef 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -158,6 +158,14 @@ the address is defined in config file`, }() } } + if conf.Conf.DLNA.Listen != "" && conf.Conf.DLNA.Enable { + go func() { + err := server.StartDlnaServer() + if err != nil { + utils.Log.Fatalf("failed to start dlna server: %s", err.Error()) + } + }() + } // Wait for interrupt signal to gracefully shutdown the server with // a timeout of 1 second. quit := make(chan os.Signal, 1) diff --git a/go.mod b/go.mod index 9ba146e5b..4c77e52be 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ require ( github.com/ProtonMail/go-crypto v1.3.0 github.com/SheltonZhu/115driver v1.1.0 github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible + github.com/anacrolix/dms v1.7.1 + github.com/anacrolix/log v0.16.0 github.com/avast/retry-go v3.0.0+incompatible github.com/aws/aws-sdk-go v1.55.7 github.com/blevesearch/bleve/v2 v2.5.2 @@ -82,6 +84,7 @@ require ( cloud.google.com/go/compute/metadata v0.7.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect + github.com/anacrolix/generics v0.0.3 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect @@ -92,9 +95,10 @@ require ( github.com/minio/xxml v0.0.3 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/otiai10/mint v1.6.3 // indirect - gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect github.com/shirou/gopsutil/v4 v4.25.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect + gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect ) require ( diff --git a/go.sum b/go.sum index d00764fd2..0f942df60 100644 --- a/go.sum +++ b/go.sum @@ -82,6 +82,14 @@ github.com/aead/ecdh v0.2.0 h1:pYop54xVaq/CEREFEcukHRZfTdjiWvYIsZDXXrBapQQ= github.com/aead/ecdh v0.2.0/go.mod h1:a9HHtXuSo8J1Js1MwLQx2mBhkXMT6YwUmVVEY4tTB8U= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= +github.com/anacrolix/dms v1.7.1 h1:XVOpT3eoO5Ds34B1X+TE3R2ApfqGGeqotEoCVNP8BaI= +github.com/anacrolix/dms v1.7.1/go.mod h1:excFJW5MKBhn5yt5ZMyeE9iFVqnO6tEGQl7YG/2tUoQ= +github.com/anacrolix/generics v0.0.1 h1:4WVhK6iLb3UAAAQP6I3uYlMOHcp9FqJC9j4n81Wv9Ks= +github.com/anacrolix/generics v0.0.1/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8= +github.com/anacrolix/generics v0.0.3 h1:wMkQgQzq0obSy1tMkxDu7Ife7PsegOBWHDRaSW31EnM= +github.com/anacrolix/generics v0.0.3/go.mod h1:MN3ve08Z3zSV/rTuX/ouI4lNdlfTxgdafQJiLzyNRB8= +github.com/anacrolix/log v0.16.0 h1:DSuyb5kAJwl3Y0X1TRcStVrTS9ST9b0BHW+7neE4Xho= +github.com/anacrolix/log v0.16.0/go.mod h1:m0poRtlr41mriZlXBQ9SOVZ8yZBkLjOkDhd5Li5pITA= github.com/andreburgaud/crypt2go v1.8.0 h1:J73vGTb1P6XL69SSuumbKs0DWn3ulbl9L92ZXBjw6pc= github.com/andreburgaud/crypt2go v1.8.0/go.mod h1:L5nfShQ91W78hOWhUH2tlGRPO+POAPJAF5fKOLB9SXg= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= @@ -310,6 +318,8 @@ github.com/foxxorcat/mopan-sdk-go v0.1.6 h1:6J37oI4wMZLj8EPgSCcSTTIbnI5D6RCNW/sr github.com/foxxorcat/mopan-sdk-go v0.1.6/go.mod h1:UaY6D88yBXWGrcu/PcyLWyL4lzrk5pSxSABPHftOvxs= github.com/foxxorcat/weiyun-sdk-go v0.1.3 h1:I5c5nfGErhq9DBumyjCVCggRA74jhgriMqRRFu5jeeY= github.com/foxxorcat/weiyun-sdk-go v0.1.3/go.mod h1:TPxzN0d2PahweUEHlOBWlwZSA+rELSUlGYMWgXRn9ps= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= @@ -849,6 +859,8 @@ golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= +golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= diff --git a/internal/conf/config.go b/internal/conf/config.go index e34f3f99f..7ab1b0936 100644 --- a/internal/conf/config.go +++ b/internal/conf/config.go @@ -119,6 +119,15 @@ type SFTP struct { Listen string `json:"listen" env:"LISTEN"` } +type DLNA struct { + Enable bool `json:"enable" env:"ENABLE"` + Listen string `json:"listen" env:"LISTEN"` + FriendlyName string `json:"friendly_name" env:"FRIEDNLY_NAME"` + InterfaceNames []string `json:"interface_names" env:"INTERFACE_NAMES"` + AnnounceInterval int `json:"announce_interval" env:"ANNOUNCE_INTERVAL"` + RootDir string `json:"root_dir" env:"ROOT_DIR"` +} + type Config struct { Force bool `json:"force" env:"FORCE"` SiteURL string `json:"site_url" env:"SITE_URL"` @@ -141,6 +150,7 @@ type Config struct { S3 S3 `json:"s3" envPrefix:"S3_"` FTP FTP `json:"ftp" envPrefix:"FTP_"` SFTP SFTP `json:"sftp" envPrefix:"SFTP_"` + DLNA DLNA `json:"dlna" envPrefix:"DLNA_"` LastLaunchedVersion string `json:"last_launched_version"` } @@ -252,6 +262,14 @@ func DefaultConfig(dataDir string) *Config { Enable: false, Listen: ":5222", }, + DLNA: DLNA{ + Enable: false, + Listen: ":5200", + FriendlyName: "", + InterfaceNames: []string{}, + AnnounceInterval: 12, + RootDir: "/", + }, LastLaunchedVersion: "", } } diff --git a/server/dlna.go b/server/dlna.go new file mode 100644 index 000000000..e051accc6 --- /dev/null +++ b/server/dlna.go @@ -0,0 +1,24 @@ +package server + +import ( + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/server/dlna" + "github.com/OpenListTeam/OpenList/v4/server/dlna/dlnaflags" +) + +func StartDlnaServer() error { + err := dlna.Run(&dlnaflags.Options{ + ListenAddr: conf.Conf.DLNA.Listen, + FriendlyName: conf.Conf.DLNA.FriendlyName, + LogTrace: false, + InterfaceNames: conf.Conf.DLNA.InterfaceNames, + AnnounceInterval: time.Duration(conf.Conf.DLNA.AnnounceInterval) * time.Minute, + RootDir: conf.Conf.DLNA.RootDir, + }) + if err != nil { + return err + } + return nil +} diff --git a/server/dlna/LICENSE.anacrolix b/server/dlna/LICENSE.anacrolix new file mode 100644 index 000000000..836e8f7c0 --- /dev/null +++ b/server/dlna/LICENSE.anacrolix @@ -0,0 +1,27 @@ +This directory contains code derived from https://github.com/anacrolix/dms +which is under the following license. + +Copyright (c) 2012, Matt Joiner . +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/server/dlna/cds.go b/server/dlna/cds.go new file mode 100644 index 000000000..233c9751a --- /dev/null +++ b/server/dlna/cds.go @@ -0,0 +1,370 @@ +//go:build go1.21 + +package dlna + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "mime" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/fs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/server/dlna/upnpav" + "github.com/anacrolix/dms/dlna" + "github.com/anacrolix/dms/upnp" + log "github.com/sirupsen/logrus" +) + +type contentDirectoryService struct { + *server + upnp.Eventing +} + +func (cds *contentDirectoryService) updateIDString() string { + return fmt.Sprintf("%d", uint32(os.Getpid())) +} + +var mediaMimeTypeRegexp = regexp.MustCompile("^(video|audio|image)/") + +// MimeTypeFromName returns a guess at the mime type from the name +func MimeTypeFromName(remote string) (mimeType string) { + mimeType = mime.TypeByExtension(path.Ext(remote)) + if !strings.ContainsRune(mimeType, '/') { + mimeType = "application/octet-stream" + } + return mimeType +} + +// Turns the given entry and DMS host into a UPnP object. A nil object is +// returned if the entry is not of interest. +func (cds *contentDirectoryService) cdsObjectToUpnpavObject(cdsObject object, fileInfo model.Obj, resources []model.Obj, host string) (ret interface{}, err error) { + obj := upnpav.Object{ + ID: cdsObject.ID(), + Restricted: 1, + ParentID: cdsObject.ParentID(), + } + + if fileInfo.IsDir() { + defaultChildCount := 1 + obj.Class = "object.container.storageFolder" + obj.Title = fileInfo.GetName() + return upnpav.Container{ + Object: obj, + ChildCount: &defaultChildCount, + }, nil + } + + mimeType := MimeTypeFromName(fileInfo.GetName()) + + mediaType := mediaMimeTypeRegexp.FindStringSubmatch(mimeType) + if mediaType == nil { + return + } + + obj.Class = "object.item." + mediaType[1] + "Item" + obj.Title = fileInfo.GetName() + obj.Date = upnpav.Timestamp{Time: fileInfo.ModTime()} + + item := upnpav.Item{ + Object: obj, + Res: make([]upnpav.Resource, 0, 1), + } + + item.Res = append(item.Res, upnpav.Resource{ + URL: (&url.URL{ + Scheme: "http", + Host: host, + Path: path.Join(resPath, cdsObject.Path), + }).String(), + ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mimeType, dlna.ContentFeatures{ + SupportRange: true, + }.String()), + Size: uint64(fileInfo.GetSize()), + }) + + for _, resource := range resources { + subtitleURL := (&url.URL{ + Scheme: "http", + Host: host, + Path: path.Join(resPath, resource.GetPath()), + }).String() + item.Res = append(item.Res, upnpav.Resource{ + URL: subtitleURL, + ProtocolInfo: fmt.Sprintf("http-get:*:%s:*", "text/srt"), + }) + } + + ret = item + return +} + +// Returns all the upnpav objects in a directory. +func (cds *contentDirectoryService) readContainer(o object, host string) (ret []interface{}, err error) { + node, err := fs.Get(context.Background(), cds.RootDir+o.Path, &fs.GetArgs{}) + if err != nil { + return + } + + if !node.IsDir() { + err = errors.New("not a directory") + return + } + + dirEntries, err := fs.List(context.Background(), cds.RootDir+o.Path, &fs.ListArgs{}) + if err != nil { + err = errors.New("failed to list directory") + return + } + + // Sort the directory entries by directories first then alphabetically by name + sort.Slice(dirEntries, func(i, j int) bool { + iNode, jNode := dirEntries[i], dirEntries[j] + iIsDir, jIsDir := iNode.IsDir(), jNode.IsDir() + if iIsDir && !jIsDir { + return true + } else if !iIsDir && jIsDir { + return false + } + return strings.ToLower(iNode.GetName()) < strings.ToLower(jNode.GetName()) + }) + + dirEntries, mediaResources := mediaWithResources(dirEntries) + for _, de := range dirEntries { + child := object{ + path.Join(o.Path, de.GetName()), + } + obj, err := cds.cdsObjectToUpnpavObject(child, de, mediaResources[de], host) + if err != nil { + log.Errorf("error with %s: %s", child.FilePath(), err) + continue + } + if obj == nil { + log.Debugf("unrecognized file type: %s", de) + continue + } + ret = append(ret, obj) + } + + return +} + +// Given a list of nodes, separate them into potential media items and any associated resources (external subtitles, +// for example.) +// +// The result is 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 []model.Obj) ([]model.Obj, map[model.Obj][]model.Obj) { + media, mediaResources := make([]model.Obj, 0), make(map[model.Obj][]model.Obj) + + // First, separate out the subtitles and media into maps, keyed by their lowercase base names. + mediaByName, subtitlesByName := make(map[string][]model.Obj), make(map[string]model.Obj) + for _, node := range nodes { + baseName, ext := splitExt(strings.ToLower(node.GetName())) + switch ext { + case ".srt", ".ass", ".ssa", ".sub", ".idx", ".sup", ".jss", ".txt", ".usf", ".cue", ".vtt", ".css": + // .idx should be with .sub, .css should be with vtt otherwise they should be culled, + // and their mimeTypes are not consistent, but anyway these negatives don't throw errors. + subtitlesByName[baseName] = node + default: + mediaByName[baseName] = append(mediaByName[baseName], node) + media = append(media, node) + } + } + + // Find the associated media file for each subtitle + for baseName, node := range subtitlesByName { + // Find a media file with the same basename (video.mp4 for video.srt) + mediaNodes, found := mediaByName[baseName] + if !found { + // Or basename of the basename (video.mp4 for video.en.srt) + baseName, _ = splitExt(baseName) + mediaNodes, found = mediaByName[baseName] + } + + // Just advise if no match found + if !found { + log.Infof("could not find associated media for subtitle: %s", node.GetName()) + continue + } + + // Associate with all potential media nodes + log.Debugf("associating subtitle: %s", node.GetName()) + for _, mediaNode := range mediaNodes { + mediaResources[mediaNode] = append(mediaResources[mediaNode], node) + } + } + + return media, mediaResources +} + +type browse struct { + ObjectID string + BrowseFlag string + Filter string + StartingIndex int + RequestedCount int +} + +// ContentDirectory object from ObjectID. +func (cds *contentDirectoryService) objectFromID(id string) (o object, err error) { + o.Path, err = url.QueryUnescape(id) + if err != nil { + return + } + if o.Path == "0" { + o.Path = "/" + } + o.Path = path.Clean(o.Path) + if !path.IsAbs(o.Path) { + err = fmt.Errorf("bad ObjectID %v", o.Path) + return + } + return +} + +var _OnLastHandleGetSearchCapabilitiesTime int64 = 0 + +func (cds *contentDirectoryService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) { + host := r.Host + + switch action { + case "GetSystemUpdateID": + return map[string]string{ + "Id": cds.updateIDString(), + }, nil + case "GetSortCapabilities": + return map[string]string{ + "SortCaps": "dc:title", + }, nil + case "Browse": + var browse browse + if err := xml.Unmarshal(argsXML, &browse); err != nil { + return nil, err + } + obj, err := cds.objectFromID(browse.ObjectID) + if err != nil { + return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, "%s", err.Error()) + } + switch browse.BrowseFlag { + case "BrowseDirectChildren": + var objs []interface{} + if _OnLastHandleGetSearchCapabilitiesTime == 0 || time.Now().UnixMilli()-_OnLastHandleGetSearchCapabilitiesTime >= 8000 { + var err error + objs, err = cds.readContainer(obj, host) + if err != nil { + return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, "%s", err.Error()) + } + } else { + log.Infof("Detected webOS TV starting disk scan, returned empty folder") + } + _OnLastHandleGetSearchCapabilitiesTime = 0 + + totalMatches := len(objs) + objs = objs[func() (low int) { + low = min(browse.StartingIndex, len(objs)) + return + }():] + if browse.RequestedCount != 0 && browse.RequestedCount < len(objs) { + objs = objs[:browse.RequestedCount] + } + result, err := xml.Marshal(objs) + if err != nil { + return nil, err + } + return map[string]string{ + "TotalMatches": fmt.Sprint(totalMatches), + "NumberReturned": fmt.Sprint(len(objs)), + "Result": didlLite(string(result)), + "UpdateID": cds.updateIDString(), + }, nil + case "BrowseMetadata": + node, err := fs.Get(context.Background(), obj.Path, &fs.GetArgs{}) + if err != nil { + return nil, err + } + // TODO: External subtitles won't appear in the metadata here, but probably should. + upnpObject, err := cds.cdsObjectToUpnpavObject(obj, node, []model.Obj{}, host) + if err != nil { + return nil, err + } + result, err := xml.Marshal(upnpObject) + if err != nil { + return nil, err + } + return map[string]string{ + "TotalMatches": "1", + "NumberReturned": "1", + "Result": didlLite(string(result)), + "UpdateID": cds.updateIDString(), + }, nil + default: + return nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, "unhandled browse flag: %v", browse.BrowseFlag) + } + case "GetSearchCapabilities": + _OnLastHandleGetSearchCapabilitiesTime = time.Now().UnixMilli() + return map[string]string{ + "SearchCaps": "", + }, nil + // Samsung Extensions + case "X_GetFeatureList": + return map[string]string{ + "FeatureList": ` + + + + + +`}, nil + case "X_SetBookmark": + // just ignore + return map[string]string{}, nil + default: + return nil, upnp.InvalidActionError + } +} + +// Represents a ContentDirectory object. +type object struct { + Path string // The cleaned, absolute path for the object relative to the server. +} + +// Returns the actual local filesystem path for the object. +func (o *object) FilePath() string { + return filepath.FromSlash(o.Path) +} + +// Returns the ObjectID for the object. This is used in various ContentDirectory actions. +func (o object) ID() string { + if !path.IsAbs(o.Path) { + log.Panicf("Relative object path: %s", o.Path) + } + if len(o.Path) == 1 { + return "0" + } + return url.QueryEscape(o.Path) +} + +func (o *object) IsRoot() bool { + return o.Path == "/" +} + +// Returns the object's parent ObjectID. Fortunately it can be deduced from the +// ObjectID (for now). +func (o object) ParentID() string { + if o.IsRoot() { + return "-1" + } + o.Path = path.Dir(o.Path) + return o.ID() +} diff --git a/server/dlna/cms.go b/server/dlna/cms.go new file mode 100644 index 000000000..eb2bbc9b0 --- /dev/null +++ b/server/dlna/cms.go @@ -0,0 +1,28 @@ +//go:build go1.21 + +package dlna + +import ( + "net/http" + + "github.com/anacrolix/dms/upnp" +) + +const defaultProtocolInfo = "http-get:*:video/mpeg:*,http-get:*:video/mp4:*,http-get:*:video/vnd.dlna.mpeg-tts:*,http-get:*:video/avi:*,http-get:*:video/x-matroska:*,http-get:*:video/x-ms-wmv:*,http-get:*:video/wtv:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-ms-wma*,http-get:*:audio/wav:*,http-get:*:audio/L16:*,http-get:*image/jpeg:*,http-get:*image/png:*,http-get:*image/gif:*,http-get:*image/tiff:*" + +type connectionManagerService struct { + *server + upnp.Eventing +} + +func (cms *connectionManagerService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) { + switch action { + case "GetProtocolInfo": + return map[string]string{ + "Source": defaultProtocolInfo, + "Sink": "", + }, nil + default: + return nil, upnp.InvalidActionError + } +} diff --git a/server/dlna/data/assets_generate.go b/server/dlna/data/assets_generate.go new file mode 100644 index 000000000..69230a297 --- /dev/null +++ b/server/dlna/data/assets_generate.go @@ -0,0 +1,25 @@ +//go:generate go run assets_generate.go +// The "go:generate" directive compiles static assets by running assets_generate.go +//go:build ignore +// +build ignore + +package main + +import ( + "log" + "net/http" + + "github.com/shurcooL/vfsgen" +) + +func main() { + var AssetDir http.FileSystem = http.Dir("./static") + err := vfsgen.Generate(AssetDir, vfsgen.Options{ + PackageName: "data", + BuildTags: "!dev", + VariableName: "Assets", + }) + if err != nil { + log.Fatalln(err) + } +} diff --git a/server/dlna/data/assets_vfsdata.go b/server/dlna/data/assets_vfsdata.go new file mode 100644 index 000000000..4b0bfa4c4 --- /dev/null +++ b/server/dlna/data/assets_vfsdata.go @@ -0,0 +1,257 @@ +// Code generated by vfsgen; DO NOT EDIT. + +//go:build !dev + +package data + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + pathpkg "path" + "time" +) + +// Assets statically implements the virtual filesystem provided to vfsgen. +var Assets = func() http.FileSystem { + fs := vfsgen۰FS{ + "/": &vfsgen۰DirInfo{ + name: "/", + modTime: time.Date(2025, 7, 4, 7, 22, 50, 369163600, time.UTC), + }, + "/ConnectionManager.xml": &vfsgen۰CompressedFileInfo{ + name: "ConnectionManager.xml", + modTime: time.Date(2024, 5, 9, 3, 12, 13, 0, time.UTC), + uncompressedSize: 5505, + + compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\xd4\x57\xcd\x6e\xdb\x30\x0c\x3e\x3b\x4f\x11\xf8\x9e\xb9\x05\x76\x18\x0a\xc5\x45\x97\xfe\x20\xd8\x8a\x06\x6d\x1a\x60\xa7\x42\x93\xd9\x54\xab\x4d\x19\x12\xdd\x9f\xb7\x1f\x62\xc7\xb5\xdd\x3a\x8b\xe3\xc8\x59\x7a\x93\x28\x91\xdf\x27\x52\x22\x29\x76\xfc\x12\x85\xfd\x27\xd0\x46\x2a\x1c\xba\x87\x5f\x0e\xdc\x3e\xa0\x50\x81\xc4\xf9\xd0\xbd\x9d\x9e\x0f\xbe\xb9\xc7\x7e\x8f\x19\x11\x07\xfd\x97\x28\x44\x33\x74\x13\x8d\x47\x46\x3c\x40\xc4\xcd\x20\x89\x31\x1e\x28\x3d\x3f\x32\xa0\x9f\xa4\x80\xc1\xe1\xe0\xc0\xf5\x7b\x0e\x33\x31\x88\x59\x66\xd6\xef\x39\x0e\x8b\xf8\x1f\xa5\xfd\x43\xe6\x65\x83\x54\x24\x51\x69\xff\x80\x79\xd9\xa0\xe7\x30\xaf\xaa\xc5\xb8\x20\xa9\xf0\xa7\x34\x94\x2a\x64\xd3\xc5\xd0\x61\xc8\x23\xf0\x2f\x80\x26\x5a\x91\x12\x2a\x1c\xe3\xbd\x62\x5e\x2a\x4d\xd7\xb9\x9e\x27\x11\x20\xe5\xca\x25\x51\x36\x5d\x9a\xb8\x51\x89\x16\x50\xd2\x74\x1c\x16\x48\x0d\x19\x94\x4a\x88\x79\xc5\x74\xb9\xae\x21\xe4\x04\xc1\x0d\x71\x82\x19\xd7\x92\xff\x0e\x73\x43\x55\x3a\xb5\x1b\x33\x32\x5e\x95\xcd\x0a\x72\x12\x1f\x6d\x50\x93\xf8\xd8\x92\x58\x31\x7d\x8b\x82\x57\x84\xe1\x63\x44\x26\x1a\x62\xae\xe1\x5c\xe9\x91\x42\xcc\xb8\xb5\x09\xcb\x35\x44\x8a\x60\x45\x70\x2b\x7e\x90\xd8\xd4\x0d\x27\x77\x27\xd7\x17\x77\xd3\x5f\x93\xb3\x3b\xbb\x61\x9a\x00\x94\x8e\x7b\xc9\x91\xcf\x41\x5b\xe5\x5b\x63\xdd\x2e\xe9\xf1\x69\x47\x7c\x17\x86\xb7\xa5\x7a\x9a\xc3\x5b\xe5\x58\xb2\xba\x2d\xc1\x26\x7e\xdc\xe0\xbd\x76\xe6\xc8\x93\xd9\x54\x73\x34\xb1\xd2\x64\x9b\xe8\x3b\xd3\xdb\x32\xbd\x16\xc6\x36\xc3\xa5\xc9\xce\x52\x5f\x11\xaa\x91\x8a\xe2\x10\x08\xda\x24\xbe\x7d\x7b\x92\x9b\x7a\xe1\x02\x68\x94\x68\x0d\x48\x65\x40\xb3\xad\x2b\x8c\x85\xbb\x50\xcf\x6b\xb7\x9e\x68\xd9\xa5\x7c\x92\x44\xbd\x07\xaf\xf6\xd3\x67\xbe\x26\x3d\x4f\x3b\xa2\xff\xb1\xe9\xd9\xb6\xf8\xed\xbc\xeb\xd9\x87\x6a\xbd\xb6\xed\x69\x47\xd2\x62\xdf\xb3\x50\x4c\x4c\x57\x3e\xcc\xad\xdb\xc9\xd0\xf9\x70\xb9\xc4\x96\x1f\xd6\xd4\xea\x34\x37\xc9\x4c\x19\xa4\x6f\x00\x83\xb3\x27\x40\x32\x43\xf7\x15\x8c\x5b\xca\xee\x75\xdf\xbd\x22\xaf\x07\x9c\xf8\xf4\x35\x06\xdf\x90\x96\x38\x67\xde\x9b\x20\x25\x65\xde\x1f\x65\x03\xdc\x0f\x7f\xb9\x5d\xa0\xae\x2b\xe9\x16\x91\x51\x95\x81\xff\x79\x31\x1a\xe2\x3b\x8c\x87\xa1\x7a\x86\x60\xc6\xc3\x04\xca\xa5\xb6\x24\xf6\xaf\x7e\x30\xaf\x22\xa8\xd9\x33\x52\x48\x80\x74\xae\x74\xc4\xe9\x52\x9a\x88\x93\x78\x58\xaf\x36\x46\x93\xdc\xdf\x4b\x21\x01\xe9\x3b\xc7\xe0\x59\x06\xd4\x40\xed\x16\x35\x84\xa9\x83\x46\x0f\x1c\x11\xc2\x26\x2a\x8f\xa8\x9e\xb1\x66\x63\x55\x54\xbc\x0f\xcb\xa1\xf9\x58\x07\x76\x72\x37\xea\x52\xa5\x8d\x4b\x31\xc6\x78\x91\xc4\xd6\xb9\xfd\x2a\xa1\xfa\x7d\x9d\x7a\x7d\x07\x69\xa0\x41\xc4\x2b\x35\xb4\xc0\x96\x5f\x3b\xc1\x5d\xd5\xc7\x75\x0e\xfc\xbe\xb5\xdd\x04\x90\x79\x35\xc5\x86\x79\x46\xc4\x81\xff\x37\x00\x00\xff\xff\x2a\x62\x9d\xe1\x81\x15\x00\x00"), + }, + "/ContentDirectory.xml": &vfsgen۰CompressedFileInfo{ + name: "ContentDirectory.xml", + modTime: time.Date(2024, 5, 9, 3, 12, 13, 0, time.UTC), + uncompressedSize: 14527, + + compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\xec\x5a\x5f\x6f\xe2\x38\x10\x7f\xa6\x9f\xa2\xe2\xbd\x97\xad\x74\x4f\x2b\x97\xd5\x2e\xa5\x15\x52\xff\xa0\xc0\x56\x7b\x4f\xc8\x4d\xa6\xe0\xdd\xc4\xce\xd9\x93\x16\xbe\xfd\x29\x09\x81\x04\x92\x02\x89\x9d\x4d\xf7\x78\xcb\x3f\xff\xe6\x37\x93\xf1\x8c\x3d\x1e\xf2\x65\xe1\x7b\xe7\xaf\x20\x15\x13\xfc\xaa\x7b\xf9\xd7\xa7\xee\x97\xde\x19\x51\x4e\xe0\x9e\x2f\x7c\x8f\xab\xab\x6e\x28\xf9\x67\xe5\xcc\xc1\xa7\xea\x22\x0c\x78\x70\x21\xe4\xec\xb3\x02\xf9\xca\x1c\xb8\xb8\xbc\xf8\xd4\xed\x9d\x75\x88\x0a\xc0\x79\x4a\x50\x7a\x67\x9d\x0e\xf1\xe9\x4f\x21\x7b\x97\xc4\x4a\x2e\xe2\x47\x8c\x0b\xd9\xfb\x44\xac\xe4\xe2\xac\x43\xac\xfc\x28\x42\x1d\x64\x82\xdf\x31\x85\xf1\x80\xe4\x36\xba\xec\x10\x4e\x7d\xe8\xdd\x02\x8e\x81\x4a\x67\xde\xa7\x01\x7d\x66\x1e\x43\x06\x8a\x58\xf1\xbb\xf8\x2b\x2a\x67\xa1\x0f\x1c\x53\x88\xcc\xa3\xe4\x76\x05\xb4\x46\xc9\x8e\xee\x74\x88\xcb\x24\x24\x42\x45\x88\xc4\xda\xdc\xae\xde\x4b\xf0\x28\x82\x3b\x46\x8a\xf0\x44\x25\xa3\xcf\x5e\x06\x2c\x43\xa9\xf0\xc3\x84\x90\x95\x63\xb4\xb9\x5d\xab\x6d\x6d\xf4\x2e\x36\x81\x90\x58\xdb\x00\x09\x86\x16\xf5\x77\xe8\x98\x56\x7e\xb0\x40\xe0\x91\xcf\xe8\xb0\x42\x16\x4c\x97\x39\x4a\x08\x9a\xb4\xcb\x0d\x50\x0c\x25\x44\xdf\x57\xb1\x44\xf1\xf0\xaa\x36\xc8\xa1\x19\xf5\x86\xa5\x42\xf0\xbf\x07\x2e\x45\x18\x5e\x57\x51\x7c\xe8\xea\xf8\xe7\x5b\x34\x8c\xa9\xfc\x4d\x8a\x37\x05\x55\xf4\x7c\x7c\xfe\x09\x0e\xe6\x6c\x94\xd3\x96\xf1\x43\x95\xfd\x3a\xfd\x6a\xdf\x4e\x27\xff\x8c\x06\xd3\x0d\xe8\xc1\x1a\x97\xd0\x4b\x14\xbb\xf1\xe8\x4c\x2b\xc1\x2c\x6c\x5d\x8a\x37\xcc\x43\x90\x5a\xe9\xa5\x90\x75\xa9\x8d\x91\x4a\x64\x7c\x36\xe4\x2e\x2c\xb4\x32\x5c\x21\xd6\x25\x68\xc3\xbf\x21\x28\x04\xb7\x2f\x42\x8e\x5a\x19\xae\x10\x6b\x9b\x30\xca\x62\x92\x21\x48\x46\xb5\xf2\xcb\x03\xd7\x37\xa4\x0a\x3d\x1d\x21\x3a\xc3\x30\xc5\xac\xcb\xed\x21\xf4\x9f\x41\xda\x80\xa1\xe4\xe0\xea\xe5\xa8\xe9\x2f\x4f\x04\x52\xef\x9e\xa2\x33\x07\xd5\x4a\x82\x05\xa9\x4c\x03\xb9\x06\x32\x53\xb2\x08\xae\x92\x99\xfa\x82\x23\x65\x1c\x64\x6b\x93\xd3\x6a\x81\x6f\x24\x3a\x6c\x41\x9f\x92\xd4\x29\x49\x9d\x92\xd4\x29\x49\x9d\x92\x94\x89\x24\xd5\x97\x40\x11\x92\xc4\xf0\x67\xa6\xaa\x81\x07\xd1\x33\xa5\x95\x9e\xae\xb9\xb7\x6f\x0f\x5a\xcd\x6f\xf4\x19\xaf\x15\x71\xeb\x58\x9f\xbe\x06\x85\x52\x2c\xab\x3b\x75\x5b\x2a\x03\xc7\x2a\x9e\xc4\x8b\x8f\xaf\x77\x59\xac\x09\xa5\x04\x8e\x13\x3a\x7b\xa2\x5e\x08\x5a\x59\xa6\xa0\x47\x16\xe8\xca\x52\x2a\xbc\xb5\x89\xe5\xb1\x7e\x74\x2f\x5e\xff\x5c\x2f\x7a\x80\xb7\x11\x8d\xfc\xa8\xcd\x0c\x5b\x93\x17\x8e\x75\x9d\xa1\x1f\x08\x89\x36\x28\x11\x4a\x07\xaa\x9d\x40\x44\x23\xbf\xdb\x43\xad\x7f\x27\xc6\xab\xfb\x63\xa2\xc4\xc2\x38\x8d\xe4\xb6\x92\xdf\x44\x52\xae\x5e\xde\x5b\x8b\x55\xf3\x9b\x2c\xae\x31\xcf\x19\x2c\x4e\x9e\x73\xf2\x9c\x4a\x85\x36\x14\x41\x2a\xa8\x8e\xff\xec\x37\x42\xb5\xa4\xdd\x84\x0d\xae\xc1\x03\x84\x3a\xda\xa7\x63\x7f\xaf\x7f\x56\x38\xf1\x4c\xed\x3b\x92\x62\x26\x41\xa9\x8f\xfe\xeb\xf7\x50\x8c\x00\x42\x65\x66\x9a\xa6\xd8\xba\xb8\xde\x01\x9f\xe1\xdc\x0c\xd7\x14\x5b\x17\xd7\xb8\xc6\x64\x86\xea\x0a\xda\x70\x11\xc7\x86\x17\x90\xc0\xab\xcd\xfe\xf6\xd7\x71\xda\xbf\xad\xf8\x80\xcb\xf5\x1f\xd3\xb6\x35\xca\x64\x8f\x59\x12\x60\xcf\xec\x66\xf7\xc7\x74\x0c\xf8\x4d\x88\x5f\x3e\x95\xbf\x2a\x4d\x1d\x8a\x30\x13\x72\x39\x59\x06\x7a\x77\xfb\x79\xe0\xda\xa5\x3c\xcd\x53\xc7\xfe\x1f\x4c\xea\x91\x50\x63\x70\x04\x77\xb5\xf2\x4b\x50\x75\xb9\x74\x7a\xb9\x7a\x45\x56\x6d\xa8\x31\xea\x24\x85\x24\x2a\x2b\xe4\x5c\x01\x77\x07\xaf\xc0\x51\x5d\x75\xb9\xe8\xee\x1c\x5a\x97\x75\x11\xba\x14\x69\xe4\x8d\x3d\x85\x92\xf1\x19\xb1\xd6\x0f\x62\x4e\x6a\x5b\x93\xc3\xc5\xbe\xd3\xc0\x69\x54\xe8\xde\xc6\x49\x8d\xd2\x97\xa0\x72\xe2\x4b\x3b\xf5\xd6\x22\x42\xf6\xb7\x46\x81\xeb\x1c\x9f\xca\x6c\x48\xd1\xcd\x2a\xb8\x99\xff\x5a\x92\xcf\x8c\xc9\x2b\x0c\x3d\xcd\xca\xdd\x39\xa7\x69\x44\x6a\x69\x17\x48\x23\xd2\x0b\x1b\x24\xdf\x97\xdc\x21\xd4\xf3\xc4\x1b\xb8\xeb\x2a\x7a\x1a\xfd\x33\x8f\x57\x9d\x97\xf7\x80\x34\x1a\x4b\xac\xdc\xcb\xd2\xef\xaf\xe3\x34\xd0\x9f\x33\xcf\x95\xc0\x0b\x46\xe5\x1f\x6d\x22\xb9\x16\x63\xec\x74\xb9\x34\xe3\x00\xc5\xdd\x17\x8d\xc8\xde\x6e\x9a\xd1\x16\x35\x4b\x25\x6e\x37\xc1\x98\x97\x68\x32\x39\x94\x0a\x2d\xac\x85\x34\x27\x76\xa7\xbe\xa1\x63\x4a\xf7\x1f\xef\x47\x77\x83\xc9\xe0\x7a\xff\x6c\x1e\xd8\xf6\xa3\xbd\xff\xb3\xe1\xc3\x74\x64\x3f\xde\xda\x83\xf1\x78\xff\xc7\xe3\xc9\xe3\x68\x54\x28\xdc\x68\x50\x28\x2d\xc3\x34\x32\x41\xcb\x0a\x2b\xcd\x08\xcf\x9d\x94\x36\x2b\x3b\x5f\x41\xcd\xcc\x1c\xc9\xcc\xc4\xa5\xe2\x0d\xe8\x3b\x53\xb6\x43\x5c\x78\xa1\xa1\x87\xb1\x8d\xce\x2d\x9d\x2b\x91\x43\xc3\x86\x41\x0e\xe9\x06\xeb\x37\xd3\xc8\xd5\x2f\x0e\x0e\x67\x87\xd0\x21\x56\xc1\x2e\x8f\x58\xca\x09\xdc\xde\x7f\x01\x00\x00\xff\xff\x1a\x57\x6a\x92\xbf\x38\x00\x00"), + }, + "/X_MS_MediaReceiverRegistrar.xml": &vfsgen۰CompressedFileInfo{ + name: "X_MS_MediaReceiverRegistrar.xml", + modTime: time.Date(2024, 5, 9, 3, 12, 13, 0, time.UTC), + uncompressedSize: 2485, + + compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\xec\x56\xc1\x6a\xdb\x40\x10\x3d\xcb\x5f\x61\x74\x77\x36\x86\xd2\x43\x58\x6f\x30\x38\x04\x43\x0b\x45\x71\x0d\x3d\x99\xb5\x76\x50\xa6\x95\x66\xd5\x9d\x95\x49\xfa\xf5\x45\x5a\x39\x92\x13\x25\x14\x27\x4e\x2e\xbd\xcd\xcc\x7a\xde\x7b\xde\xa7\x19\x49\x5e\xde\x15\xf9\x78\x07\x8e\xd1\xd2\x2c\x9e\x9e\x9d\xc7\xe3\x4b\x35\x92\x9c\x96\x66\x7c\x57\xe4\xc4\xb3\xb8\x72\x74\xc1\xe9\x2d\x14\x9a\x27\x55\x49\xe5\xc4\xba\xec\x82\xc1\xed\x30\x85\xc9\x74\x72\x1e\xab\x51\x24\xb9\x84\x74\x1d\x60\xd4\x28\x8a\x64\xa1\x7f\x5a\xa7\xa6\x52\x84\xa0\x29\x21\x59\xa7\xce\xa5\x08\xc1\x28\x92\xe2\xb0\x4b\xea\xd4\xa3\xa5\x2f\xc8\xbe\x69\x08\x69\x1d\x46\x92\x74\x01\x6a\xc9\xf3\xca\xdf\x5a\x87\x7f\xc0\x48\xd1\x94\x9a\x43\xed\xb2\xaa\x00\xf2\xfb\xce\x5e\x29\xa4\x6d\xff\x02\x6a\xcd\xcb\x45\xaf\x37\x8a\xa4\x41\x07\x81\x09\x49\x8a\x2e\x6b\x8f\x1d\xe4\xda\x83\xb9\xf1\xda\xc3\x5a\x3b\xd4\xdb\x1c\xd4\x7c\x33\x4f\xae\x37\xab\x1f\xdf\xae\x36\x1d\xe8\xe0\x2f\x83\x1c\x71\xa8\x67\x58\x5e\x02\x5c\xe5\xfe\x39\x71\xb6\xf2\x47\xa8\xdb\x63\xfe\xb3\xb6\x2e\x7d\xf0\x41\x74\x46\x3c\xf5\x24\x81\x0c\xd9\x83\x0b\xd7\x70\x8c\x2b\x01\xc1\xe9\x1a\x38\x81\xdf\x5f\x39\x7b\x53\x7f\x86\xe0\x5f\xef\x54\x1f\x93\xcb\x17\x34\x1f\x6b\xdb\x00\xc1\xc9\x3c\x5c\xf2\x5a\xe7\x68\x6a\xf0\xff\x63\xf5\x01\x63\xb5\x0f\xdb\x23\xd9\x2e\xd7\x06\x75\xb5\x87\x94\xdc\x27\x19\x33\x90\xb9\xda\x01\x79\x9e\xc5\x64\xe3\x9e\x9b\x83\x97\xd8\xb9\x6a\xb4\xd7\xab\xfb\x12\x14\x7b\x87\x94\x49\xf1\x50\x68\x44\xf1\xe3\xbf\x72\x0c\xef\x93\x2b\xef\x58\x91\xfc\x89\x28\x5f\xd8\x22\x1d\xfd\x16\xe9\x6c\xab\x19\x3e\x7f\x7a\x07\x15\x8f\x17\xc3\x5b\xcb\xb8\x07\x3e\xd0\xd1\xbe\x1c\x1b\xf6\x6b\xa7\xc9\x83\xf9\x5e\xd6\x63\xfd\xcc\x13\x50\xe1\xc9\xe8\x17\x40\xf8\xae\xec\xed\x06\x43\x4b\x37\x55\x9a\x02\x98\x0f\x62\x4f\x60\x67\x7f\xbd\x9e\x5b\x8a\x81\x25\x20\x45\xfd\x5d\xa6\xfe\x06\x00\x00\xff\xff\xfa\x10\x27\xa0\xb5\x09\x00\x00"), + }, + "/openlist-120x120.png": &vfsgen۰FileInfo{ + name: "openlist-120x120.png", + modTime: time.Date(2025, 7, 4, 7, 5, 3, 156954500, time.UTC), + content: []byte("\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x78\x00\x00\x00\x78\x08\x03\x00\x00\x00\x0e\xba\xc6\xe0\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x1e\xc0\x00\x00\x1e\xc0\x01\x11\x2e\xb6\xb7\x00\x00\x00\x5d\x50\x4c\x54\x45\x47\x70\x4c\x1e\x4e\x53\x38\x87\x8c\x45\xa2\xa5\x55\xb9\xb5\x00\x76\xaa\x5a\xc6\xc1\x00\x7d\xb3\x5f\xcf\xca\x62\xd6\xd1\x00\x87\xc2\x63\xd9\xd4\x66\xde\xd9\x67\xe0\xdb\x00\x8e\xcb\x68\xe4\xdf\x6b\xe9\xe3\x00\x93\xd2\x6c\xeb\xe5\x6e\xf0\xea\x00\x97\xd9\x6f\xf3\xed\x00\x9a\xdc\x70\xf5\xef\x00\x9c\xe0\x72\xf8\xf2\x00\x9f\xe4\x73\xfb\xf5\x75\xff\xfa\x74\xfe\xf8\x00\xa2\xe8\x5a\x4f\xaa\x02\x00\x00\x00\x1c\x74\x52\x4e\x53\x00\x06\x0f\x18\x27\x29\x3b\x42\x51\x62\x68\x72\x7f\x8d\x8d\x9d\xac\xb1\xbe\xcf\xce\xdc\xe0\xe7\xef\xf3\xfb\xfc\x12\x3f\x22\xdb\x00\x00\x05\x6d\x49\x44\x41\x54\x78\xda\xd5\x9b\xed\x72\xa4\x28\x14\x86\xf9\x6a\x02\x01\x1b\x06\x62\x0c\x0d\x27\xf7\x7f\x99\x5b\xb5\xd6\xec\x74\x35\xe8\x01\x35\x5b\x35\xcf\xdf\xa4\xfa\xc8\x0b\x3c\xa0\x28\xf9\x6b\x60\x42\x2a\x6d\x27\xf7\x2f\x93\x35\x4a\x0a\x4e\xc9\xcf\x42\x85\xb2\x2e\x2e\x29\x03\x40\x59\x01\xc8\x69\x89\x61\xd2\x92\xfd\x58\x4b\xa5\x0d\x73\x86\x02\x50\x6a\x20\xa7\xe8\xb4\xa0\x3f\x50\x75\x8a\x09\x4a\x29\x50\x36\x80\x52\xf2\xe2\xb5\x20\x57\x22\x4c\x48\x50\x7a\x80\xd9\xa9\xab\x32\xa7\x72\x9a\xa1\x74\x03\x29\x68\x7e\x49\x59\xb7\x40\x19\x23\x47\x73\xba\xb4\x70\x09\xca\x38\x10\xf5\xa9\xc0\xb9\x5d\xa0\x1c\x01\x4a\x0e\x8a\x1e\x4e\x59\x45\x28\xc7\x49\x4e\x1c\x6c\xae\x4b\xe5\x04\x00\x10\x0f\x35\x1a\x6f\x2e\x9e\x77\x9a\xf8\xb0\x30\x6c\x2a\x17\x00\x41\x8e\xc6\x9c\xcb\x35\xcc\x9a\x0c\x20\x02\x94\xab\x48\x86\x92\x5e\x64\x2c\x17\x92\x27\xd6\xeb\xaa\xb9\xec\xb2\x2e\x84\xde\x3b\xe7\x7c\x88\x73\xca\x80\x54\x76\x7d\x95\xd5\x52\xa0\x6c\x92\x97\x30\x69\x29\x18\x5d\x03\xa4\x8c\x0b\x65\x7c\xdc\xf5\x5b\xf6\x9c\xe0\xec\xb5\x37\xcf\x4e\x35\xd7\x5c\x2e\x4d\xd8\x96\x1c\x80\x63\x27\xea\xc2\xe2\x35\xdf\xeb\x21\x1b\xf3\xf1\xb4\x45\xdc\x2c\x3b\x49\x8a\x4d\x42\x1d\xd2\x56\x65\x4b\xc9\x1e\xdc\xc3\x56\x59\xd1\xe5\x1d\x15\xf2\x46\x65\x43\x76\xa0\x13\x6c\xf8\x5e\x76\x2b\x4f\x37\x55\x0b\xb0\x28\xb2\x8d\x6e\x26\x35\x28\x7b\x31\xb5\xf3\x8e\x62\x6c\x60\x41\x76\xe2\x92\xe5\x14\x3c\xdb\x4a\x29\x94\x06\x8b\xa6\x64\x14\xe1\xf3\x48\x37\xdb\xdc\x0a\x48\x92\x03\xb0\x66\xdc\x8b\x6c\x07\xbd\xb4\x96\x35\x41\x0e\x41\x4d\xab\x72\x68\x85\xcd\x3c\x52\x77\x10\x9d\x3a\xc3\xd6\x19\xad\x7b\xbe\xf2\x2c\x6a\x75\xc4\xba\x6e\x44\xea\x8e\x57\x86\x89\xbc\x62\xa0\xbe\x3c\x49\x4e\x41\x6d\xc6\xc7\x57\xed\x68\x48\x8a\x9c\x84\xba\xba\x35\x8e\x62\x0d\xce\x96\x9c\x86\x87\xda\xbe\x12\xeb\xe1\x0d\xcf\xb0\xdb\xdb\x8d\x93\x2d\xf0\x29\x0a\x8e\xd6\x43\x1a\x9f\xec\xb7\xfb\xc7\xd7\xd7\xd7\xe7\xfd\x8d\x74\x62\xea\x1f\x16\xbb\xb2\x04\x43\x6a\xe8\xfb\xe7\xf7\xca\xd7\x9d\x11\x9c\xf6\x2f\xdb\xe7\x44\x52\x65\xca\x56\x9c\xef\x8f\xef\xdf\x3c\xee\xa8\xc1\xf1\x9f\xae\x87\x16\x64\x45\x2a\xe8\xdb\xd7\xf7\x1f\x1e\xef\x47\x47\xf6\xf3\x6f\x5b\xe8\x90\x2a\xff\xf8\x7e\xe6\x83\x93\x2e\x44\x35\xbe\x3c\x7d\x2a\x8c\x37\x98\xdc\xd7\x82\xa3\x4d\x26\x13\x6c\x7b\x53\x67\xbc\xc1\x6b\xd0\x4f\xdc\xc9\xc1\x26\x67\xbd\xf5\xb7\xac\x49\x05\xfb\xf5\xfd\xc2\x2f\xda\xdb\xcb\x95\xbd\xb6\xe2\x88\xbc\x35\xa2\x8f\x16\x26\x2a\xbd\x66\xcd\xdb\xaa\x6e\x48\x9a\xde\xd6\x19\x7c\x24\x6a\xc2\xe2\x8e\x36\xd5\xbc\x7b\x5f\x49\xd7\xa0\x9b\x83\x0b\xc7\xc2\x8e\x9d\x64\xc8\xd5\x5e\xb6\x19\x34\x3e\x9d\x70\x89\xf8\xe7\x12\x4c\xb9\x38\x47\x6f\x78\x4b\xd1\x1f\x48\x83\x07\xb3\x8e\xec\xe5\x1f\x38\xa3\xa4\x01\x5d\xa7\x70\x35\xb4\xba\x71\xf5\x42\x81\x51\x4f\xe1\x95\xcf\x1b\xc1\xd9\xf4\x44\x53\x50\x98\x2b\x57\xaa\xa0\x47\x3a\x19\xc0\x90\x1e\x5a\x41\x33\x32\x02\x9f\x91\x3d\x5f\x6f\xd0\x5f\x6f\x64\x08\x1a\x5e\x87\x35\xa9\xc0\x5d\x89\xbb\x03\x1f\x5d\x81\x12\x0c\x8a\x4f\xe1\xf1\x15\x0a\x22\x23\x18\xb7\x4f\x3c\x68\x1c\x03\xd5\x2e\x04\x81\xb6\x82\xa6\x64\x14\x0d\xd5\x32\x51\x81\xbb\xf2\x46\x86\xd1\xb9\x2a\x8c\x05\xdd\xef\x4a\xca\xb6\x93\x50\x48\xe1\xe3\xae\x14\xc6\x87\xe0\x8d\xb8\xa8\xf0\x7b\xa7\x2b\x99\x99\x61\x3d\x75\xb2\xec\x8a\x3e\xe6\x9d\x8b\x12\x73\xf9\xcf\x1d\x4a\xb3\xb2\x41\x0a\x8f\xba\xb2\x5e\xe8\x21\x4f\xb4\x63\x2b\x10\xd9\xb0\x2b\xd1\x25\x20\xa9\x61\x73\xe1\xae\x7c\xdc\x3b\xee\x45\x5d\x63\xe7\xe4\xeb\x2d\xc8\x36\xef\xdf\x7d\xae\xb4\xd5\x88\xc5\xb7\x20\x6e\x2f\xe8\xb6\x2b\xf1\x5b\xe0\x24\xb1\x3d\x3d\x80\x3d\xef\x4a\xe6\x4b\x55\x18\x9d\xc6\x59\x9f\x77\xa5\xce\x55\x61\x81\x0e\xea\x24\x4f\xbb\x52\xcc\xe5\x95\xc8\xd1\xb1\x35\xf3\x01\x57\x3e\x7e\xd1\xae\xe7\x3a\x30\xe1\x57\x17\xe8\xd9\x7d\xa5\x4a\xf8\xc3\xbb\xba\x8b\x61\x1a\x76\x25\xfe\x38\x30\x1b\xfc\x76\x31\xeb\x61\x57\xe2\x27\x09\x9e\xe1\x49\x2f\x62\xd4\x95\x78\xd0\x8b\xec\x78\xe4\x14\xd8\xe0\xbe\x12\x7f\x6e\x07\xb6\xe3\x89\xd3\xa6\x3e\xee\x8f\xaa\x83\xdb\xae\xac\x83\x0e\x7c\x33\x17\x7c\x16\xdf\xc6\x5c\x89\xad\x4c\xd4\xe3\x13\x7d\xdc\x95\xf5\x34\xc1\x1b\x0c\xd3\x39\x57\x9a\x5c\x7a\x9a\xc2\x02\xe6\xf2\xf3\xae\x4c\xaa\xc7\xe5\x10\x06\xef\xc1\x71\x57\x3a\xba\x75\x48\x8a\xdb\x83\xbe\x3d\xda\xae\xc4\xcf\x1b\x60\x16\x5d\xd7\x17\xf9\x19\x57\x8a\x86\x2b\x75\xdf\xf5\x99\xeb\x5d\xd9\x35\x10\x66\x71\xb9\x2b\xbb\xd5\x56\x43\x47\xf6\x95\xf8\x19\x1a\x73\x75\x2e\xb1\xb7\xc1\xdd\xae\x64\x8d\xfe\xc8\xbd\x07\xaa\xef\x47\x5d\x09\x0d\x29\x50\x93\x0b\xee\xf2\x95\x7b\x9f\x2b\x43\x8f\x2b\xa9\x4d\x05\x51\x4c\x55\x78\xd8\x95\x50\xcf\x4d\xb6\xe6\x5c\x2b\x06\x8d\x7a\xdc\x95\xf8\xdb\x51\x71\x67\xe7\x81\xef\x2b\x7d\x87\x2b\x65\x80\x82\x04\x5d\x4d\x27\xd4\x95\x19\x75\x02\x33\x4b\x69\x00\x13\x45\x6e\x4c\xc7\x82\x86\x57\x57\x4a\x9f\x9b\x75\x03\xef\x7b\xf6\xf0\xb8\x77\x2e\x4a\x9e\x22\x6f\x54\xd6\xb9\xe0\xa7\x87\xb8\x2b\x61\x11\xcf\x65\x4d\x84\xd2\x64\x51\xa4\xa6\x3e\x2f\xfd\xb8\xbf\x91\xf1\x0d\xbc\xd8\x7e\xbf\x29\x69\x82\xc2\x6e\xb7\x1b\xeb\x3c\x34\x2b\xf0\x9f\x2b\xb9\x72\x33\x94\x0d\xb2\xa1\xa4\x07\xba\x7b\x0c\x5d\x2f\x4a\x4c\x68\x57\x35\x16\x79\xa9\x0a\x01\x75\x25\x97\xca\xb8\xb0\x64\x28\x70\x5d\x5d\x7c\x5f\x09\x33\xf6\xae\xe0\x78\x5d\x3c\xe8\x15\x80\x81\x17\x33\x71\x10\x57\x0e\xb0\x68\x72\x0e\x95\xca\x01\x20\x4a\x72\x12\x57\x0e\x90\xbd\x20\x27\x61\xf1\x48\xcc\x96\x91\xb3\x88\xe5\xba\x77\x9c\xcf\x9c\x59\xe1\xc0\x62\x39\x41\x18\xb7\x07\x4e\xf2\xe3\xcd\xc5\x3c\x8d\x03\xc8\x37\x03\xc7\xfc\x81\x93\x82\x62\xe4\x32\xa8\xcd\x9d\x7d\xeb\x4f\x97\xad\x4f\x1e\x30\x20\x45\x2b\x29\xb9\x16\xca\xec\x02\x48\xd5\x49\x71\xf2\x03\xd0\xf5\xa3\xa7\x76\xd1\xd9\x5b\xc9\xc9\x8f\xc1\xa4\x59\xbf\x2d\xfb\xcd\xfa\x42\xbd\x33\x92\xd3\xff\xe7\x6b\x3a\x63\xa7\xc9\x4e\xd6\xe8\xf5\x5b\xba\xbf\x89\x7f\x00\xff\x00\x7d\xec\x4c\x73\x2e\x7e\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82"), + }, + "/openlist-48x48.png": &vfsgen۰FileInfo{ + name: "openlist-48x48.png", + modTime: time.Date(2025, 7, 4, 7, 6, 9, 138755100, time.UTC), + content: []byte("\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x30\x00\x00\x00\x30\x08\x03\x00\x00\x00\x60\xdc\x09\xb5\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x1e\xc0\x00\x00\x1e\xc0\x01\x11\x2e\xb6\xb7\x00\x00\x00\x6c\x50\x4c\x54\x45\x00\x00\x00\x49\xae\xb2\x49\xba\xc5\x63\xd7\xd2\x00\x8c\xc9\x66\xde\xd9\x68\xe3\xde\x00\x91\xcf\x6a\xe7\xe2\x6b\xeb\xe5\x00\x95\xd5\x6c\xed\xe7\x6d\xee\xe9\x00\x98\xda\x6e\xf1\xeb\x6f\xf3\xed\x00\x9a\xdd\x6f\xf4\xee\x00\x9b\xde\x70\xf5\xef\x71\xf6\xf0\x71\xf7\xf1\x00\x9d\xe1\x71\xf8\xf2\x72\xf9\xf3\x00\x9e\xe2\x73\xfa\xf4\x00\x9f\xe4\x73\xfb\xf5\x73\xfc\xf6\x00\xa0\xe6\x74\xfd\xf7\x74\xfe\xf8\x00\xa1\xe7\x75\xff\xf9\x00\xa2\xe8\x5f\xf7\xfd\xbc\x00\x00\x00\x22\x74\x52\x4e\x53\x00\x07\x0d\x14\x1e\x21\x2c\x31\x36\x46\x51\x57\x64\x73\x75\x84\x87\x92\x9f\xa2\xac\xb7\xbb\xc2\xd0\xd0\xdc\xe1\xe8\xee\xf1\xf6\xfd\xfc\x13\xf2\x47\xb2\x00\x00\x02\x3b\x49\x44\x41\x54\x78\xda\x85\x55\xe1\xba\x82\x20\x0c\x55\x49\x92\x8b\x61\x88\x06\x79\xf1\x42\xf8\xfe\xef\x78\xbf\x64\x64\x20\xd6\xf9\x07\x0c\x76\xb6\x9d\x8d\x62\x87\xb2\x26\x2d\xeb\xba\xae\xa5\x18\x15\x5f\x51\x62\x36\x4c\xb3\xb5\xce\x39\x6b\xb4\xe2\xb4\xfe\x68\x5e\xd1\x41\xbb\x08\x76\xe2\xcd\xf1\xeb\x44\x1a\xb7\x87\xe6\x38\x6f\x5f\x0b\xe3\xf2\x98\xda\x32\x63\x4f\x94\x3b\x84\x11\xfb\x50\x5a\xfd\xce\x7c\xd6\x7a\x8e\xfc\x49\x9c\xda\xcf\x1b\xe7\xb1\xa3\x0d\xc6\x98\xb4\x5c\xbd\x2e\x59\x15\xdf\xa0\x2f\xfb\xa9\xc3\x1b\x61\x44\xc4\xeb\x40\xbe\xb3\x6a\x26\xd8\x9d\xbb\x3a\xcd\xdc\x68\xe1\x6c\x40\xdb\x43\x12\xf6\x14\xc9\xd4\x86\x05\x27\x5d\x11\xd0\xc1\xce\x98\x2f\x2b\xd5\x10\x1c\x09\x84\xf4\x9e\x65\x36\xc2\xb1\xf2\x6b\x01\xe1\xe2\xe2\x08\xad\xf1\xe5\xa0\xe0\x20\xac\x3c\xce\xd7\xfe\x72\x4a\x42\x17\xef\x2e\x18\x24\xa1\x04\xfb\xfb\xb2\x2c\xb7\xe4\x06\x9e\xfc\xa3\x6b\x14\xdc\x27\xb4\x81\xa4\xdc\x96\x27\x2e\x45\x0c\x78\x95\xaf\x04\xd7\x44\x0b\x70\x70\x79\xac\x17\xfa\x22\xeb\x42\xa1\xe7\x9b\xc2\x3a\xa7\x1a\x20\xf4\xbb\xac\xb8\x16\x09\xc4\x1b\x0f\x44\x39\xab\x21\xba\xde\xdb\xff\x9e\x77\xa9\xf5\x05\x6f\x93\xed\x9f\xbf\xd5\xfe\x71\x29\x52\x60\x0d\xd5\x8e\x70\xba\x7b\x07\xb7\xaa\x48\x81\x7c\xbb\x88\x78\xf7\xea\xed\xff\xce\x99\xfe\x95\xbe\x12\x65\x8e\xd0\x2b\xe2\x0a\x6d\x87\xe3\xda\x16\xd2\xfb\x8e\x4a\xb0\xdc\x4f\xb0\x66\x52\x09\x1c\x3c\x8c\xe0\x21\x43\xe8\x07\xd6\xcc\x3e\x15\x89\xe0\x76\x44\x29\x2a\x41\x1f\x89\xd8\xb6\x10\xf4\x94\x06\x5d\xa6\x84\x3c\x07\xc7\xe0\xfa\x9c\xa6\x15\x34\xf1\x08\x84\x5a\x0b\x7a\x83\xe5\xba\xb2\x74\x2b\x41\x20\x54\x46\xe2\x09\x1a\x2b\x07\x2f\x0d\xc8\xc1\x5e\x13\x65\xd2\x55\x50\x68\x59\x1d\x69\x82\x1a\x4f\x81\xc5\x6d\xdf\x1d\x69\xa2\x56\x71\x17\x37\x1a\xc4\x1a\x08\xa5\x9a\xe0\x30\x27\x42\x57\x0d\x5b\x57\x02\xa1\xb8\x0b\xc8\x1c\x33\x60\xaf\x8c\x01\x7a\x20\x74\x8a\x07\x9b\x44\x61\xf0\x26\x0e\xca\x1b\x68\x22\x8e\x70\x26\xc1\x3e\x26\x18\x3c\x3c\xfa\x64\xb0\x71\xef\xae\x33\x2e\xce\x58\x3a\x5a\x82\x26\x54\xed\xbf\x31\x07\x10\xd5\x7b\xab\xf9\xe1\x15\x45\x48\x9f\xdd\x3e\x18\x77\x3c\x78\x63\x4d\x48\xca\xc4\x04\xe6\x9f\x06\x6f\x25\xc0\xc2\x82\x71\xfa\x7e\x0a\x62\xdc\x1e\x86\xa3\xe3\x41\xed\xf6\x50\xd1\xb7\xfb\xd5\x83\x86\x6f\xec\x4b\x0c\x00\xa3\x3a\x5c\x7c\x06\x62\x52\x1b\xeb\x9c\xb5\x66\x92\x9c\xa0\xe2\x3b\x50\x43\x5b\xc6\x58\x4b\x70\x95\x39\xfd\x07\xde\x50\x79\x59\x83\x43\x82\x17\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82"), + }, + "/rootDesc.xml.tmpl": &vfsgen۰CompressedFileInfo{ + name: "rootDesc.xml.tmpl", + modTime: time.Date(2025, 7, 4, 7, 20, 21, 416001300, time.UTC), + uncompressedSize: 2537, + + compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\xcc\x56\x5f\x6b\xfb\x36\x14\x7d\xef\xa7\x08\x79\xda\xa0\xb1\xec\xd2\x41\x30\xaa\x4a\x89\x19\x04\x9a\xae\x24\xcb\xe8\x5b\x50\xe5\x1b\x47\x9b\x2d\x19\x49\x4e\x52\x4a\xbf\xfb\x90\x6c\xc7\x72\xd2\x66\x65\x81\x1f\xbf\x3c\xe9\xfe\x39\xc7\xf7\x1e\xe9\x2a\xc2\xf7\xfb\x22\x1f\x6c\x41\x69\x2e\xc5\xdd\x30\x0a\xc2\xe1\x3d\xb9\xc2\x4a\x4a\x33\xd8\x17\xb9\xd0\x77\xc3\x4a\x89\x58\xb3\x0d\x14\x54\x8f\xaa\x52\x94\x23\xa9\xb2\x38\x85\x2d\x67\x30\x8a\x46\xe1\xf0\x6a\xe0\x7e\x2e\x3b\x4e\x73\x41\xfb\x10\xeb\x39\x0b\xd1\xc0\xee\x86\x1b\x63\xca\x18\xa1\xdd\x6e\x17\x68\x60\x01\x93\xc1\x3f\x0a\x59\xe8\x90\x5c\x0d\x06\x58\x97\xc0\xfe\xaa\x8b\x24\x0e\x8c\x0b\xfa\xb7\x54\x24\xc2\xa8\x5e\x34\x4e\x2e\xa4\x22\x21\x46\xf5\xc2\x22\xd1\x11\x14\xd7\x65\x34\x80\xda\xf8\xf3\xad\x04\x72\xa6\xcd\x78\x06\x29\xa7\x0b\x50\x5b\x50\x71\x84\x91\x87\xaa\x69\xd6\x8a\x83\x48\xf3\xb7\x27\x5a\x00\x79\x7f\x0f\x7e\xf7\xec\x8f\x0f\x8c\x7a\xf1\xb6\x7e\x51\xad\x29\x33\x95\x02\x45\x64\x09\x22\xe7\xda\x0c\x7e\x69\x57\x81\x54\xd9\xaf\xb6\x39\x2f\xeb\x14\xb8\x9c\x3f\x12\xab\x9c\x8e\x11\xf2\x91\xa8\x8f\xb4\x69\x0d\x58\xa6\x90\x27\xa0\x99\xe2\xa5\xb1\x8a\xb4\x28\x8c\x4e\x42\x1e\xc2\xd5\x7d\x94\xea\xf7\xe2\xec\xaa\x78\x05\x65\xbb\x9f\x75\xa6\x6d\xde\x8f\x7a\xf9\xe7\x6a\x6f\xe3\x75\xba\x06\xc5\x69\xcb\x10\x36\x3f\x8c\x7a\xee\x3a\x73\x99\x3c\xd9\x02\xe6\x52\x9a\xc4\xed\xd1\x72\x39\x4d\x6c\x0d\x36\xd0\xec\x78\x2e\x68\xfc\xb2\x4a\x1e\x9f\x1e\x26\x0f\xcf\xe8\xd4\x9b\xfc\x31\x21\xc9\x6c\x31\x8a\x82\xdf\x42\x8c\x8e\x02\x9f\x66\xcf\x46\xff\x91\xaf\x81\xc5\xcf\x4a\xa6\x15\x33\x13\x5a\x12\x5d\xf0\xeb\x64\x32\x8b\xc2\xeb\x0c\x8c\x3b\x5a\x53\xb1\x96\xf6\xdc\x5b\xc7\x84\x3a\xf9\x5b\x97\xed\xb3\x87\xee\x28\x5f\x56\x97\x91\xf6\xf0\x35\x2d\x67\x52\x3c\x72\x6d\x48\x33\xa1\xce\xd1\x1a\x6e\xc0\x0a\x30\xf6\xdc\xf3\x82\x66\x80\x4a\x91\xd9\x59\x6b\x7c\x5d\xda\x8e\xa7\x66\x43\x6e\xc7\x18\xd5\xab\x2e\xb2\x01\x9e\x6d\x8c\x0b\x35\xcb\x2e\x96\x42\x69\x36\x64\x6c\xe7\xab\xec\x81\x2a\x95\x13\xa4\x0d\x35\x9c\x1d\x8e\xca\xe8\x76\xbc\xbf\x1d\x07\xae\x02\x1b\x6f\x0b\x46\x7e\xc5\x17\x95\x1f\xdd\x84\x5f\xd6\xef\x62\x17\x36\x10\xdd\x84\xfb\xe8\x26\x3c\xdb\x42\xbd\xee\x76\xc4\xce\x82\x3d\xd7\xbd\x3d\x6a\x7c\xde\xf7\x1a\xcf\xd7\xf7\x5a\x93\x10\x4f\xa4\x30\x20\x4c\xc2\x15\x30\x23\xd5\x9b\xbd\xdd\x7c\xf0\x09\xe5\x34\x75\x84\xc7\x44\xd3\xf4\x84\xea\x40\x34\x4d\x3d\x9a\xc5\xe4\x39\xb1\x93\xdd\xaa\x71\x8c\x0a\xf6\x45\x8e\x51\x9b\xd5\xe1\x98\x14\x46\x49\x77\x29\x20\x66\x72\x8c\x3c\x47\x97\x05\x5b\x10\x66\x51\xbd\x5a\x2f\x46\xbe\x75\xd0\xf6\x48\xac\x0b\xc5\x13\xc0\xec\x5c\xcd\xa8\xa0\x59\xfd\xdf\xf0\xff\xd5\xeb\x73\x7d\x5b\xbe\x3e\xec\x27\xd4\xaf\xe0\x4c\x49\x2d\xd7\x26\x60\xb2\x38\x88\xf7\xb2\x9a\x2d\x56\xee\x9e\x9a\x03\x03\xbe\x05\x35\x87\x8c\x6b\xa3\xe8\xb7\x65\xfc\x94\x78\x9a\x9e\xa3\xfe\x9e\xaa\x67\x08\x7e\xac\xbe\x07\xd3\xbb\x01\x4a\x05\x1a\x84\x2d\x54\x0a\xf7\x39\x8c\x8e\x5d\xee\xdd\xd3\xbe\x73\x30\xb2\x8f\x39\xf2\x6f\x00\x00\x00\xff\xff\xb9\xf5\x9f\x69\xe9\x09\x00\x00"), + }, + } + fs["/"].(*vfsgen۰DirInfo).entries = []os.FileInfo{ + fs["/ConnectionManager.xml"].(os.FileInfo), + fs["/ContentDirectory.xml"].(os.FileInfo), + fs["/X_MS_MediaReceiverRegistrar.xml"].(os.FileInfo), + fs["/openlist-120x120.png"].(os.FileInfo), + fs["/openlist-48x48.png"].(os.FileInfo), + fs["/rootDesc.xml.tmpl"].(os.FileInfo), + } + + return fs +}() + +type vfsgen۰FS map[string]interface{} + +func (fs vfsgen۰FS) Open(path string) (http.File, error) { + path = pathpkg.Clean("/" + path) + f, ok := fs[path] + if !ok { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrNotExist} + } + + switch f := f.(type) { + case *vfsgen۰CompressedFileInfo: + gr, err := gzip.NewReader(bytes.NewReader(f.compressedContent)) + if err != nil { + // This should never happen because we generate the gzip bytes such that they are always valid. + panic("unexpected error reading own gzip compressed bytes: " + err.Error()) + } + return &vfsgen۰CompressedFile{ + vfsgen۰CompressedFileInfo: f, + gr: gr, + }, nil + case *vfsgen۰FileInfo: + return &vfsgen۰File{ + vfsgen۰FileInfo: f, + Reader: bytes.NewReader(f.content), + }, nil + case *vfsgen۰DirInfo: + return &vfsgen۰Dir{ + vfsgen۰DirInfo: f, + }, nil + default: + // This should never happen because we generate only the above types. + panic(fmt.Sprintf("unexpected type %T", f)) + } +} + +// vfsgen۰CompressedFileInfo is a static definition of a gzip compressed file. +type vfsgen۰CompressedFileInfo struct { + name string + modTime time.Time + compressedContent []byte + uncompressedSize int64 +} + +func (f *vfsgen۰CompressedFileInfo) Readdir(count int) ([]os.FileInfo, error) { + return nil, fmt.Errorf("cannot Readdir from file %s", f.name) +} +func (f *vfsgen۰CompressedFileInfo) Stat() (os.FileInfo, error) { return f, nil } + +func (f *vfsgen۰CompressedFileInfo) GzipBytes() []byte { + return f.compressedContent +} + +func (f *vfsgen۰CompressedFileInfo) Name() string { return f.name } +func (f *vfsgen۰CompressedFileInfo) Size() int64 { return f.uncompressedSize } +func (f *vfsgen۰CompressedFileInfo) Mode() os.FileMode { return 0444 } +func (f *vfsgen۰CompressedFileInfo) ModTime() time.Time { return f.modTime } +func (f *vfsgen۰CompressedFileInfo) IsDir() bool { return false } +func (f *vfsgen۰CompressedFileInfo) Sys() interface{} { return nil } + +// vfsgen۰CompressedFile is an opened compressedFile instance. +type vfsgen۰CompressedFile struct { + *vfsgen۰CompressedFileInfo + gr *gzip.Reader + grPos int64 // Actual gr uncompressed position. + seekPos int64 // Seek uncompressed position. +} + +func (f *vfsgen۰CompressedFile) Read(p []byte) (n int, err error) { + if f.grPos > f.seekPos { + // Rewind to beginning. + err = f.gr.Reset(bytes.NewReader(f.compressedContent)) + if err != nil { + return 0, err + } + f.grPos = 0 + } + if f.grPos < f.seekPos { + // Fast-forward. + _, err = io.CopyN(io.Discard, f.gr, f.seekPos-f.grPos) + if err != nil { + return 0, err + } + f.grPos = f.seekPos + } + n, err = f.gr.Read(p) + f.grPos += int64(n) + f.seekPos = f.grPos + return n, err +} +func (f *vfsgen۰CompressedFile) Seek(offset int64, whence int) (int64, error) { + switch whence { + case io.SeekStart: + f.seekPos = 0 + offset + case io.SeekCurrent: + f.seekPos += offset + case io.SeekEnd: + f.seekPos = f.uncompressedSize + offset + default: + panic(fmt.Errorf("invalid whence value: %v", whence)) + } + return f.seekPos, nil +} +func (f *vfsgen۰CompressedFile) Close() error { + return f.gr.Close() +} + +// vfsgen۰FileInfo is a static definition of an uncompressed file (because it's not worth gzip compressing). +type vfsgen۰FileInfo struct { + name string + modTime time.Time + content []byte +} + +func (f *vfsgen۰FileInfo) Readdir(count int) ([]os.FileInfo, error) { + return nil, fmt.Errorf("cannot Readdir from file %s", f.name) +} +func (f *vfsgen۰FileInfo) Stat() (os.FileInfo, error) { return f, nil } + +func (f *vfsgen۰FileInfo) NotWorthGzipCompressing() {} + +func (f *vfsgen۰FileInfo) Name() string { return f.name } +func (f *vfsgen۰FileInfo) Size() int64 { return int64(len(f.content)) } +func (f *vfsgen۰FileInfo) Mode() os.FileMode { return 0444 } +func (f *vfsgen۰FileInfo) ModTime() time.Time { return f.modTime } +func (f *vfsgen۰FileInfo) IsDir() bool { return false } +func (f *vfsgen۰FileInfo) Sys() interface{} { return nil } + +// vfsgen۰File is an opened file instance. +type vfsgen۰File struct { + *vfsgen۰FileInfo + *bytes.Reader +} + +func (f *vfsgen۰File) Close() error { + return nil +} + +// vfsgen۰DirInfo is a static definition of a directory. +type vfsgen۰DirInfo struct { + name string + modTime time.Time + entries []os.FileInfo +} + +func (d *vfsgen۰DirInfo) Read([]byte) (int, error) { + return 0, fmt.Errorf("cannot Read from directory %s", d.name) +} +func (d *vfsgen۰DirInfo) Close() error { return nil } +func (d *vfsgen۰DirInfo) Stat() (os.FileInfo, error) { return d, nil } + +func (d *vfsgen۰DirInfo) Name() string { return d.name } +func (d *vfsgen۰DirInfo) Size() int64 { return 0 } +func (d *vfsgen۰DirInfo) Mode() os.FileMode { return 0755 | os.ModeDir } +func (d *vfsgen۰DirInfo) ModTime() time.Time { return d.modTime } +func (d *vfsgen۰DirInfo) IsDir() bool { return true } +func (d *vfsgen۰DirInfo) Sys() interface{} { return nil } + +// vfsgen۰Dir is an opened dir instance. +type vfsgen۰Dir struct { + *vfsgen۰DirInfo + pos int // Position within entries for Seek and Readdir. +} + +func (d *vfsgen۰Dir) Seek(offset int64, whence int) (int64, error) { + if offset == 0 && whence == io.SeekStart { + d.pos = 0 + return 0, nil + } + return 0, fmt.Errorf("unsupported Seek in directory %s", d.name) +} + +func (d *vfsgen۰Dir) Readdir(count int) ([]os.FileInfo, error) { + if d.pos >= len(d.entries) && count > 0 { + return nil, io.EOF + } + if count <= 0 || count > len(d.entries)-d.pos { + count = len(d.entries) - d.pos + } + e := d.entries[d.pos : d.pos+count] + d.pos += count + return e, nil +} diff --git a/server/dlna/data/data.go b/server/dlna/data/data.go new file mode 100644 index 000000000..ec236262b --- /dev/null +++ b/server/dlna/data/data.go @@ -0,0 +1,42 @@ +// Package data provides utilities for DLNA server. +// The "go:generate" directive compiles static assets by running assets_generate.go +// +//go:generate go run assets_generate.go +package data + +import ( + "fmt" + "io" + "text/template" +) + +// GetTemplate returns the rootDesc XML template +func GetTemplate() (tpl *template.Template, err error) { + templateFile, err := Assets.Open("rootDesc.xml.tmpl") + if err != nil { + return nil, fmt.Errorf("get template open: %w", err) + } + + defer CheckClose(templateFile, &err) + + templateBytes, err := io.ReadAll(templateFile) + if err != nil { + return nil, fmt.Errorf("get template read: %w", err) + } + + var templateString = string(templateBytes) + + tpl, err = template.New("rootDesc").Parse(templateString) + if err != nil { + return nil, fmt.Errorf("get template parse: %w", err) + } + + return +} + +func CheckClose(c io.Closer, err *error) { + cerr := c.Close() + if *err == nil { + *err = cerr + } +} diff --git a/server/dlna/data/static/ConnectionManager.xml b/server/dlna/data/static/ConnectionManager.xml new file mode 100644 index 000000000..97a52f152 --- /dev/null +++ b/server/dlna/data/static/ConnectionManager.xml @@ -0,0 +1,182 @@ + + + + 1 + 0 + + + + GetProtocolInfo + + + Source + out + SourceProtocolInfo + + + Sink + out + SinkProtocolInfo + + + + + PrepareForConnection + + + RemoteProtocolInfo + in + A_ARG_TYPE_ProtocolInfo + + + PeerConnectionManager + in + A_ARG_TYPE_ConnectionManager + + + PeerConnectionID + in + A_ARG_TYPE_ConnectionID + + + Direction + in + A_ARG_TYPE_Direction + + + ConnectionID + out + A_ARG_TYPE_ConnectionID + + + AVTransportID + out + A_ARG_TYPE_AVTransportID + + + RcsID + out + A_ARG_TYPE_RcsID + + + + + ConnectionComplete + + + ConnectionID + in + A_ARG_TYPE_ConnectionID + + + + + GetCurrentConnectionIDs + + + ConnectionIDs + out + CurrentConnectionIDs + + + + + GetCurrentConnectionInfo + + + ConnectionID + in + A_ARG_TYPE_ConnectionID + + + RcsID + out + A_ARG_TYPE_RcsID + + + AVTransportID + out + A_ARG_TYPE_AVTransportID + + + ProtocolInfo + out + A_ARG_TYPE_ProtocolInfo + + + PeerConnectionManager + out + A_ARG_TYPE_ConnectionManager + + + PeerConnectionID + out + A_ARG_TYPE_ConnectionID + + + Direction + out + A_ARG_TYPE_Direction + + + Status + out + A_ARG_TYPE_ConnectionStatus + + + + + + + SourceProtocolInfo + string + + + SinkProtocolInfo + string + + + CurrentConnectionIDs + string + + + A_ARG_TYPE_ConnectionStatus + string + + OK + ContentFormatMismatch + InsufficientBandwidth + UnreliableChannel + Unknown + + + + A_ARG_TYPE_ConnectionManager + string + + + A_ARG_TYPE_Direction + string + + Input + Output + + + + A_ARG_TYPE_ProtocolInfo + string + + + A_ARG_TYPE_ConnectionID + i4 + + + A_ARG_TYPE_AVTransportID + i4 + + + A_ARG_TYPE_RcsID + i4 + + + \ No newline at end of file diff --git a/server/dlna/data/static/ContentDirectory.xml b/server/dlna/data/static/ContentDirectory.xml new file mode 100644 index 000000000..12fddb98d --- /dev/null +++ b/server/dlna/data/static/ContentDirectory.xml @@ -0,0 +1,504 @@ + + + + 1 + 0 + + + + GetSearchCapabilities + + + SearchCaps + out + SearchCapabilities + + + + + GetSortCapabilities + + + SortCaps + out + SortCapabilities + + + + + GetSortExtensionCapabilities + + + SortExtensionCaps + out + SortExtensionCapabilities + + + + + GetFeatureList + + + FeatureList + out + FeatureList + + + + + GetSystemUpdateID + + + Id + out + SystemUpdateID + + + + + Browse + + + ObjectID + in + A_ARG_TYPE_ObjectID + + + BrowseFlag + in + A_ARG_TYPE_BrowseFlag + + + Filter + in + A_ARG_TYPE_Filter + + + StartingIndex + in + A_ARG_TYPE_Index + + + RequestedCount + in + A_ARG_TYPE_Count + + + SortCriteria + in + A_ARG_TYPE_SortCriteria + + + Result + out + A_ARG_TYPE_Result + + + NumberReturned + out + A_ARG_TYPE_Count + + + TotalMatches + out + A_ARG_TYPE_Count + + + UpdateID + out + A_ARG_TYPE_UpdateID + + + + + Search + + + ContainerID + in + A_ARG_TYPE_ObjectID + + + SearchCriteria + in + A_ARG_TYPE_SearchCriteria + + + Filter + in + A_ARG_TYPE_Filter + + + StartingIndex + in + A_ARG_TYPE_Index + + + RequestedCount + in + A_ARG_TYPE_Count + + + SortCriteria + in + A_ARG_TYPE_SortCriteria + + + Result + out + A_ARG_TYPE_Result + + + NumberReturned + out + A_ARG_TYPE_Count + + + TotalMatches + out + A_ARG_TYPE_Count + + + UpdateID + out + A_ARG_TYPE_UpdateID + + + + + CreateObject + + + ContainerID + in + A_ARG_TYPE_ObjectID + + + Elements + in + A_ARG_TYPE_Result + + + ObjectID + out + A_ARG_TYPE_ObjectID + + + Result + out + A_ARG_TYPE_Result + + + + + DestroyObject + + + ObjectID + in + A_ARG_TYPE_ObjectID + + + + + UpdateObject + + + ObjectID + in + A_ARG_TYPE_ObjectID + + + CurrentTagValue + in + A_ARG_TYPE_TagValueList + + + NewTagValue + in + A_ARG_TYPE_TagValueList + + + + + MoveObject + + + ObjectID + in + A_ARG_TYPE_ObjectID + + + NewParentID + in + A_ARG_TYPE_ObjectID + + + NewObjectID + out + A_ARG_TYPE_ObjectID + + + + + ImportResource + + + SourceURI + in + A_ARG_TYPE_URI + + + DestinationURI + in + A_ARG_TYPE_URI + + + TransferID + out + A_ARG_TYPE_TransferID + + + + + ExportResource + + + SourceURI + in + A_ARG_TYPE_URI + + + DestinationURI + in + A_ARG_TYPE_URI + + + TransferID + out + A_ARG_TYPE_TransferID + + + + + StopTransferResource + + + TransferID + in + A_ARG_TYPE_TransferID + + + + + DeleteResource + + + ResourceURI + in + A_ARG_TYPE_URI + + + + + GetTransferProgress + + + TransferID + in + A_ARG_TYPE_TransferID + + + TransferStatus + out + A_ARG_TYPE_TransferStatus + + + TransferLength + out + A_ARG_TYPE_TransferLength + + + TransferTotal + out + A_ARG_TYPE_TransferTotal + + + + + CreateReference + + + ContainerID + in + A_ARG_TYPE_ObjectID + + + ObjectID + in + A_ARG_TYPE_ObjectID + + + NewID + out + A_ARG_TYPE_ObjectID + + + + + X_GetFeatureList + + + FeatureList + out + A_ARG_TYPE_Featurelist + + + + + X_SetBookmark + + + CategoryType + in + A_ARG_TYPE_CategoryType + + + RID + in + A_ARG_TYPE_RID + + + ObjectID + in + A_ARG_TYPE_ObjectID + + + PosSecond + in + A_ARG_TYPE_PosSec + + + + + + + SearchCapabilities + string + + + SortCapabilities + string + + + SortExtensionCapabilities + string + + + SystemUpdateID + ui4 + + + ContainerUpdateIDs + string + + + TransferIDs + string + + + FeatureList + string + + + A_ARG_TYPE_ObjectID + string + + + A_ARG_TYPE_Result + string + + + A_ARG_TYPE_SearchCriteria + string + + + A_ARG_TYPE_BrowseFlag + string + + BrowseMetadata + BrowseDirectChildren + + + + A_ARG_TYPE_Filter + string + + + A_ARG_TYPE_SortCriteria + string + + + A_ARG_TYPE_Index + ui4 + + + A_ARG_TYPE_Count + ui4 + + + A_ARG_TYPE_UpdateID + ui4 + + + A_ARG_TYPE_TransferID + ui4 + + + A_ARG_TYPE_TransferStatus + string + + COMPLETED + ERROR + IN_PROGRESS + STOPPED + + + + A_ARG_TYPE_TransferLength + string + + + A_ARG_TYPE_TransferTotal + string + + + A_ARG_TYPE_TagValueList + string + + + A_ARG_TYPE_URI + uri + + + A_ARG_TYPE_CategoryType + ui4 + + + + A_ARG_TYPE_RID + ui4 + + + + A_ARG_TYPE_PosSec + ui4 + + + + A_ARG_TYPE_Featurelist + string + + + + \ No newline at end of file diff --git a/server/dlna/data/static/X_MS_MediaReceiverRegistrar.xml b/server/dlna/data/static/X_MS_MediaReceiverRegistrar.xml new file mode 100644 index 000000000..4aecdff00 --- /dev/null +++ b/server/dlna/data/static/X_MS_MediaReceiverRegistrar.xml @@ -0,0 +1,88 @@ + + + + 1 + 0 + + + + IsAuthorized + + + DeviceID + in + A_ARG_TYPE_DeviceID + + + Result + out + A_ARG_TYPE_Result + + + + + RegisterDevice + + + RegistrationReqMsg + in + A_ARG_TYPE_RegistrationReqMsg + + + RegistrationRespMsg + out + A_ARG_TYPE_RegistrationRespMsg + + + + + IsValidated + + + DeviceID + in + A_ARG_TYPE_DeviceID + + + Result + out + A_ARG_TYPE_Result + + + + + + + A_ARG_TYPE_DeviceID + string + + + A_ARG_TYPE_Result + int + + + A_ARG_TYPE_RegistrationReqMsg + bin.base64 + + + A_ARG_TYPE_RegistrationRespMsg + bin.base64 + + + AuthorizationGrantedUpdateID + ui4 + + + AuthorizationDeniedUpdateID + ui4 + + + ValidationSucceededUpdateID + ui4 + + + ValidationRevokedUpdateID + ui4 + + + \ No newline at end of file diff --git a/server/dlna/data/static/openlist-120x120.png b/server/dlna/data/static/openlist-120x120.png new file mode 100644 index 0000000000000000000000000000000000000000..c259217d2f0725ae28c4345cac0bf7477ae368e2 GIT binary patch literal 1612 zcmV-S2DABzP)~A|-)rgP0F%;e>*a3n>HwG7Z}aT{n%r>p?*N?Oa`^H9pX77<^>zRH zbpH4NqUc&rssaE20338uPE!B|4;UvYJ3>)nXmWpzjh(EqzR%9w;OFo2`}`6=BHI7} z1#L-0K~#9!)tl{dq$m`I`Dy|I8wO$w4JY@1nOn8i>~uBg0X17S&)=l_$O}B6DEVt( zLMm-1_b-z*N(xTN&q9T=E-onpKv@CEX^COdlKogqr44h20#Iror|7hxKTvgw2}&tY zHh@y{;W){2iM(QikyVHx;2hs4bGOFhKJg|66@g&&#>Fujgu|#DGH9(LmHp#xBJJ}*VpI$jdWt_ z9~Qf^1Vd`*`{(z{*PRiZ_AlG#L{eqSR-EsT@83M`sKN1{u7?(6MJnjq*Y_VD??*@W z^KV<|kShQ9e&&OdE<`oHJbfyRH<;Ylf}>3)6Ks1^XS~5{&`;dS!6s+D^|!1^1^e~= z!Y|s}%ay&h;)uL8qK_6PD!pdS+p2Czn%>qKd?aeZ;*K`eynA0s*Pxq&3&+Baon*+> zUbY!DKAmuh_~#N#xj08}cuUbANP`Enj_lNsT%C?hGNa5qI6vl_(jku`rd#5Xz znPEM3vh0dJ=ADZYbNSC5O0%*-3Xi(7Ug#lDv|}p}*|00oB_OF`bK$Oh+&k_?U1R6u z>n+GO@85l{wAF~kw;*mW=(a2ITi_(IJw1Ru?@H!NRT!PdS)X^M&Q)Q74(cRNyHd>M zmB`VtHS+;iK7P@z=Q=RqR~P8neWgnH!@IhRVH$QN{x~oj{B|+xVOQcO37+9eFJjb{ zc+iXNc;pn=;Jy-nZU?sGiPNS&?vJX2+pesG4tz5gd=}rvT#2nr)Z-I<+?Dk-!5a+I zIaiuodYZxl=JNBdOsdD_5C-qTaaTIJbx4ZagT6i5uEft9U>0517x@`iiiRt7-}Ra6 zUD-7dW^uVIyVX`hNA{KY85(nSF0r@4-L7Pf{>Z%ts_#c#2@NSwlMC+hm3yBnLys$| zO$AX*Re0EyP|r=a=3FZVje6Xb_}fu&XXky{l^JycGfQaz6Gd)J z$AX4eS4y9obp!8v_VBoOE=ryvZ&cQ~;HQVUD`64OWle>azmf3`yx4xf zDQSYjZnqojJTxotpDVd?Gn5Uop`-`8dkkGKN=#^1H5PfP3ITN`m&&-XWGHZ5U3^~& zGxo0FcwIc`6@Y;kGjR}C4zOr)4wcFQAWCu)R}PT9ASYq*NX(Xzvxw!ocbw0hS>eE9 zIYAh=2Tty>ra=hPaZHJMJp+}7Ru}bNYzPk@&nNI zqo;P!*;~oUkHMr_zb*UC`a-=klGFd^YdT}6$xhbj^;^2XiGKk90DbICb1r@W0000< KMNUMnLSTZ?ISR=D literal 0 HcmV?d00001 diff --git a/server/dlna/data/static/openlist-48x48.png b/server/dlna/data/static/openlist-48x48.png new file mode 100644 index 0000000000000000000000000000000000000000..223c6e1bc3c5d8a9c47ff2bf59beef5b37b8928d GIT binary patch literal 815 zcmV+~1JL}5P)u-=wJ8!yZ`_IB6LztQve4I6doZgF*Zg~S7dW_gol!! zqO7;O!qCv%;pp!1_Wk@5@<*}&00KKnL_t(|+J#l&x`H4KRY{VIVTcBK@k03i?|8pt zWFXf02Ml($osD9LvL-F-x~`=d6<<+eHcYd%&N*we;DelVMN61 zK)$nTCfJ?3I?dc@;cP(LLlX8B$<&4gCtVTH!Ooa3E7cGj354{XWj4aG@@oO%;^GLc z@#Uc4Agnyj*Zy)!LKp8Y7$l(t`}?vix8w$%{G)3W+$Xewq}-M#E=3G@m9GTXLpTE`qaVb(@4b_ft zx_bw=sz`x+yNG!A>h1r|nf{f&5@n9iE8+@mhvV87()*4|u-s3uW>N_pJdEHWL+A(A z%sv%~;C4hQDnTEK*tQV#lm=a4c z4amV>6J^p>P-s~lX@VM0fyHg#9ciLgaThll#2N^tn&2=eLRSr2R|2893&RxZzaIOf^qtO3S!(IhNI&_?ewPuo>pyE4FLtDmJOK_Dr{RiGqd0B%)f)@Y)002ovPDHLkV1hYthHn4> literal 0 HcmV?d00001 diff --git a/server/dlna/data/static/rootDesc.xml.tmpl b/server/dlna/data/static/rootDesc.xml.tmpl new file mode 100644 index 000000000..436baa3f3 --- /dev/null +++ b/server/dlna/data/static/rootDesc.xml.tmpl @@ -0,0 +1,66 @@ + + + + 1 + 0 + + + urn:schemas-upnp-org:device:MediaServer:1 + {{.FriendlyName}} + openlist (openlist.org) + https://openlist.org/ + openlist + openlist + {{.ModelNumber}} + https://openlist.org/ + 00000000 + {{.RootDeviceUUID}} + + DMS-1.50 + M-DMS-1.50 + smi,DCM10,getMediaInfo.sec,getCaptionInfo.sec + smi,DCM10,getMediaInfo.sec,getCaptionInfo.sec + + + image/png + 48 + 48 + 8 + /static/openlist-48x48.png + + + image/png + 120 + 120 + 8 + /static/openlist-120x120.png + + + + + urn:schemas-upnp-org:service:ContentDirectory:1 + urn:upnp-org:serviceId:ContentDirectory + /static/ContentDirectory.xml + /ctl + + + + urn:schemas-upnp-org:service:ConnectionManager:1 + urn:upnp-org:serviceId:ConnectionManager + /static/ConnectionManager.xml + /ctl + + + + urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1 + urn:microsoft.com:serviceId:X_MS_MediaReceiverRegistrar + /static/X_MS_MediaReceiverRegistrar.xml + /ctl + + + + / + + \ No newline at end of file diff --git a/server/dlna/dlna.go b/server/dlna/dlna.go new file mode 100644 index 000000000..b7df0a10a --- /dev/null +++ b/server/dlna/dlna.go @@ -0,0 +1,429 @@ +//go:build go1.21 + +// Package dlna provides DLNA server. +package dlna + +import ( + "bytes" + "encoding/xml" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/fs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/OpenListTeam/OpenList/v4/server/dlna/data" + "github.com/OpenListTeam/OpenList/v4/server/dlna/dlnaflags" + dms_dlna "github.com/anacrolix/dms/dlna" + "github.com/anacrolix/dms/soap" + "github.com/anacrolix/dms/ssdp" + "github.com/anacrolix/dms/upnp" + alog "github.com/anacrolix/log" + log "github.com/sirupsen/logrus" +) + +func Run(opt *dlnaflags.Options) error { + s, err := newServer(opt) + if err != nil { + return err + } + if err := s.Serve(); err != nil { + return err + } + s.Wait() + return nil +} + +const ( + serverField = "Linux/3.4 DLNADOC/1.50 UPnP/1.0 DMS/1.0" + rootDescPath = "/rootDesc.xml" + resPath = "/r/" + serviceControlURL = "/ctl" +) + +type server struct { + // The service SOAP handler keyed by service URN. + services map[string]UPnPService + + Interfaces []net.Interface + + HTTPConn net.Listener + httpListenAddr string + handler http.Handler + + RootDeviceUUID string + + FriendlyName string + + // For waiting on the listener to close + waitChan chan struct{} + + // Time interval between SSPD announces + AnnounceInterval time.Duration + + RootDir string +} + +func newServer(opt *dlnaflags.Options) (*server, error) { + friendlyName := opt.FriendlyName + if friendlyName == "" { + friendlyName = makeDefaultFriendlyName() + } + interfaces := make([]net.Interface, 0, len(opt.InterfaceNames)) + for _, interfaceName := range opt.InterfaceNames { + var err error + intf, err := net.InterfaceByName(interfaceName) + if err != nil { + return nil, fmt.Errorf("failed to resolve interface name '%s': %w", interfaceName, err) + } + if !isAppropriatelyConfigured(*intf) { + return nil, fmt.Errorf("interface '%s' is not appropriately configured (it should be UP, MULTICAST and MTU > 0)", interfaceName) + } + interfaces = append(interfaces, *intf) + } + if len(interfaces) == 0 { + interfaces = listInterfaces() + } + + s := &server{ + AnnounceInterval: opt.AnnounceInterval, + FriendlyName: friendlyName, + RootDeviceUUID: makeDeviceUUID(friendlyName), + Interfaces: interfaces, + waitChan: make(chan struct{}), + httpListenAddr: opt.ListenAddr, + RootDir: opt.RootDir, + } + + s.services = map[string]UPnPService{ + "ContentDirectory": &contentDirectoryService{ + server: s, + }, + "ConnectionManager": &connectionManagerService{ + server: s, + }, + "X_MS_MediaReceiverRegistrar": &mediaReceiverRegistrarService{ + server: s, + }, + } + + // Setup the various http routes. + r := http.NewServeMux() + 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))) + } else { + r.HandleFunc(rootDescPath, s.rootDescHandler) + r.HandleFunc(serviceControlURL, s.serviceControlHandler) + } + r.Handle("/static/", http.StripPrefix("/static/", + withHeader("Cache-Control", "public, max-age=86400", + http.FileServer(data.Assets)))) + s.handler = logging(withHeader("Server", serverField, r)) + + return s, nil +} + +// UPnPService is the interface for the SOAP service. +type UPnPService interface { + Handle(action string, argsXML []byte, r *http.Request) (respArgs map[string]string, err error) + Subscribe(callback []*url.URL, timeoutSeconds int) (sid string, actualTimeout int, err error) + Unsubscribe(sid string) error +} + +// Formats the server as a string (used for logging.) +func (s *server) String() string { + return fmt.Sprintf("DLNA server on %v", s.httpListenAddr) +} + +// Returns version number as the model number. +func (s *server) ModelNumber() string { + return conf.VERSION +} + +// Renders the root device descriptor. +func (s *server) rootDescHandler(w http.ResponseWriter, r *http.Request) { + tmpl, err := data.GetTemplate() + if err != nil { + serveError(s, w, "Failed to load root descriptor template", err) + return + } + + buffer := new(bytes.Buffer) + err = tmpl.Execute(buffer, s) + if err != nil { + serveError(s, w, "Failed to render root descriptor XML", err) + return + } + + w.Header().Set("content-type", `text/xml; charset="utf-8"`) + w.Header().Set("cache-control", "private, max-age=60") + w.Header().Set("content-length", strconv.FormatInt(int64(buffer.Len()), 10)) + _, err = buffer.WriteTo(w) + if err != nil { + // Network error + log.Debugf("Error writing rootDesc: %v", err) + } +} + +// Handle a service control HTTP request. +func (s *server) serviceControlHandler(w http.ResponseWriter, r *http.Request) { + soapActionString := r.Header.Get("SOAPACTION") + soapAction, err := upnp.ParseActionHTTPHeader(soapActionString) + if err != nil { + serveError(s, w, "Could not parse SOAPACTION header", err) + return + } + var env soap.Envelope + if err := xml.NewDecoder(r.Body).Decode(&env); err != nil { + serveError(s, w, "Could not parse SOAP request body", err) + return + } + + w.Header().Set("Content-Type", `text/xml; charset="utf-8"`) + w.Header().Set("Ext", "") + soapRespXML, code := func() ([]byte, int) { + respArgs, err := s.soapActionResponse(soapAction, env.Body.Action, r) + if err != nil { + log.Errorf("Error invoking %v: %v", soapAction, err) + upnpErr := upnp.ConvertError(err) + return mustMarshalXML(soap.NewFault("UPnPError", upnpErr)), http.StatusInternalServerError + } + return marshalSOAPResponse(soapAction, respArgs), http.StatusOK + }() + bodyStr := fmt.Sprintf(`%s`, soapRespXML) + w.WriteHeader(code) + if _, err := w.Write([]byte(bodyStr)); err != nil { + log.Infof("Error writing response: %v", err) + } +} + +// Handle a SOAP request and return the response arguments or UPnP error. +func (s *server) soapActionResponse(sa upnp.SoapAction, actionRequestXML []byte, r *http.Request) (map[string]string, error) { + service, ok := s.services[sa.Type] + if !ok { + // TODO: What's the invalid service error? + return nil, upnp.Errorf(upnp.InvalidActionErrorCode, "Invalid service: %s", sa.Type) + } + return service.Handle(sa.Action, actionRequestXML, r) +} + +// Serves actual resources (media files). +func (s *server) resourceHandler(w http.ResponseWriter, r *http.Request) { + realPath := s.RootDir + "/" + r.URL.Path + ctx := r.Context() + node, err := fs.Get(ctx, realPath, &fs.GetArgs{}) + if err != nil { + http.NotFound(w, r) + return + } + if node.IsDir() { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Length", strconv.FormatInt(node.GetSize(), 10)) + + // add some DLNA specific headers + if r.Header.Get("getContentFeatures.dlna.org") != "" { + w.Header().Set("contentFeatures.dlna.org", dms_dlna.ContentFeatures{ + SupportRange: true, + }.String()) + } + w.Header().Set("transferMode.dlna.org", "Streaming") + + link, _, err := fs.Link(ctx, realPath, model.LinkArgs{Header: r.Header}) + if err != nil { + serveError(node, w, "Could not open resource", err) + return + } + err = common.Proxy(w, r, link, node) + if err != nil { + serveError(node, w, "dlna proxy error", err) + return + } +} + +// Serve runs the server - returns the error only if +// the listener was not started; does not block, so +// use s.Wait() to block on the listener indefinitely. +func (s *server) Serve() (err error) { + if s.HTTPConn == nil { + // Currently, the SSDP server only listens on an IPv4 multicast address. + // Differentiate between two INADDR_ANY addresses, + // so that 0.0.0.0 can only listen on IPv4 addresses. + network := "tcp4" + if strings.Count(s.httpListenAddr, ":") > 1 { + network = "tcp" + } + s.HTTPConn, err = net.Listen(network, s.httpListenAddr) + if err != nil { + return + } + } + + go func() { + s.startSSDP() + }() + + go func() { + utils.Log.Infof("Serving DLNA server on %s", s.HTTPConn.Addr().String()) + + err := s.serveHTTP() + if err != nil { + utils.Log.Infof("Error on serving DLNA server: %v", err) + } + }() + + return nil +} + +// Wait blocks while the listener is open. +func (s *server) Wait() { + <-s.waitChan +} + +func (s *server) Close() { + err := s.HTTPConn.Close() + if err != nil { + log.Errorf("Error closing HTTP server: %v", err) + return + } + close(s.waitChan) +} + +// Run SSDP (multicast for server discovery) on all interfaces. +func (s *server) startSSDP() { + active := 0 + stopped := make(chan struct{}) + for _, intf := range s.Interfaces { + active++ + go func(intf2 net.Interface) { + defer func() { + stopped <- struct{}{} + }() + s.ssdpInterface(intf2) + }(intf) + } + for active > 0 { + <-stopped + active-- + } +} + +// Run SSDP server on an interface. +func (s *server) ssdpInterface(intf net.Interface) { + // Figure out whether should an ip be announced + ipfilterFn := func(ip net.IP) bool { + listenaddr := s.HTTPConn.Addr().String() + listenip := listenaddr[:strings.LastIndex(listenaddr, ":")] + switch listenip { + case "0.0.0.0": + if strings.Contains(ip.String(), ":") { + // Any IPv6 address should not be announced + // because SSDP only listen on IPv4 multicast address + return false + } + return true + case "[::]": + // In the @Serve() section, the default settings have been made to not listen on IPv6 addresses. + // If actually still listening on [::], then allow to announce any address. + return true + default: + if listenip == ip.String() { + return true + } + return false + } + } + + // Figure out which HTTP location to advertise based on the interface IP. + advertiseLocationFn := func(ip net.IP) string { + url := url.URL{ + Scheme: "http", + Host: (&net.TCPAddr{ + IP: ip, + Port: s.HTTPConn.Addr().(*net.TCPAddr).Port, + }).String(), + Path: rootDescPath, + } + return url.String() + } + + _, err := intf.Addrs() + if err != nil { + panic(err) + } + + // Note that the devices and services advertised here via SSDP should be + // in agreement with the rootDesc XML descriptor that is defined above. + ssdpServer := ssdp.Server{ + Interface: intf, + Devices: []string{ + "urn:schemas-upnp-org:device:MediaServer:1"}, + Services: []string{ + "urn:schemas-upnp-org:service:ContentDirectory:1", + "urn:schemas-upnp-org:service:ConnectionManager:1", + "urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1"}, + IPFilter: ipfilterFn, + Location: advertiseLocationFn, + Server: serverField, + UUID: s.RootDeviceUUID, + NotifyInterval: s.AnnounceInterval, + Logger: alog.Default, + } + + // An interface with these flags should be valid for SSDP. + const ssdpInterfaceFlags = net.FlagUp | net.FlagMulticast + + if err := ssdpServer.Init(); err != nil { + if intf.Flags&ssdpInterfaceFlags != ssdpInterfaceFlags { + // Didn't expect it to work anyway. + return + } + if strings.Contains(err.Error(), "listen") { + // OSX has a lot of dud interfaces. Failure to create a socket on + // the interface are what we're expecting if the interface is no + // good. + return + } + log.Errorf("Error creating ssdp server on %s: %s", intf.Name, err) + return + } + defer ssdpServer.Close() + utils.Log.Infof("Started SSDP on %v", intf.Name) + stopped := make(chan struct{}) + go func() { + defer close(stopped) + if err := ssdpServer.Serve(); err != nil { + log.Errorf("%q: %q\n", intf.Name, err) + } + }() + select { + case <-s.waitChan: + // Returning will close the server. + case <-stopped: + } +} + +func (s *server) serveHTTP() error { + srv := &http.Server{ + Handler: s.handler, + } + err := srv.Serve(s.HTTPConn) + select { + case <-s.waitChan: + return nil + default: + return err + } +} diff --git a/server/dlna/dlna_util.go b/server/dlna/dlna_util.go new file mode 100644 index 000000000..dea50c3b0 --- /dev/null +++ b/server/dlna/dlna_util.go @@ -0,0 +1,206 @@ +//go:build go1.21 + +package dlna + +import ( + "crypto/md5" + "encoding/xml" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "os" + + "github.com/anacrolix/dms/soap" + "github.com/anacrolix/dms/upnp" + log "github.com/sirupsen/logrus" +) + +// Return a default "friendly name" for the server. +func makeDefaultFriendlyName() string { + hostName, err := os.Hostname() + if err != nil { + hostName = "" + } else { + hostName = " (" + hostName + ")" + } + return "openlist" + hostName +} + +func makeDeviceUUID(unique string) string { + h := md5.New() + if _, err := io.WriteString(h, unique); err != nil { + log.Panicf("makeDeviceUUID write failed: %s", err) + } + buf := h.Sum(nil) + return upnp.FormatUUID(buf) +} + +// Get all available active network interfaces. +func listInterfaces() []net.Interface { + ifs, err := net.Interfaces() + if err != nil { + log.Printf("list network interfaces: %v", err) + return []net.Interface{} + } + + var active []net.Interface + for _, intf := range ifs { + if isAppropriatelyConfigured(intf) { + active = append(active, intf) + } + } + return active +} + +func isAppropriatelyConfigured(intf net.Interface) bool { + return intf.Flags&net.FlagUp != 0 && intf.Flags&net.FlagMulticast != 0 && intf.MTU > 0 +} + +func didlLite(chardata string) string { + return `` + + chardata + + `` +} + +func mustMarshalXML(value interface{}) []byte { + ret, err := xml.MarshalIndent(value, "", " ") + if err != nil { + log.Panicf("mustMarshalXML failed to marshal %v: %s", value, err) + } + return ret +} + +// Marshal SOAP response arguments into a response XML snippet. +func marshalSOAPResponse(sa upnp.SoapAction, args map[string]string) []byte { + soapArgs := make([]soap.Arg, 0, len(args)) + for argName, value := range args { + soapArgs = append(soapArgs, soap.Arg{ + XMLName: xml.Name{Local: argName}, + Value: value, + }) + } + return []byte(fmt.Sprintf(`%[3]s`, + sa.Action, sa.ServiceURN.String(), mustMarshalXML(soapArgs))) +} + +type loggingResponseWriter struct { + http.ResponseWriter + request *http.Request + committed bool +} + +func (lrw *loggingResponseWriter) logRequest(code int, err interface{}) { + // Choose appropriate log level based on response status code. + logfn := log.Errorf + if code < 400 && err == nil { + logfn = log.Infof + } + + if err == nil { + err = "" + } + + path, e := url.QueryUnescape(lrw.request.URL.EscapedPath()) + if e != nil { + path = lrw.request.URL.EscapedPath() + } + + logfn("%s %s %s %d %s %s", + path, + lrw.request.RemoteAddr, lrw.request.Method, code, + lrw.request.Header.Get("SOAPACTION"), err) +} + +func (lrw *loggingResponseWriter) WriteHeader(code int) { + lrw.committed = true + lrw.logRequest(code, nil) + lrw.ResponseWriter.WriteHeader(code) +} + +// HTTP handler that logs requests and any errors or panics. +func logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lrw := &loggingResponseWriter{ResponseWriter: w, request: r} + defer func() { + err := recover() + if err != nil { + if !lrw.committed { + lrw.logRequest(http.StatusInternalServerError, err) + http.Error(w, fmt.Sprint(err), http.StatusInternalServerError) + } else { + // Too late to send the error to client, but at least log it. + log.Errorf("Recovered panic: %v", err) + } + } + }() + next.ServeHTTP(lrw, r) + }) +} + +// HTTP handler that logs complete request and response bodies for debugging. +// Error recovery and general request logging are left to logging(). +func traceLogging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + dump, err := httputil.DumpRequest(r, true) + if err != nil { + serveError(nil, w, "error dumping request", err) + return + } + log.Debugf("%s", dump) + + recorder := httptest.NewRecorder() + next.ServeHTTP(recorder, r) + + dump, err = httputil.DumpResponse(recorder.Result(), true) + if err != nil { + // log the error but ignore it + log.Errorf("error dumping response: %v", err) + } else { + log.Debugf("%s", dump) + } + + // copy from recorder to the real response writer + for k, v := range recorder.Header() { + w.Header()[k] = v + } + w.WriteHeader(recorder.Code) + _, err = recorder.Body.WriteTo(w) + if err != nil { + // Network error + log.Debugf("Error writing response: %v", err) + } + }) +} + +// HTTP handler that sets headers. +func withHeader(name string, value string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(name, value) + next.ServeHTTP(w, r) + }) +} + +// serveError returns an http.StatusInternalServerError and logs the error +func serveError(what interface{}, w http.ResponseWriter, text string, err error) { + log.Errorf("%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/server/dlna/dlnaflags/dlnaflags.go b/server/dlna/dlnaflags/dlnaflags.go new file mode 100644 index 000000000..5584428bd --- /dev/null +++ b/server/dlna/dlnaflags/dlnaflags.go @@ -0,0 +1,16 @@ +// Package dlnaflags provides utility functionality to DLNA. +package dlnaflags + +import ( + "time" +) + +// Options is the type for DLNA serving options. +type Options struct { + ListenAddr string + FriendlyName string + LogTrace bool + InterfaceNames []string + AnnounceInterval time.Duration + RootDir string +} diff --git a/server/dlna/mrrs.go b/server/dlna/mrrs.go new file mode 100644 index 000000000..0cc72d8c7 --- /dev/null +++ b/server/dlna/mrrs.go @@ -0,0 +1,29 @@ +//go:build go1.21 + +package dlna + +import ( + "net/http" + + "github.com/anacrolix/dms/upnp" +) + +type mediaReceiverRegistrarService struct { + *server + upnp.Eventing +} + +func (mrrs *mediaReceiverRegistrarService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) { + switch action { + case "IsAuthorized", "IsValidated": + return map[string]string{ + "Result": "1", + }, nil + case "RegisterDevice": + return map[string]string{ + "RegistrationRespMsg": mrrs.RootDeviceUUID, + }, nil + default: + return nil, upnp.InvalidActionError + } +} diff --git a/server/dlna/upnpav/upnpav.go b/server/dlna/upnpav/upnpav.go new file mode 100644 index 000000000..c6dc9dc4f --- /dev/null +++ b/server/dlna/upnpav/upnpav.go @@ -0,0 +1,64 @@ +// Package upnpav provides utilities for DLNA server. +package upnpav + +import ( + "encoding/xml" + "time" +) + +const ( + // NoSuchObjectErrorCode : The specified ObjectID is invalid. + NoSuchObjectErrorCode = 701 +) + +// Resource description +type Resource struct { + XMLName xml.Name `xml:"res"` + ProtocolInfo string `xml:"protocolInfo,attr"` + URL string `xml:",chardata"` + Size uint64 `xml:"size,attr,omitempty"` + Bitrate uint `xml:"bitrate,attr,omitempty"` + Duration string `xml:"duration,attr,omitempty"` + Resolution string `xml:"resolution,attr,omitempty"` +} + +// Container description +type Container struct { + Object + XMLName xml.Name `xml:"container"` + ChildCount *int `xml:"childCount,attr"` +} + +// Item description +type Item struct { + Object + XMLName xml.Name `xml:"item"` + Res []Resource + InnerXML string `xml:",innerxml"` +} + +// Object description +type Object struct { + ID string `xml:"id,attr"` + ParentID string `xml:"parentID,attr"` + Restricted int `xml:"restricted,attr"` // indicates whether the object is modifiable + Class string `xml:"upnp:class"` + Icon string `xml:"upnp:icon,omitempty"` + Title string `xml:"dc:title"` + Date Timestamp `xml:"dc:date"` + Artist string `xml:"upnp:artist,omitempty"` + Album string `xml:"upnp:album,omitempty"` + Genre string `xml:"upnp:genre,omitempty"` + AlbumArtURI string `xml:"upnp:albumArtURI,omitempty"` + Searchable int `xml:"searchable,attr"` +} + +// Timestamp wraps time.Time for formatting purposes +type Timestamp struct { + time.Time +} + +// MarshalXML formats the Timestamp per DIDL-Lite spec +func (t Timestamp) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return e.EncodeElement(t.Format("2006-01-02"), start) +}