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