From 0b7fdf16a280cf0ed9245b2d1d1fef40cabea32a Mon Sep 17 00:00:00 2001 From: nicolov Date: Thu, 3 Jan 2019 23:23:50 +0100 Subject: [PATCH] serve: add dlna server --- cmd/serve/dlna/cd-service-desc.go | 451 +++++++++++++++++++ cmd/serve/dlna/cds.go | 240 ++++++++++ cmd/serve/dlna/dlna.go | 440 ++++++++++++++++++ cmd/serve/dlna/dlna_test.go | 88 ++++ cmd/serve/dlna/dlna_util.go | 52 +++ cmd/serve/dlna/dlnaflags/dlnaflags.go | 42 ++ cmd/serve/dlna/testdata/files/small_jpeg.jpg | Bin 0 -> 107 bytes cmd/serve/serve.go | 5 + 8 files changed, 1318 insertions(+) create mode 100644 cmd/serve/dlna/cd-service-desc.go create mode 100644 cmd/serve/dlna/cds.go create mode 100644 cmd/serve/dlna/dlna.go create mode 100644 cmd/serve/dlna/dlna_test.go create mode 100644 cmd/serve/dlna/dlna_util.go create mode 100644 cmd/serve/dlna/dlnaflags/dlnaflags.go create mode 100644 cmd/serve/dlna/testdata/files/small_jpeg.jpg diff --git a/cmd/serve/dlna/cd-service-desc.go b/cmd/serve/dlna/cd-service-desc.go new file mode 100644 index 000000000..9a00e2ea5 --- /dev/null +++ b/cmd/serve/dlna/cd-service-desc.go @@ -0,0 +1,451 @@ +package dlna + +const contentDirectoryServiceDescription = ` + + + 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 + + + + + + + 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 + + +` diff --git a/cmd/serve/dlna/cds.go b/cmd/serve/dlna/cds.go new file mode 100644 index 000000000..79330bc54 --- /dev/null +++ b/cmd/serve/dlna/cds.go @@ -0,0 +1,240 @@ +package dlna + +import ( + "encoding/xml" + "fmt" + "log" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "sort" + + "github.com/anacrolix/dms/dlna" + "github.com/anacrolix/dms/upnp" + "github.com/anacrolix/dms/upnpav" + "github.com/ncw/rclone/vfs" + "github.com/pkg/errors" +) + +type contentDirectoryService struct { + *server + upnp.Eventing +} + +func (cds *contentDirectoryService) updateIDString() string { + return fmt.Sprintf("%d", uint32(os.Getpid())) +} + +// 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 os.FileInfo, host string) (ret interface{}, err error) { + obj := upnpav.Object{ + ID: cdsObject.ID(), + Restricted: 1, + ParentID: cdsObject.ParentID(), + } + + if fileInfo.IsDir() { + obj.Class = "object.container.storageFolder" + obj.Title = fileInfo.Name() + ret = upnpav.Container{Object: obj} + return + } + + if !fileInfo.Mode().IsRegular() { + return + } + + // Hardcode "videoItem" so that files show up in VLC. + obj.Class = "object.item.videoItem" + obj.Title = fileInfo.Name() + + 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: resPath, + RawQuery: url.Values{ + "path": {cdsObject.Path}, + }.Encode(), + }).String(), + // Hardcode "video/x-matroska" so that files show up in VLC. + ProtocolInfo: fmt.Sprintf("http-get:*:video/x-matroska:%s", dlna.ContentFeatures{ + SupportRange: true, + }.String()), + Bitrate: 0, + Duration: "", + Size: uint64(fileInfo.Size()), + Resolution: "", + }) + + 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 + } + + sort.Sort(dirEntries) + + for _, de := range dirEntries { + child := object{ + path.Join(o.Path, de.Name()), + } + obj, err := cds.cdsObjectToUpnpavObject(child, de, host) + if err != nil { + log.Printf("error with %s: %s", child.FilePath(), err) + continue + } + if obj != nil { + ret = append(ret, obj) + } else { + log.Printf("bad %s", de) + } + } + + return +} + +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([]byte(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 && int(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 + default: + return nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, "unhandled browse flag: %v", browse.BrowseFlag) + } + case "GetSearchCapabilities": + return map[string]string{ + "SearchCaps": "", + }, 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/cmd/serve/dlna/dlna.go b/cmd/serve/dlna/dlna.go new file mode 100644 index 000000000..960e7db32 --- /dev/null +++ b/cmd/serve/dlna/dlna.go @@ -0,0 +1,440 @@ +package dlna + +import ( + "bytes" + "encoding/xml" + "fmt" + "log" + "net" + "net/http" + "net/url" + "os" + "path" + "strconv" + "strings" + "time" + + "github.com/anacrolix/dms/soap" + "github.com/anacrolix/dms/ssdp" + "github.com/anacrolix/dms/upnp" + "github.com/ncw/rclone/cmd" + "github.com/ncw/rclone/cmd/serve/dlna/dlnaflags" + "github.com/ncw/rclone/fs" + "github.com/ncw/rclone/vfs" + "github.com/ncw/rclone/vfs/vfsflags" + "github.com/spf13/cobra" +) + +func init() { + dlnaflags.AddFlags(Command.Flags()) + vfsflags.AddFlags(Command.Flags()) +} + +// Command definition for cobra. +var Command = &cobra.Command{ + Use: "dlna remote:path", + Short: `Serve remote:path over DLNA`, + Long: `rclone serve dlna is a DLNA media server for media stored in a rclone remote. Many +devices, such as the Xbox and PlayStation, can automatically discover this server in the LAN +and play audio/video from it. VLC is also supported. Service discovery uses UDP multicast +packets (SSDP) and will thus only work on LANs. + +Rclone will list all files present in the remote, without filtering based on media formats or +file extensions. Additionally, there is no media transcoding support. This means that some +players might show files that they are not able to play back correctly. + +` + dlnaflags.Help + vfs.Help, + Run: func(command *cobra.Command, args []string) { + cmd.CheckArgs(1, 1, command, args) + f := cmd.NewFsSrc(args) + + cmd.Run(false, false, command, func() error { + s := newServer(f, &dlnaflags.Opt) + if err := s.Serve(); err != nil { + log.Fatal(err) + } + s.Wait() + return nil + }) + }, +} + +const ( + serverField = "Linux/3.4 DLNADOC/1.50 UPnP/1.0 DMS/1.0" + rootDeviceType = "urn:schemas-upnp-org:device:MediaServer:1" + rootDeviceModelName = "rclone" + resPath = "/res" + rootDescPath = "/rootDesc.xml" + serviceControlURL = "/ctl" +) + +// Groups the service definition with its XML description. +type service struct { + upnp.Service + SCPD string +} + +// Exposed UPnP AV services. +var services = []*service{ + { + Service: upnp.Service{ + ServiceType: "urn:schemas-upnp-org:service:ContentDirectory:1", + ServiceId: "urn:upnp-org:serviceId:ContentDirectory", + ControlURL: serviceControlURL, + }, + SCPD: contentDirectoryServiceDescription, + }, +} + +func devices() []string { + return []string{ + "urn:schemas-upnp-org:device:MediaServer:1", + } +} + +func serviceTypes() (ret []string) { + for _, s := range services { + ret = append(ret, s.ServiceType) + } + return +} + +type server struct { + // The service SOAP handler keyed by service URN. + services map[string]UPnPService + + Interfaces []net.Interface + + HTTPConn net.Listener + httpListenAddr string + httpServeMux *http.ServeMux + + rootDeviceUUID string + rootDescXML []byte + + FriendlyName string + + // For waiting on the listener to close + waitChan chan struct{} + + // Time interval between SSPD announces + AnnounceInterval time.Duration + + f fs.Fs + vfs *vfs.VFS +} + +func newServer(f fs.Fs, opt *dlnaflags.Options) *server { + hostName, err := os.Hostname() + if err != nil { + hostName = "" + } else { + hostName = " (" + hostName + ")" + } + + s := &server{ + AnnounceInterval: 10 * time.Second, + FriendlyName: "rclone" + hostName, + + httpListenAddr: opt.ListenAddr, + + f: f, + vfs: vfs.New(f, &vfsflags.Opt), + } + + s.initServicesMap() + s.listInterfaces() + + s.httpServeMux = http.NewServeMux() + s.rootDeviceUUID = makeDeviceUUID(s.FriendlyName) + s.rootDescXML, err = xml.MarshalIndent( + upnp.DeviceDesc{ + SpecVersion: upnp.SpecVersion{Major: 1, Minor: 0}, + Device: upnp.Device{ + DeviceType: rootDeviceType, + FriendlyName: s.FriendlyName, + Manufacturer: "rclone (rclone.org)", + ModelName: rootDeviceModelName, + UDN: s.rootDeviceUUID, + ServiceList: func() (ss []upnp.Service) { + for _, s := range services { + ss = append(ss, s.Service) + } + return + }(), + }, + }, + " ", " ") + if err != nil { + // Contents are hardcoded, so this will never happen in production. + log.Panicf("Marshal root descriptor XML: %v", err) + } + s.rootDescXML = append([]byte(``), s.rootDescXML...) + s.initMux(s.httpServeMux) + + return s +} + +// 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 +} + +// initServicesMap is called during initialization of the server to prepare some internal datastructures. +func (s *server) initServicesMap() { + urn, err := upnp.ParseServiceType(services[0].ServiceType) + if err != nil { + // The service type is hardcoded, so this error should never happen. + log.Panicf("ParseServiceType: %v", err) + } + s.services = map[string]UPnPService{ + urn.Type: &contentDirectoryService{ + server: s, + }, + } + return +} + +// listInterfaces is called during initialization of the server to list the network interfaces +// on the machine. +func (s *server) listInterfaces() { + ifs, err := net.Interfaces() + if err != nil { + fs.Errorf(s.f, "list network interfaces: %v", err) + return + } + + var tmp []net.Interface + for _, intf := range ifs { + if intf.Flags&net.FlagUp == 0 || intf.MTU <= 0 { + continue + } + s.Interfaces = append(s.Interfaces, intf) + tmp = append(tmp, intf) + } +} + +func (s *server) initMux(mux *http.ServeMux) { + mux.HandleFunc(resPath, func(w http.ResponseWriter, r *http.Request) { + remotePath := r.URL.Query().Get("path") + node, err := s.vfs.Stat(remotePath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Length", strconv.FormatInt(node.Size(), 10)) + + file := node.(*vfs.File) + in, err := file.Open(os.O_RDONLY) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + defer fs.CheckClose(in, &err) + + http.ServeContent(w, r, remotePath, node.ModTime(), in) + return + }) + + mux.HandleFunc(rootDescPath, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", `text/xml; charset="utf-8"`) + w.Header().Set("content-length", fmt.Sprint(len(s.rootDescXML))) + w.Header().Set("server", serverField) + _, err := w.Write(s.rootDescXML) + if err != nil { + fs.Errorf(s, "Failed to serve root descriptor XML: %v", err) + } + }) + + // Install handlers to serve SCPD for each UPnP service. + for _, s := range services { + p := path.Join("/scpd", s.ServiceId) + s.SCPDURL = p + + mux.HandleFunc(s.SCPDURL, func(serviceDesc string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", `text/xml; charset="utf-8"`) + http.ServeContent(w, r, ".xml", time.Time{}, bytes.NewReader([]byte(serviceDesc))) + } + }(s.SCPD)) + } + + mux.HandleFunc(serviceControlURL, s.serviceControlHandler) +} + +// 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 { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var env soap.Envelope + if err := xml.NewDecoder(r.Body).Decode(&env); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", `text/xml; charset="utf-8"`) + w.Header().Set("Ext", "") + w.Header().Set("server", serverField) + soapRespXML, code := func() ([]byte, int) { + respArgs, err := s.soapActionResponse(soapAction, env.Body.Action, r) + if err != nil { + upnpErr := upnp.ConvertError(err) + return mustMarshalXML(soap.NewFault("UPnPError", upnpErr)), 500 + } + return marshalSOAPResponse(soapAction, respArgs), 200 + }() + bodyStr := fmt.Sprintf(`%s`, soapRespXML) + w.WriteHeader(code) + if _, err := w.Write([]byte(bodyStr)); err != nil { + log.Print(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) +} + +// 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 { + s.HTTPConn, err = net.Listen("tcp", s.httpListenAddr) + if err != nil { + return + } + } + + go func() { + s.startSSDP() + }() + + go func() { + fs.Logf(s.f, "Serving HTTP on %s", s.HTTPConn.Addr().String()) + + err = s.serveHTTP() + if err != nil { + fs.Logf(s.f, "Error on serving HTTP 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 { + fs.Errorf(s.f, "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 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() + } + + ssdpServer := ssdp.Server{ + Interface: intf, + Devices: devices(), + Services: serviceTypes(), + Location: advertiseLocationFn, + Server: serverField, + UUID: s.rootDeviceUUID, + NotifyInterval: s.AnnounceInterval, + } + + // 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.Printf("Error creating ssdp server on %s: %s", intf.Name, err) + return + } + defer ssdpServer.Close() + log.Println("Started SSDP on", intf.Name) + stopped := make(chan struct{}) + go func() { + defer close(stopped) + if err := ssdpServer.Serve(); err != nil { + log.Printf("%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: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.httpServeMux.ServeHTTP(w, r) + }), + } + err := srv.Serve(s.HTTPConn) + select { + case <-s.waitChan: + return nil + default: + return err + } +} diff --git a/cmd/serve/dlna/dlna_test.go b/cmd/serve/dlna/dlna_test.go new file mode 100644 index 000000000..0f5c59188 --- /dev/null +++ b/cmd/serve/dlna/dlna_test.go @@ -0,0 +1,88 @@ +// +build go1.8 + +package dlna + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "testing" + + "github.com/ncw/rclone/vfs" + + _ "github.com/ncw/rclone/backend/local" + "github.com/ncw/rclone/cmd/serve/dlna/dlnaflags" + "github.com/ncw/rclone/fs" + "github.com/ncw/rclone/fs/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + dlnaServer *server +) + +const ( + testBindAddress = "localhost:51777" + testURL = "http://" + testBindAddress + "/" +) + +func startServer(t *testing.T, f fs.Fs) { + opt := dlnaflags.DefaultOpt + opt.ListenAddr = testBindAddress + dlnaServer = newServer(f, &opt) + assert.NoError(t, dlnaServer.Serve()) +} + +func TestInit(t *testing.T) { + config.LoadConfig() + + f, err := fs.NewFs("testdata/files") + l, _ := f.List("") + fmt.Println(l) + require.NoError(t, err) + + startServer(t, f) +} + +// Make sure that it serves rootDesc.xml (SCPD in uPnP parlance). +func TestRootSCPD(t *testing.T) { + req, err := http.NewRequest("GET", testURL+"rootDesc.xml", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + // Make sure that the SCPD contains a CDS service. + require.Contains(t, string(body), + "urn:schemas-upnp-org:service:ContentDirectory:1") +} + +// Make sure that it serves content from the remote. +func TestServeContent(t *testing.T) { + itemPath := "/small_jpeg.jpg" + pathQuery := url.QueryEscape(itemPath) + req, err := http.NewRequest("GET", testURL+"res?path="+pathQuery, nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer fs.CheckClose(resp.Body, &err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + actualContents, err := ioutil.ReadAll(resp.Body) + assert.NoError(t, err) + + // Now compare the contents with the golden file. + node, err := dlnaServer.vfs.Stat(itemPath) + assert.NoError(t, err) + goldenFile := node.(*vfs.File) + goldenReader, err := goldenFile.Open(os.O_RDONLY) + assert.NoError(t, err) + defer fs.CheckClose(goldenReader, &err) + goldenContents, err := ioutil.ReadAll(goldenReader) + assert.NoError(t, err) + + require.Equal(t, goldenContents, actualContents) +} diff --git a/cmd/serve/dlna/dlna_util.go b/cmd/serve/dlna/dlna_util.go new file mode 100644 index 000000000..d89648f18 --- /dev/null +++ b/cmd/serve/dlna/dlna_util.go @@ -0,0 +1,52 @@ +package dlna + +import ( + "crypto/md5" + "encoding/xml" + "fmt" + "io" + "log" + + "github.com/anacrolix/dms/soap" + "github.com/anacrolix/dms/upnp" +) + +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) +} + +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))) +} diff --git a/cmd/serve/dlna/dlnaflags/dlnaflags.go b/cmd/serve/dlna/dlnaflags/dlnaflags.go new file mode 100644 index 000000000..ba820372e --- /dev/null +++ b/cmd/serve/dlna/dlnaflags/dlnaflags.go @@ -0,0 +1,42 @@ +package dlnaflags + +import ( + "github.com/ncw/rclone/fs/config/flags" + "github.com/ncw/rclone/fs/rc" + "github.com/spf13/pflag" +) + +// Help contains the text for the command line help and manual. +var Help = ` +### Server options + +Use --addr to specify which IP address and port the server should +listen on, eg --addr 1.2.3.4:8000 or --addr :8080 to listen to all +IPs. + +` + +// Options is the type for DLNA serving options. +type Options struct { + ListenAddr string +} + +// DefaultOpt contains the defaults options for DLNA serving. +var DefaultOpt = Options{ + ListenAddr: ":7879", +} + +// Opt contains the options for DLNA serving. +var ( + Opt = DefaultOpt +) + +func addFlagsPrefix(flagSet *pflag.FlagSet, prefix string, Opt *Options) { + rc.AddOption("dlna", &Opt) + flags.StringVarP(flagSet, &Opt.ListenAddr, prefix+"addr", "", Opt.ListenAddr, "ip:port or :port to bind the DLNA http server to.") +} + +// AddFlags add the command line flags for DLNA serving. +func AddFlags(flagSet *pflag.FlagSet) { + addFlagsPrefix(flagSet, "", &Opt) +} diff --git a/cmd/serve/dlna/testdata/files/small_jpeg.jpg b/cmd/serve/dlna/testdata/files/small_jpeg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..71911bf48766c7181518c1070911019fbb00b1fc GIT binary patch literal 107 zcmWm0!3~2z3