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:
+29
-26
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user