serve dlna: fix SOAP response argument ordering for Samsung TV compatibility

Samsung TVs are strict DLNA clients that expect SOAP response arguments
in the order defined by the service SCPD (Service Control Protocol
Description). The Browse response was using a Go map which produces
random iteration order, causing arguments like Result, NumberReturned,
TotalMatches, and UpdateID to appear in unpredictable order. Samsung TVs
fail to parse such responses and never proceed to browse directory
children, showing "no content" to the user.

Replace the map[string]string return type with an ordered []soapArg
slice throughout the UPnPService.Handle() interface, ensuring response
arguments always appear in SCPD-defined order.

See #9346
This commit is contained in:
Nick Craig-Wood
2026-04-24 16:27:09 +01:00
parent 18aa4b2f29
commit 9b7f960a24
6 changed files with 121 additions and 48 deletions
+29 -26
View File
@@ -266,18 +266,18 @@ func (cds *contentDirectoryService) objectFromID(id string) (o object, err error
return
}
func (cds *contentDirectoryService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) {
func (cds *contentDirectoryService) Handle(action string, argsXML []byte, r *http.Request) ([]soapArg, error) {
host := r.Host
switch action {
case "GetSystemUpdateID":
return map[string]string{
"Id": cds.updateIDString(),
}, nil
return soapArgs(
"Id", cds.updateIDString(),
), nil
case "GetSortCapabilities":
return map[string]string{
"SortCaps": "dc:title",
}, nil
return soapArgs(
"SortCaps", "dc:title",
), nil
case "Browse":
var browse browse
if err := xml.Unmarshal(argsXML, &browse); err != nil {
@@ -305,12 +305,13 @@ func (cds *contentDirectoryService) Handle(action string, argsXML []byte, r *htt
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
// Argument order must match SCPD definition in ContentDirectory.xml
return soapArgs(
"Result", didlLite(string(result)),
"NumberReturned", fmt.Sprint(len(objs)),
"TotalMatches", fmt.Sprint(totalMatches),
"UpdateID", cds.updateIDString(),
), nil
case "BrowseMetadata":
node, err := cds.vfs.Stat(obj.Path)
if err != nil {
@@ -325,32 +326,34 @@ func (cds *contentDirectoryService) Handle(action string, argsXML []byte, r *htt
if err != nil {
return nil, err
}
return map[string]string{
"TotalMatches": "1",
"NumberReturned": "1",
"Result": didlLite(string(result)),
"UpdateID": cds.updateIDString(),
}, nil
// Argument order must match SCPD definition in ContentDirectory.xml
return soapArgs(
"Result", didlLite(string(result)),
"NumberReturned", "1",
"TotalMatches", "1",
"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
return soapArgs(
"SearchCaps", "",
), nil
// Samsung Extensions
case "X_GetFeatureList":
return map[string]string{
"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">
return soapArgs(
"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">
<Feature name="samsung.com_BASICVIEW" version="1">
<container id="0" type="object.item.imageItem"/>
<container id="0" type="object.item.audioItem"/>
<container id="0" type="object.item.videoItem"/>
</Feature>
</Features>`}, nil
</Features>`,
), nil
case "X_SetBookmark":
// just ignore
return map[string]string{}, nil
return nil, nil
default:
return nil, upnp.InvalidActionError
}
+5 -5
View File
@@ -13,13 +13,13 @@ type connectionManagerService struct {
upnp.Eventing
}
func (cms *connectionManagerService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) {
func (cms *connectionManagerService) Handle(action string, argsXML []byte, r *http.Request) ([]soapArg, error) {
switch action {
case "GetProtocolInfo":
return map[string]string{
"Source": defaultProtocolInfo,
"Sink": "",
}, nil
return soapArgs(
"Source", defaultProtocolInfo,
"Sink", "",
), nil
default:
return nil, upnp.InvalidActionError
}
+2 -2
View File
@@ -252,7 +252,7 @@ func newServer(ctx context.Context, f fs.Fs, opt *Options, vfsOpt *vfscommon.Opt
// 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)
Handle(action string, argsXML []byte, r *http.Request) (respArgs []soapArg, err error)
Subscribe(callback []*url.URL, timeoutSeconds int) (sid string, actualTimeout int, err error)
Unsubscribe(sid string) error
}
@@ -327,7 +327,7 @@ func (s *server) serviceControlHandler(w http.ResponseWriter, r *http.Request) {
}
// 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) {
func (s *server) soapActionResponse(sa upnp.SoapAction, actionRequestXML []byte, r *http.Request) ([]soapArg, error) {
service, ok := s.services[sa.Type]
if !ok {
// TODO: What's the invalid service error?
+45
View File
@@ -136,6 +136,51 @@ func TestContentDirectoryBrowseMetadata(t *testing.T) {
require.Contains(t, string(body), html.EscapeString("<dc:date>"))
}
// Check that Browse response arguments are in the SCPD-defined order.
// Samsung TVs require this specific ordering to work correctly.
// See: https://github.com/rclone/rclone/issues/9346
func TestContentDirectoryBrowseResponseOrder(t *testing.T) {
req, err := http.NewRequest("POST", baseURL+serviceControlURL, strings.NewReader(`
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
<ObjectID>0</ObjectID>
<BrowseFlag>BrowseMetadata</BrowseFlag>
<Filter>*</Filter>
<StartingIndex>0</StartingIndex>
<RequestedCount>0</RequestedCount>
<SortCriteria></SortCriteria>
</u:Browse>
</s:Body>
</s:Envelope>`))
require.NoError(t, err)
req.Header.Set("SOAPACTION", `"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"`)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
bodyStr := string(body)
// Verify that the response arguments appear in SCPD-defined order:
// Result, NumberReturned, TotalMatches, UpdateID
resultIdx := strings.Index(bodyStr, "<Result>")
numberReturnedIdx := strings.Index(bodyStr, "<NumberReturned>")
totalMatchesIdx := strings.Index(bodyStr, "<TotalMatches>")
updateIDIdx := strings.Index(bodyStr, "<UpdateID>")
require.NotEqual(t, -1, resultIdx, "Result element not found")
require.NotEqual(t, -1, numberReturnedIdx, "NumberReturned element not found")
require.NotEqual(t, -1, totalMatchesIdx, "TotalMatches element not found")
require.NotEqual(t, -1, updateIDIdx, "UpdateID element not found")
assert.Less(t, resultIdx, numberReturnedIdx, "Result should come before NumberReturned")
assert.Less(t, numberReturnedIdx, totalMatchesIdx, "NumberReturned should come before TotalMatches")
assert.Less(t, totalMatchesIdx, updateIDIdx, "TotalMatches should come before UpdateID")
}
// Check that the X_MS_MediaReceiverRegistrar is faked out properly.
func TestMediaReceiverRegistrarService(t *testing.T) {
env := soap.Envelope{
+33 -8
View File
@@ -77,17 +77,42 @@ func mustMarshalXML(value any) []byte {
return ret
}
// soapArg is an ordered SOAP response argument.
type soapArg struct {
name string
value string
}
// soapArgs creates a list of soapArg from pairs of name, value strings.
// Panics if an odd number of strings is provided.
func soapArgs(nameValuePairs ...string) []soapArg {
if len(nameValuePairs)%2 != 0 {
fs.Panicf(nil, "soapArgs: odd number of arguments")
}
args := make([]soapArg, len(nameValuePairs)/2)
for i := range args {
args[i] = soapArg{
name: nameValuePairs[i*2],
value: nameValuePairs[i*2+1],
}
}
return args
}
// 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,
})
// Argument order is preserved from the input slice, which is important
// for compatibility with strict DLNA clients like Samsung TVs that
// expect arguments in the order defined by the service SCPD.
func marshalSOAPResponse(sa upnp.SoapAction, args []soapArg) []byte {
xmlArgs := make([]soap.Arg, len(args))
for i, arg := range args {
xmlArgs[i] = soap.Arg{
XMLName: xml.Name{Local: arg.name},
Value: arg.value,
}
}
return fmt.Appendf(nil, `<u:%[1]sResponse xmlns:u="%[2]s">%[3]s</u:%[1]sResponse>`,
sa.Action, sa.ServiceURN.String(), mustMarshalXML(soapArgs))
sa.Action, sa.ServiceURN.String(), mustMarshalXML(xmlArgs))
}
type loggingResponseWriter struct {
+7 -7
View File
@@ -11,16 +11,16 @@ type mediaReceiverRegistrarService struct {
upnp.Eventing
}
func (mrrs *mediaReceiverRegistrarService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) {
func (mrrs *mediaReceiverRegistrarService) Handle(action string, argsXML []byte, r *http.Request) ([]soapArg, error) {
switch action {
case "IsAuthorized", "IsValidated":
return map[string]string{
"Result": "1",
}, nil
return soapArgs(
"Result", "1",
), nil
case "RegisterDevice":
return map[string]string{
"RegistrationRespMsg": mrrs.RootDeviceUUID,
}, nil
return soapArgs(
"RegistrationRespMsg", mrrs.RootDeviceUUID,
), nil
default:
return nil, upnp.InvalidActionError
}