diff --git a/dlna/connectionmanagerservice.go b/dlna/connectionmanagerservice.go new file mode 100644 index 000000000..8f868d3d7 --- /dev/null +++ b/dlna/connectionmanagerservice.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 { + *DLNAServer + 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/dlna/contenddirectoryservice.go b/dlna/contenddirectoryservice.go new file mode 100644 index 000000000..694e7d743 --- /dev/null +++ b/dlna/contenddirectoryservice.go @@ -0,0 +1,349 @@ +//go:build go1.21 + +package dlna + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/anacrolix/dms/dlna" + "github.com/anacrolix/dms/upnp" +) + +type contentDirectoryService struct { + *DLNAServer + upnp.Eventing +} + +func (cds *contentDirectoryService) updateIDString() string { + return fmt.Sprintf("%d", uint32(os.Getpid())) +} + +var mediaMimeTypeRegexp = regexp.MustCompile("^(video|audio|image)/") + +// 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 vfs.Node, resources vfs.Nodes, 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.Name() + return upnpav.Container{ + Object: obj, + ChildCount: &defaultChildCount, + }, nil + } + + if !fileInfo.Mode().IsRegular() { + return + } + + // Read the mime type from the fs.Object if possible, + // otherwise fall back to working out what it is from the file path. + var mimeType string + if o, ok := fileInfo.DirEntry().(fs.Object); ok { + mimeType = fs.MimeType(context.TODO(), o) + // If backend doesn't know what the mime type is then + // try getting it from the file name + if mimeType == "application/octet-stream" { + mimeType = fs.MimeTypeFromName(fileInfo.Name()) + } + } else { + mimeType = fs.MimeTypeFromName(fileInfo.Name()) + } + + mediaType := mediaMimeTypeRegexp.FindStringSubmatch(mimeType) + if mediaType == nil { + return + } + + obj.Class = "object.item." + mediaType[1] + "Item" + obj.Title = fileInfo.Name() + 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.Size()), + }) + + for _, resource := range resources { + subtitleURL := (&url.URL{ + Scheme: "http", + Host: host, + Path: path.Join(resPath, resource.Path()), + }).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 := cds.vfs.Stat(o.Path) + if err != nil { + return + } + + if !node.IsDir() { + err = errors.New("not a directory") + return + } + + dir := node.(*vfs.Dir) + dirEntries, err := dir.ReadDirAll() + if err != nil { + err = errors.New("failed to list directory") + return + } + + dirEntries, mediaResources := mediaWithResources(dirEntries) + for _, de := range dirEntries { + child := object{ + path.Join(o.Path, de.Name()), + } + obj, err := cds.cdsObjectToUpnpavObject(child, de, mediaResources[de], host) + if err != nil { + fs.Errorf(cds, "error with %s: %s", child.FilePath(), err) + continue + } + if obj == nil { + fs.Debugf(cds, "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 vfs.Nodes) (vfs.Nodes, map[vfs.Node]vfs.Nodes) { + media, mediaResources := vfs.Nodes{}, make(map[vfs.Node]vfs.Nodes) + + // First, separate out the subtitles and media into maps, keyed by their lowercase base names. + mediaByName, subtitlesByName := make(map[string]vfs.Nodes), make(map[string]vfs.Node) + for _, node := range nodes { + baseName, ext := splitExt(strings.ToLower(node.Name())) + 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 { + fs.Infof(node, "could not find associated media for subtitle: %s", node.Name()) + continue + } + + // Associate with all potential media nodes + fs.Debugf(mediaNodes, "associating subtitle: %s", node.Name()) + 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 +} + +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, err.Error()) + } + switch browse.BrowseFlag { + case "BrowseDirectChildren": + objs, err := cds.readContainer(obj, host) + if err != nil { + return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, err.Error()) + } + totalMatches := len(objs) + objs = objs[func() (low int) { + low = browse.StartingIndex + if low > len(objs) { + low = 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 := cds.vfs.Stat(obj.Path) + 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, vfs.Nodes{}, host) + if err != nil { + return nil, err + } + result, err := xml.Marshal(upnpObject) + if err != nil { + return nil, err + } + return map[string]string{ + "Result": didlLite(string(result)), + }, nil + default: + return nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, "unhandled browse flag: %v", browse.BrowseFlag) + } + case "GetSearchCapabilities": + 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/dlna/dlnaserver.go b/dlna/dlnaserver.go index ae0f1b3a1..eda01c832 100644 --- a/dlna/dlnaserver.go +++ b/dlna/dlnaserver.go @@ -57,6 +57,18 @@ func New(ds model.DataStore, broker events.Broker) *DLNAServer { s.ssdp.Interfaces = listInterfaces() s.ssdp.AnnounceInterval = time.Duration(30) * time.Second + s.ssdp.services = map[string]UPnPService { + "ContentDirectory": &contentDirectoryService{ + server: s, + }, + "ConnectionManager": &connectionManagerService{ + server: s, + }, + "X_MS_MediaReceiverRegistrar": &mediaReceiverRegistrarService{ + server: s, + }, + } + return s } diff --git a/dlna/mediareceiverregistrarservice.go b/dlna/mediareceiverregistrarservice.go new file mode 100644 index 000000000..0be03bdb2 --- /dev/null +++ b/dlna/mediareceiverregistrarservice.go @@ -0,0 +1,29 @@ +//go:build go1.21 + +package dlna + +import ( + "net/http" + + "github.com/anacrolix/dms/upnp" +) + +type mediaReceiverRegistrarService struct { + *DLNAServer + 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/dlna/upnpav/upnpav.go b/dlna/upnpav/upnpav.go new file mode 100644 index 000000000..c6dc9dc4f --- /dev/null +++ b/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) +}