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