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 000000000..71911bf48
Binary files /dev/null and b/cmd/serve/dlna/testdata/files/small_jpeg.jpg differ
diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go
index 3c3e455f3..4ab583188 100644
--- a/cmd/serve/serve.go
+++ b/cmd/serve/serve.go
@@ -3,6 +3,8 @@ package serve
import (
"errors"
+ "github.com/ncw/rclone/cmd/serve/dlna"
+
"github.com/ncw/rclone/cmd"
"github.com/ncw/rclone/cmd/serve/ftp"
"github.com/ncw/rclone/cmd/serve/http"
@@ -19,6 +21,9 @@ func init() {
if restic.Command != nil {
Command.AddCommand(restic.Command)
}
+ if dlna.Command != nil {
+ Command.AddCommand(dlna.Command)
+ }
if ftp.Command != nil {
Command.AddCommand(ftp.Command)
}