]> git.basschouten.com Git - openhab-addons.git/commitdiff
[upnpcontrol] Rework and extension of binding. (#9081)
authorMark Herwege <mherwege@users.noreply.github.com>
Sat, 28 Nov 2020 12:38:44 +0000 (13:38 +0100)
committerGitHub <noreply@github.com>
Sat, 28 Nov 2020 12:38:44 +0000 (13:38 +0100)
Signed-off-by: Mark Herwege <mark.herwege@telenet.be>
35 files changed:
bundles/org.openhab.binding.upnpcontrol/README.md
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSink.java [deleted file]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSinkReg.java [deleted file]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpChannelName.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpControlBindingConstants.java
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpControlHandlerFactory.java
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntry.java [deleted file]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntryRes.java [deleted file]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpProtocolMatcher.java [deleted file]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpXMLParser.java [deleted file]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSink.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSinkReg.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpNotificationAudioSink.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlBindingConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlConfiguration.java
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlRendererConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlServerConfiguration.java
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/discovery/UpnpControlDiscoveryParticipant.java
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandler.java
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandler.java
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandler.java
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntry.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryQueue.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryRes.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpFavorite.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpPlaylistsListener.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/services/UpnpRenderingControlConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpControlUtil.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpProtocolMatcher.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpXMLParser.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/main/resources/OH-INF/binding/binding.xml
bundles/org.openhab.binding.upnpcontrol/src/main/resources/OH-INF/thing/thing-types.xml
bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandlerTest.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandlerTest.java [new file with mode: 0644]
bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandlerTest.java [new file with mode: 0644]

index 5418c65660be212537396fc57104bb12140127ae..5e4dc52d39eefaa0f57bfabce06c5d0c47715250 100644 (file)
@@ -8,10 +8,13 @@ UPnP AV media renderers take care of playback of the content.
 You can select a renderer to play the media served from a server.
 The full content hierarchy of the media on the server can be browsed hierarchically.
 Searching the media library is also supported using UPnP search syntax.
+Playlists can be created and maintained.
 
 Controls are available to control the playback of the media on the renderer.
+Currently playing media can be stored as a favorite.
 Each discovered renderer will also be registered as an openHAB audio sink.
 
+
 ## Supported Things
 
 Two thing types are supported, a server thing, `upnpserver`, and a renderer thing, `upnprenderer`.
@@ -22,6 +25,13 @@ It complies with part of the UPnP AV Media standard, but has not been verified t
 Tests have focused on the playback of audio, but if the server and renderer support it, other media types should play as well.
 
 
+## Binding Configuration
+
+The binding has one configuration parameter, `path`.
+This is used as the disk location for storing and retrieving playlists and favorites.
+The default location is `$OPENHAB_USERDATA/upnpcontrol`.
+
+
 ## Discovery
 
 UPnP media servers and media renderers in the network will be discovered automatically.
@@ -33,79 +43,288 @@ Both the  `upnprenderer` and `upnpserver` thing require a configuration paramete
 This `udn` uniquely defines the UPnP device.
 It can be retrieved from the thing ID when using auto discovery.
 
+Both also have `refresh` configuration parameter. This parameter defines a polling interval for polling the state of the `upnprenderer` or `upnpserver`.
+The default polling interval is 60s.
+0 turns off polling.
+
+An advanced configuration parameter `responseTimeout` permits tweaking how long the `upnprenderer` and `upnpserver` will wait for GENA events from the UPnP device.
+This timeout is checked when there is a dependency between an action invocation and an event with expected result.
+The default is 2500ms.
+It should not be changed in normal circumstances.
+
 Additionally, a `upnpserver` device has the following optional configuration parameters:
 
 * `filter`: when true, only list content that is playable on the renderer, default is `false`.
-* `sortcriteria`: Sort criteria for the titles in the selection list and when sending for playing to a renderer.
-The criteria are defined in UPnP sort criteria format, examples: `+dc:title`, `-dc:creator`, `+upnp:album`.
-Support for sort criteria will depend on the media server.
-The default is to sort ascending on title, `+dc:title`.
+
+* `sortCriteria`: sort criteria for the titles in the selection list and when sending for playing to a renderer.
+
+  The criteria are defined in UPnP sort criteria format, examples: `+dc:title`, `-dc:creator`, `+upnp:album`.
+  Support for sort criteria will depend on the media server.
+  The default is to sort ascending on title, `+dc:title`.
+
+* `browseDown`: when browse or search results in exactly one container entry, iteratively browse down until the result contains multiple container entries or at least one media entry, default is `true`.
+
+* `searchFromRoot`: always start search from root instead of the current id, default is `false`.
+
+A `upnprenderer` has the following optional configuration parameters:
+
+* `seekStep`: step in seconds when sending fast forward or rewind command on the player control, default 5s.
+
+* `notificationVolumeAdjustment`: volume adjustment from current volume in percent (range -100 to +100) for notifications when no volume is set in `playSound` command, default 10.
+
+* `maxNotificationDuration`: maximum duration for notifications (default 15s), no maximum duration when set to 0s.
 
 The full syntax for manual configuration is:
 
 ```
-Thing upnpcontrol:upnpserver:<serverId> [udn="<udn of media server>"]
-Thing upnpcontrol:upnprenderer:<rendererId> [udn="<udn of media renderer>", filter=<true/false>, sortcriteria="<sort criteria string>"]
+Thing upnpcontrol:upnpserver:<serverId> [udn="<udn of media server>", refresh=<polling interval>, seekStep=<step>]
+Thing upnpcontrol:upnprenderer:<rendererId> [udn="<udn of media renderer>", refresh=<polling interval>, filter=<true/false>, sortCriteria="<sort criteria string>", browseDown=<true/false>, searchfromroot=<true/false>]
 ```
 
+
 ## Channels
 
-The `upnpserver` has the following channels:
-
-* `upnprenderer`: The renderer to send the media content to for playback.
-The channel allows selecting from all discovered media renderers.
-This list is dynamically adjusted as media renderers are being added/removed.
-* `currentid`: Current ID of media container or entry ready for playback.
-This channel can be used to skip to a specific container or entry in the content directory.
-This is especially useful in rules.
-* `browse`: Browse and serve media content.
-The browsing will start at the top of the content directory tree and allows you to go down and up (represented by ..) in the tree.
-The list of containers (directories) and media entries for selection in the content hierarchy is updated dynamically when selecting a container or entry.
-All media in the selection list, playable on the currently selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
-* `search`: Search for media content on the server.
-Search criteria are defined in UPnP search criteria format.
-Examples: `dc:title contains "song"`, `dc:creator contains "SpringSteen"`, `unp:class = "object.item.audioItem"`, `upnp:album contains "Born in"`.
-The search starts at the value of the `currentid` channel and searches down from there.
-When no `currentid` is selected, the search starts at the top.
-All media in the search result list, playable on the current selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
-
-The `upnprenderer` has the following channels:
-
-| Channel Type ID | Item Type | Access Mode | Description                                        |
-|-----------------|-----------|-------------|----------------------------------------------------|
-| `volume`        | Dimmer    |      RW     | playback volume                                    |
-| `control`       | Player    |      RW     | play, pause, next, previous control                |
-| `stop`          | Switch    |      RW     | stop media playback                                |
-| `title`         | String    |      R      | media title                                        |
-| `album`         | String    |      R      | media album                                        |
-| `albumart`      | Image     |      R      | image for media album                              |
-| `creator`       | String    |      R      | media creator                                      |
-| `artist`        | String    |      R      | media artist                                       |
-| `publisher`     | String    |      R      | media publisher                                    |
-| `genre`         | String    |      R      | media genre                                        |
-| `tracknumber`   | Number    |      R      | track number of current track in album             |
-| `trackduration` | Number:Time    |      R      | track duration of current track in album           |
-| `trackposition` | Number:Time    |      R      | current position in track during playback or pause |
+### `upnpserver`
+
+The `upnpserver` has the following channels (item type and access mode indicated in brackets):
+
+* `upnprenderer` (String, RW): The renderer to receive media content for playback.
+  
+  The channel allows selecting from all discovered media renderers.
+  This list is dynamically adjusted as media renderers are being added/removed.
+
+* `currenttitle` (String, R): Current title of media container or entry ready for playback.
+
+* `browse` (String, RW): Browse and serve media content, current ID of media container or entry ready for playback.
+
+  The browsing will start at the top of the content directory tree and allows you to go down and up (represented by ..) in the tree.
+  The list of containers (directories) and media entries for selection in the content hierarchy is updated dynamically when selecting a container or entry.
+  
+  This channel can also be used to skip to a specific container or entry in the content directory.
+  Setting it to 0 will reposition to the top of the content hierarchy.
+  
+  All media in the selection list, playable on the currently selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
+  
+  The `browseDown` configuration parameter influences the result in such a way that, for `browseDown = true`, if the result only contains exactly one container entry, the result will be the content of the container and not the container itself.
+
+* `search` (String, W): Search for media content on the server.
+
+  Search criteria are defined in UPnP search criteria format.
+  Examples: `dc:title contains "song"`, `dc:creator contains "SpringSteen"`, `unp:class = "object.item.audioItem"`, `upnp:album contains "Born in"`.
+  
+  The search, by default, starts at the value of the `currentid` and searches down from there unless the `searchfromroot` thing configuration parameter is set to `true`.
+  The result (media and containers) will be available in the `browse` command option list.
+  The `currentid` channel will be put to the id of the top container where the search started.
+  
+  All media in the search result list, playable on the current selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
+  
+  The `browseDown` configuration parameter influences the result in such a way that, for `browseDown = true`, if the result only contains exactly one container entry, the result   will be the content of the container and not the container itself.
+
+* `playlistselect` (String, W): Select a playlist from the available playlists currently saved on disk.
+
+  This will also update `playlist` with the selected value.
+
+* `playlist` (String, RW): Name of existing or new playlist.
+
+* `playlistaction` (String, W): action to perform with `playlist`.
+
+  Possible command options are:
+   
+  * `RESTORE`: restore the playlist from `playlist`.
+
+    If the restored playlist contains content from the current server, this content will update the `browse` command option list.
+    Note that playlists can contain a mix of media entries and container references.
+    
+    All media in the result list, playable on the current selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
+
+  * `SAVE`: save the current `browse` command option list into `playlist`.
+
+    If `playlist` already exists, it will be overwritten.
+
+  * `APPEND`: append the current `browse` command option list to `playlist`.
+
+    If `playlist` does not exist yet, a new playlist will be created.
+
+  * `DELETE`: delete `playlist` from disk and remove from `playlistselect` command option list.
+
+A number of convenience channels replicate the basic control channels from the `upnprenderer` thing for the currently selected renderer on the `upnprenderer` channel.
+These channels are `volume`, `mute` and `control`.
+
+### `upnprenderer`
+
+The `upnprenderer` has the following default channels:
+
+| Channel Type ID    | Item Type   | Access Mode | Description                                        |
+|--------------------|-------------|-------------|----------------------------------------------------|
+| `volume`           | Dimmer      | RW          | playback master volume                             |
+| `mute`             | Switch      | RW          | playback master mute                               |
+| `control`          | Player      | RW          | play, pause, next, previous, fast forward, rewind  |
+| `stop`             | Switch      | W           | stop media playback                                |
+| `repeat`           | Switch      | RW          | continuous play of media queue, restart at end     |
+| `shuffle`          | Switch      | RW          | continuous random play of media queue              |
+| `onlyplayone`      | Switch      | RW          | only play one media entry from the queue at a time |
+| `uri`              | String      | RW          | URI of currently playing media                     |
+| `favoriteselect`   | String      | W           | play favorite from list of saved favorites         |
+| `favorite`         | String      | RW          | set name for existing of new favorite              |
+| `favoriteaction`   | String      | W           | `SAVE` or `DELETE` `favorite`                      |
+| `playlistselect`   | String      | W           | play playlist from list of saved playlists        |
+| `title`            | String      | R           | media title                                        |
+| `album`            | String      | R           | media album                                        |
+| `albumart`         | Image       | R           | image for media album                              |
+| `creator`          | String      | R           | media creator                                      |
+| `artist`           | String      | R           | media artist                                       |
+| `publisher`        | String      | R           | media publisher                                    |
+| `genre`            | String      | R           | media genre                                        |
+| `tracknumber`      | Number      | R           | track number of current track in album             |
+| `trackduration`    | Number:Time | R           | track duration of current track in album           |
+| `trackposition`    | Number:Time | RW          | current position in track during playback or pause |
+| `reltrackposition` | Dimmer      | RW          | current position relative to track duration        |
+
+A numer of `upnprenderer` audio control channels may be dynamically created depending on the specific renderer capabilities.
+Examples of these are:
+
+| Channel Type ID    | Item Type   | Access Mode | Description                                        |
+|--------------------|-------------|-------------|----------------------------------------------------|
+| `loudness`         | Switch      | RW          | playback master loudness                           |
+| `lfvolume`         | Dimmer      | RW          | playback front left volume                         |
+| `lfmute`           | Switch      | RW          | playback front left mute                           |
+| `rfvolume`         | Dimmer      | RW          | playback front right volume                        |
+| `rfmute`           | Switch      | RW          | playback front right mute                          |
+
 
 ## Audio Support
 
-All configured media renderers are registered as an audio sink.
-`playSound`and `playStream`commands can be used in rules to play back audio fragments or audio streams to a renderer.
+Two audio sinks are registered for each media renderer.
+`playSound` and `playStream` commands can be used in rules to play back audio fragments or audio streams to a renderer.
+
+The first audio sink has the renderer id as a name.
+It is used for normal playback of a sound or stream.
+
+The second audio sink has `-notify` appended to the renderer id for its name, and has a special behavior.
+This audio sink is used to play notifications.
+When setting the volume parameter in the `playSound` command, the volume of the renderer will only change for the duration of playing the notification.
+The `maxNotificationDuration` configuration parameter of the renderer will limit the notification duration the value of the parameter in seconds.
+Normal playing will resume after the notification has played or when the maximum notification duration has been reached, whichever happens first.
+Longer sounds or streams will be cut off.
+
+
+## Managing a Playback Queue
+
+There are multiple ways to serve content to a renderer for playback.
+
+* Directly provide a URI on the `URI` channel or through `playSound` or `playStream` actions:
+
+  Playing will start immediately, interrupting currently playing media.
+  No metadata for the media is available, therefore will be provided in the media channels for metadata (e.g. `title`, `album`, ...).
+
+* Content served from one or multiple `upnpserver` servers:
+
+  This is done on the `upnpserver` thing with the `upnprenderer` set the the renderer for playback.
+  The media at any point in time in the `upnpserver browse` option list (result from browse, search or restoring a playlist), will be queued to the `upnprenderer` for playback.
+  Playback does not start automatically if not yet playing.
+  When already playing a queue, the first entry of the new queue will be playing as the next entry.
+  When playing an URI or media provided through an action, playback will immediately switch to the new queue.
+  
+  The `upnprenderer` will use that queue until it is replaced by another queue from the same or another `upnpserver`.
+  Note that querying the content hierarchy on the `upnpserver` will update the `upnpserver browse` option list each time, and therefore the queue on the `upnprenderer` will be updated each time as long as `upnprenderer` is selected on `upnpserver`.
+  
+* Selecting a favorite or playlist on the renderer.
+
+  Playback of the favorite or playlist will start immediately.
+
+When playing from a directly provided URI, at the end of the media, the renderer will try to move to the next entry in a queue previously provided by a server.
+Playing will stop when no such entry is available.
+
+Multiple renderers can be sent the same or different playback queue from the same server sequentially.
+Select content on the server and select the first renderer for playback.
+The content queue will be served to the renderer, a play command on the renderer will start playing the queue.
+Select another renderer on the server.
+The same or new (after another content selection) queue will be served to the second renderer.
+Both renderers will keep on playing the full queue they received.
+
+When serving a queue from a server, the renderer can be put in "only play one" mode by putting the `onlyplayone` channel to true.
+A subsequent play command will only play one media entry from the queue while respecting `shuffle` and `repeat`.
+To play the next media from the queue, a new play command will be required after the player stopped.
+An example of usage could be playing a single random sound from a playlist when you are away from home and an intrusion is detected.
+A script could put the player in `shuffle` and `onlyplayone` mode and serve a playlist.
+Only one random sound from the playlist would be played.
+
+### Favorites
+
+Currently playing media can be saved as favorites on the renderer.
+This is especially useful when playing streams, such as online radio, but is valid for any media.
+If the currently playing media has metadata, it will be saved with the favorite.
+
+A favorite only contains one media item.
+Selecting the favorite will only play that one item.
+The favorite will start playing immediately.
+Playing the server queue will resume after playing the favorite.
+
+### Playlists
+
+Playlists provide a way to define lists of server content for playback.
+
+A new playlist can be created on a server thing from the selection in the `upnpserver browse` selection list.
+When restoring a playlist on the server, the media in the playlist from the `upnpserver` thing used for restoring, will be put in the `upnpserver browse` selection list.
+
+The current selection of media playable on the currently selected renderer will automatically be stored as a playlist with name `current`.
+
+A playlist can contain media from different servers.
+Only the media from the current server will be visible in the server when restoring.
+It is possible to append content to a playlist that already contains content from a different server.
+That way, it is possible to combine multiple sources for playback.
+
+When selecting a playlist on a renderer, the playlist will be queued for playback, replacing the current queue.
+Playback will start immediately.
+
+
+## Using Search
+
+Searching content on a media server may take a lot of time, depending on the functionality and the performance of the media server.
+Therefore, it may very well be that media server searches time out.
+
+Rather than searching for individual items, it is therefore often better to search for containers or playlists.
+
+For example:
+
+* `upnp:class derivedfrom "object.item.audioItem.musicTrack" and dc:title contains "Fight For Your Right"` would search for all music tracks with "Fight For Your Right" in the title.
+  This search is potentially slow.
+
+* `dc:title contains "Evening" and upnp:class = "object.container.playlistContainer"` would search for all playlists with "Evening" in the name.
+
+* `dc:title = "Donnie Darko" and upnp:class = "object.container.playlistContainer"` would search for a playlist with a specific name.
+
+With the last example, if the `browseDown` configuration parameter is `true`, the result will not be the playlist, but the content of the playlist.
+This allows immediately starting a play command without having to browse down to the first result of the list (the unique container).
+This is especially useful when doing searches and starting to play in scripts, as the play command can immediately follow the search for a unique container, without a need to browse down to a media ID that is hidden in the browse option list.
+For interactive use through a UI, you may opt to switch the `browseDown` configuration parameter to `false` to see all levels in the browsing hierarchy.
+
+The `searchfromroot` configuration parameter always forces searching to start from the directory root.
+This will also always reset the `browse` channel to the root.
+This option is helpful if you do not want to limit search to a selected container in the directory.
 
 ## Limitations
 
-The current version of BasicUI does not support dynamic refreshing of the selection list in the `upnpserver` channels `renderer` and `browse`.
-A refresh of the browser will be required to show the adjusted selection list.
-The `upnpserver search` channel requires input of a string to trigger a search.
-This cannot be done with BasicUI, but can be achieved with rules.
+BasicUI has a number of limitations that impact the way some of the channels can be used from it:
+
+* BasicUI does not support dynamic refreshing of the selection list in the `upnpserver` channels `renderer`, `browse`, `playlistselect` and in the `upnprenderer` channel `favoriteselect`.
+  A refresh of the browser will be required to show the adjusted selection list.
+
+* The `upnpserver search` channel requires input of a string to trigger a search.
+  The `upnpserver playlist` channel and `upnprenderer favorite` channel require input of a string to set a playlist or favorite.
+  This cannot be done with BasicUI, but can be achieved with rules.
+
+* The player control in BasicUI does not support fast forward or rewind.
+
+None of these are limitations when using the main UI.
 
 ## Full Example
 
 .things:
 
 ```
-Thing upnpcontrol:upnpserver:mymediaserver [udn="538cf6e8-d188-4aed-8545-73a1b905466e"]
-Thing upnpcontrol:upnprenderer:mymediarenderer [udn="0ec457ae-6c50-4e6e-9012-dee7bb25be2d", filter=true, sortcriteria="+dc:title"]
+Thing upnpcontrol:upnpserver:mymediaserver [udn="0ec457ae-6c50-4e6e-9012-dee7bb25be2d", refresh=120, filter=true, sortCriteria="+dc:title"]
+Thing upnpcontrol:upnprenderer:mymediarenderer [udn="538cf6e8-d188-4aed-8545-73a1b905466e", refresh=600, seekStep=1]
 ```
 
 .items:
@@ -116,8 +335,18 @@ Group MediaRenderer <player>
 
 Dimmer Volume    "Volume [%.1f %%]" <soundvolume>      (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:volume"}
 Switch Mute      "Mute"             <soundvolume_mute> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:mute"}
+Switch Loudness  "Loudness"                            (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:loudness"}
+Dimmer LeftVolume "Volume [%.1f %%]" <soundvolume>     (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:lfvolume"}
+Dimmer RightVolume "Volume [%.1f %%]" <soundvolume>    (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:rfvolume"}
 Player Controls  "Controller"                          (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:control"}
 Switch Stop      "Stop"                                (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:stop"}
+Switch Repeat    "Repeat"                              (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:repeat"}
+Switch Shuffle   "Shuffle"                             (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:shuffle"}
+String URI       "URI"                                 (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:uri"}
+String FavoriteSelect "Favorite"                       (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:favoriteselect"}
+String Favorite  "Favorite"                            (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:favorite"}
+String FavoriteAction "Favorite Action"                (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:favoriteaction"}
+String PlaylistPlay "Playlist"                         (MediaRenderer)   {channel="upnpcontrol:upnprenderer:mymediarenderer:playlistselect"}
 String Title     "Now playing [%s]" <text>             (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:title"}
 String Album     "Album"            <text>             (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:album"}
 Image AlbumArt   "Album Art"                           (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:albumart"}
@@ -128,33 +357,53 @@ String Genre     "Genre"            <text>             (MediaRenderer) {channel=
 Number TrackNumber "Track Number"                      (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:tracknumber"}
 Number:Time TrackDuration "Track Duration [%d %unit%]" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:trackduration"}
 Number:Time TrackPosition "Track Position [%d %unit%]" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:trackposition"}
+Dimmer RelTrackPosition "Relative Track Position ´[%d %%]" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:reltrackposition"}
 
 String Renderer  "Renderer [%s]"    <text>             (MediaServer)   {channel="upnpcontrol:upnpserver:mymediaserver:title"}
-String CurrentId "Current Entry [%s]" <text>           (MediaServer)   {channel="upnpcontrol:upnpserver:mymediaserver:currentid"}
-String Browse   "Browse"                               (MediaServer)   {channel="upnpcontrol:upnpserver:mymediaserver:browse"}
+String CurrentTitle "Current Entry [%s]" <text>        (MediaServer)   {channel="upnpcontrol:upnpserver:mymediaserver:currenttitle"}
+String Browse    "Browse"                              (MediaServer)   {channel="upnpcontrol:upnpserver:mymediaserver:browse"}
+String Search    "Search"                              (MediaServer)   {channel="upnpcontrol:upnpserver:mymediaserver:search"}
+String PlaylistSelect "Playlist"                       (MediaServer)   {channel="upnpcontrol:upnpserver:mymediaserver:playlistselect"}
+String Playlist  "Playlist"                            (MediaServer)   {channel="upnpcontrol:upnpserver:mymediaserver:playlist"}
+String PlaylistAction "Playlist Action"                (MediaServer)   {channel="upnpcontrol:upnpserver:mymediaserver:playlistaction"}
 ```
 
 .sitemap:
 
 ```
-Slider  item=Volume
-Switch  item=Mute
-Default item=Controls
-Switch  item=Stop mappings=[ON="STOP"]
-Text    item=Title     
-Text    item=Album
-Default item=AlbumArt
-Text    item=Creator
-Text    item=Artist
-Text    item=Publisher
-Text    item=Genre
-Text    item=TrackNumber
-Text    item=TrackDuration
-Text    item=TrackPosition
-
-Text    item=Renderer
-Text    item=CurrentId
-Text    item=Browse
+Slider    item=Volume
+Switch    item=Mute
+Switch    item=Loudness
+Slider    item=LeftVolume
+Slider    item=RightVolume
+Default   item=Controls
+Switch    item=Stop mappings=[ON="STOP"]
+Switch    item=Repeat
+Switch    item=Shuffle
+Text      item=URI
+Selection item=FavoriteSelect
+Text      item=Favorite
+Switch    item=FavoriteAction
+Selection item=PlaylistPlay
+Text      item=Title
+Text      item=Album
+Default   item=AlbumArt
+Text      item=Creator
+Text      item=Artist
+Text      item=Publisher
+Text      item=Genre
+Text      item=TrackNumber
+Text      item=TrackDuration
+Text      item=TrackPosition
+Slider    item=RelTrackPosition
+
+Selection item=Renderer
+Text      item=CurrentTitle
+Selection item=Browse
+Text      item=Search
+Selection item=PlaylistSelect
+Text      item=Playlist
+Switch    item=PlaylistAction
 ```
 
 Audio sink usage examples in rules:
@@ -162,4 +411,6 @@ Audio sink usage examples in rules:
 ```
 playSound(“doorbell.mp3”)
 playStream("upnpcontrol:upnprenderer:mymediarenderer", "http://icecast.vrtcdn.be/stubru_tijdloze-high.mp3”)
+playSound("upnpcontrol:upnprenderer:mymediarenderer-notify", "doorbell.mp3", new PercentType(80))
+
 ```
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSink.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSink.java
deleted file mode 100644 (file)
index dbc557f..0000000
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import java.io.IOException;
-import java.util.Locale;
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
-import org.openhab.core.audio.AudioFormat;
-import org.openhab.core.audio.AudioHTTPServer;
-import org.openhab.core.audio.AudioSink;
-import org.openhab.core.audio.AudioStream;
-import org.openhab.core.audio.FixedLengthAudioStream;
-import org.openhab.core.audio.URLAudioStream;
-import org.openhab.core.audio.UnsupportedAudioFormatException;
-import org.openhab.core.audio.UnsupportedAudioStreamException;
-import org.openhab.core.library.types.PercentType;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- *
- * @author Mark Herwege - Initial contribution
- */
-@NonNullByDefault
-public class UpnpAudioSink implements AudioSink {
-
-    private final Logger logger = LoggerFactory.getLogger(UpnpAudioSink.class);
-
-    private static final Set<Class<? extends AudioStream>> SUPPORTED_STREAMS = Stream
-            .of(AudioStream.class, FixedLengthAudioStream.class).collect(Collectors.toSet());
-    private UpnpRendererHandler handler;
-    private AudioHTTPServer audioHTTPServer;
-    private String callbackUrl;
-
-    public UpnpAudioSink(UpnpRendererHandler handler, AudioHTTPServer audioHTTPServer, String callbackUrl) {
-        this.handler = handler;
-        this.audioHTTPServer = audioHTTPServer;
-        this.callbackUrl = callbackUrl;
-    }
-
-    @Override
-    public String getId() {
-        return handler.getThing().getUID().toString();
-    }
-
-    @Override
-    public @Nullable String getLabel(@Nullable Locale locale) {
-        return handler.getThing().getLabel();
-    }
-
-    @Override
-    public void process(@Nullable AudioStream audioStream)
-            throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
-        if (audioStream == null) {
-            stopMedia();
-            return;
-        }
-
-        String url = null;
-        if (audioStream instanceof URLAudioStream) {
-            URLAudioStream urlAudioStream = (URLAudioStream) audioStream;
-            url = urlAudioStream.getURL();
-        } else if (!callbackUrl.isEmpty()) {
-            String relativeUrl = audioStream instanceof FixedLengthAudioStream
-                    ? audioHTTPServer.serve((FixedLengthAudioStream) audioStream, 20)
-                    : audioHTTPServer.serve(audioStream);
-            url = String.valueOf(this.callbackUrl) + relativeUrl;
-        } else {
-            logger.warn("We do not have any callback url, so {} cannot play the audio stream!", handler.getUDN());
-            return;
-        }
-        playMedia(url);
-    }
-
-    @Override
-    public Set<AudioFormat> getSupportedFormats() {
-        return handler.getSupportedAudioFormats();
-    }
-
-    @Override
-    public Set<Class<? extends AudioStream>> getSupportedStreams() {
-        return SUPPORTED_STREAMS;
-    }
-
-    @Override
-    public PercentType getVolume() throws IOException {
-        return handler.getCurrentVolume();
-    }
-
-    @Override
-    public void setVolume(@Nullable PercentType volume) throws IOException {
-        if (volume != null) {
-            handler.setVolume(handler.getCurrentChannel(), volume);
-        }
-    }
-
-    private void stopMedia() {
-        handler.stop();
-    }
-
-    private void playMedia(String url) {
-        stopMedia();
-        String newUrl = url;
-        if (!url.startsWith("x-") && !url.startsWith("http")) {
-            newUrl = "x-file-cifs:" + url;
-        }
-        handler.setCurrentURI(newUrl, "");
-        handler.play();
-    }
-}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSinkReg.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpAudioSinkReg.java
deleted file mode 100644 (file)
index 244834d..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
-
-/**
- * Interface class to be implemented in {@link UpnpControlHandlerFactory}, allows a {UpnpRendererHandler} to register
- * itself as an audio sink when it supports audio. If it supports audio is only known after the communication with the
- * renderer is established.
- *
- * @author Mark Herwege - Initial contribution
- */
-@NonNullByDefault
-public interface UpnpAudioSinkReg {
-
-    /**
-     * Implemented method should create a new {@link UpnpAudioSink} and register the handler parameter as an audio sink.
-     *
-     * @param handler
-     */
-    void registerAudioSink(UpnpRendererHandler handler);
-}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpChannelName.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpChannelName.java
new file mode 100644 (file)
index 0000000..43ff357
--- /dev/null
@@ -0,0 +1,172 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
+
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * This enum contains default openHAB channel configurations for optional channels as defined in the UPnP standard.
+ * Vendor specific channels are not part of this.
+ *
+ * @author Mark Herwege - Initial contribution
+ *
+ */
+@NonNullByDefault
+public enum UpnpChannelName {
+
+    // Volume channels
+    LF_VOLUME("LFvolume", "Left Front Volume", "Left front volume, will be left volume with stereo sound",
+            ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    RF_VOLUME("RFvolume", "Right Front Volume", "Right front volume, will be left volume with stereo sound",
+            ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    CF_VOLUME("CFvolume", "Center Front Volume", "Center front volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    LFE_VOLUME("LFEvolume", "Frequency Enhancement Volume", "Low frequency enhancement volume (subwoofer)",
+            ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    LS_VOLUME("LSvolume", "Left Surround Volume", "Left surround volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    RS_VOLUME("RSvolume", "Right Surround Volume", "Right surround volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    LFC_VOLUME("LFCvolume", "Left of Center Volume", "Left of center (in front) volume", ITEM_TYPE_VOLUME,
+            CHANNEL_TYPE_VOLUME),
+    RFC_VOLUME("RFCvolume", "Right of Center Volume", "Right of center (in front) volume", ITEM_TYPE_VOLUME,
+            CHANNEL_TYPE_VOLUME),
+    SD_VOLUME("SDvolume", "Surround Volume", "Surround (rear) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    SL_VOLUME("SLvolume", "Side Left Volume", "Side left (left wall) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    SR_VOLUME("SRvolume", "Side Right Volume", "Side right (right wall) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    T_VOLUME("Tvolume", "Top Volume", "Top (overhead) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    B_VOLUME("Bvolume", "Bottom Volume", "Bottom volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    BC_VOLUME("BCvolume", "Back Center Volume", "Back center volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    BL_VOLUME("BLvolume", "Back Left Volume", "Back Left Volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+    BR_VOLUME("BRvolume", "Back Right Volume", "Back right volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
+
+    // Mute channels
+    LF_MUTE("LFmute", "Left Front Mute", "Left front mute, will be left mute with stereo sound", ITEM_TYPE_MUTE,
+            CHANNEL_TYPE_MUTE),
+    RF_MUTE("RFmute", "Right Front Mute", "Right front mute, will be left mute with stereo sound", ITEM_TYPE_MUTE,
+            CHANNEL_TYPE_MUTE),
+    CF_MUTE("CFmute", "Center Front Mute", "Center front mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    LFE_MUTE("LFEmute", "Frequency Enhancement Mute", "Low frequency enhancement mute (subwoofer)", ITEM_TYPE_MUTE,
+            CHANNEL_TYPE_MUTE),
+    LS_MUTE("LSmute", "Left Surround Mute", "Left surround mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    RS_MUTE("RSmute", "Right Surround Mute", "Right surround mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    LFC_MUTE("LFCmute", "Left of Center Mute", "Left of center (in front) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    RFC_MUTE("RFCmute", "Right of Center Mute", "Right of center (in front) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    SD_MUTE("SDmute", "Surround Mute", "Surround (rear) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    SL_MUTE("SLmute", "Side Left Mute", "Side left (left wall) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    SR_MUTE("SRmute", "Side Right Mute", "Side right (right wall) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    T_MUTE("Tmute", "Top Mute", "Top (overhead) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    B_MUTE("Bmute", "Bottom Mute", "Bottom mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    BC_MUTE("BCmute", "Back Center Mute", "Back center mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    BL_MUTE("BLmute", "Back Left Mute", "Back Left Mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+    BR_MUTE("BRmute", "Back Right Mute", "Back right mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
+
+    // Loudness channels
+    LOUDNESS("loudness", "Loudness", "Master loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+    LF_LOUDNESS("LFloudness", "Left Front Loudness", "Left front loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+    RF_LOUDNESS("RFloudness", "Right Front Loudness", "Right front loudness", ITEM_TYPE_LOUDNESS,
+            CHANNEL_TYPE_LOUDNESS),
+    CF_LOUDNESS("CFloudness", "Center Front Loudness", "Center front loudness", ITEM_TYPE_LOUDNESS,
+            CHANNEL_TYPE_LOUDNESS),
+    LFE_LOUDNESS("LFEloudness", "Frequency Enhancement Loudness", "Low frequency enhancement loudness (subwoofer)",
+            ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+    LS_LOUDNESS("LSloudness", "Left Surround Loudness", "Left surround loudness", ITEM_TYPE_LOUDNESS,
+            CHANNEL_TYPE_LOUDNESS),
+    RS_LOUDNESS("RSloudness", "Right Surround Loudness", "Right surround loudness", ITEM_TYPE_LOUDNESS,
+            CHANNEL_TYPE_LOUDNESS),
+    LFC_LOUDNESS("LFCloudness", "Left of Center Loudness", "Left of center (in front) loudness", ITEM_TYPE_LOUDNESS,
+            CHANNEL_TYPE_LOUDNESS),
+    RFC_LOUDNESS("RFCloudness", "Right of Center Loudness", "Right of center (in front) loudness", ITEM_TYPE_LOUDNESS,
+            CHANNEL_TYPE_LOUDNESS),
+    SD_LOUDNESS("SDloudness", "Surround Loudness", "Surround (rear) loudness", ITEM_TYPE_LOUDNESS,
+            CHANNEL_TYPE_LOUDNESS),
+    SL_LOUDNESS("SLloudness", "Side Left Loudness", "Side left (left wall) loudness", ITEM_TYPE_LOUDNESS,
+            CHANNEL_TYPE_LOUDNESS),
+    SR_LOUDNESS("SRloudness", "Side Right Loudness", "Side right (right wall) loudness", ITEM_TYPE_LOUDNESS,
+            CHANNEL_TYPE_LOUDNESS),
+    T_LOUDNESS("Tloudness", "Top Loudness", "Top (overhead) loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+    B_LOUDNESS("Bloudness", "Bottom Loudness", "Bottom loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+    BC_LOUDNESS("BCloudness", "Back Center Loudness", "Back center loudness", ITEM_TYPE_LOUDNESS,
+            CHANNEL_TYPE_LOUDNESS),
+    BL_LOUDNESS("BLloudness", "Back Left Loudness", "Back Left Loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
+    BR_LOUDNESS("BRloudness", "Back Right Loudness", "Back right loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS);
+
+    private static final Map<String, UpnpChannelName> UPNP_CHANNEL_NAME_MAP = Stream.of(UpnpChannelName.values())
+            .collect(Collectors.toMap(UpnpChannelName::getChannelId, Function.identity()));
+
+    private final String channelId;
+    private final String label;
+    private final String description;
+    private final String itemType;
+    private final String channelType;
+
+    UpnpChannelName(final String channelId, final String label, final String description, final String itemType,
+            final String channelType) {
+        this.channelId = channelId;
+        this.label = label;
+        this.description = description;
+        this.itemType = itemType;
+        this.channelType = channelType;
+    }
+
+    /**
+     * @return The name of the Channel
+     */
+    public String getChannelId() {
+        return channelId;
+    }
+
+    /**
+     * @return The label for the Channel
+     */
+    public String getLabel() {
+        return label;
+    }
+
+    /**
+     * @return The description for the Channel
+     */
+    public String getDescription() {
+        return description;
+    }
+
+    /**
+     * @return The item type for the Channel
+     */
+    public String getItemType() {
+        return itemType;
+    }
+
+    /**
+     * @return The channel type for the Channel
+     */
+    public String getChannelType() {
+        return channelType;
+    }
+
+    /**
+     * Returns the UPnP Channel enum for the given channel id or null if there is no enum available for the given
+     * channel.
+     *
+     * @param channelId Channel to find
+     * @return The UPnP Channel enum or null if there is none.
+     */
+    public static @Nullable UpnpChannelName channelIdToUpnpChannelName(final String channelId) {
+        return UPNP_CHANNEL_NAME_MAP.get(channelId);
+    }
+}
index c3ef0b2fbf2f568f7aa8290c8ba9e3c3dbb2adb6..24ab6f22e42e9f3fb09db05aaf36fcc2feccce26 100644 (file)
  */
 package org.openhab.binding.upnpcontrol.internal;
 
+import java.io.File;
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.OpenHAB;
+import org.openhab.core.thing.DefaultSystemChannelTypeProvider;
 import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.type.ChannelTypeUID;
 
 /**
  * The {@link UpnpControlBindingConstants} class defines common constants, which are
@@ -36,17 +40,35 @@ public class UpnpControlBindingConstants {
     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Stream.of(THING_TYPE_RENDERER, THING_TYPE_SERVER)
             .collect(Collectors.toSet());
 
-    // List of thing parameter names
-    public static final String HOST_PARAMETER = "ipAddress";
-    public static final String TCP_PORT_PARAMETER = "port";
+    // Binding config parameters
+    public static final String PATH = "path";
+
+    // Thing config parameters
     public static final String UDN_PARAMETER = "udn";
-    public static final String REFRESH_INTERVAL = "refreshInterval";
+    public static final String REFRESH_INTERVAL = "refresh";
+    public static final String RESPONSE_TIMEOUT = "responsetimeout";
+    // Server thing only config parameters
+    public static final String CONFIG_FILTER = "filter";
+    public static final String SORT_CRITERIA = "sortcriteria";
+    public static final String BROWSE_DOWN = "browsedown";
+    public static final String SEARCH_FROM_ROOT = "searchfromroot";
+    // Renderer thing only config parameters
+    public static final String NOTIFICATION_VOLUME_ADJUSTMENT = "notificationvolumeadjustment";
+    public static final String MAX_NOTIFICATION_DURATION = "maxnotificationduration";
+    public static final String SEEK_STEP = "seekstep";
 
     // List of all Channel ids
     public static final String VOLUME = "volume";
     public static final String MUTE = "mute";
     public static final String CONTROL = "control";
     public static final String STOP = "stop";
+    public static final String REPEAT = "repeat";
+    public static final String SHUFFLE = "shuffle";
+    public static final String ONLY_PLAY_ONE = "onlyplayone";
+    public static final String URI = "uri";
+    public static final String FAVORITE_SELECT = "favoriteselect";
+    public static final String FAVORITE = "favorite";
+    public static final String FAVORITE_ACTION = "favoriteaction";
     public static final String TITLE = "title";
     public static final String ALBUM = "album";
     public static final String ALBUM_ART = "albumart";
@@ -57,14 +79,44 @@ public class UpnpControlBindingConstants {
     public static final String TRACK_NUMBER = "tracknumber";
     public static final String TRACK_DURATION = "trackduration";
     public static final String TRACK_POSITION = "trackposition";
+    public static final String REL_TRACK_POSITION = "reltrackposition";
 
     public static final String UPNPRENDERER = "upnprenderer";
-    public static final String CURRENTID = "currentid";
+    public static final String CURRENTTITLE = "currenttitle";
     public static final String BROWSE = "browse";
     public static final String SEARCH = "search";
     public static final String SERVE = "serve";
+    public static final String PLAYLIST_SELECT = "playlistselect";
+    public static final String PLAYLIST = "playlist";
+    public static final String PLAYLIST_ACTION = "playlistaction";
 
-    // Thing config properties
-    public static final String CONFIG_FILTER = "filter";
-    public static final String SORT_CRITERIA = "sortcriteria";
+    // Type constants for dynamic renderer channels
+    public static final String CHANNEL_TYPE_VOLUME = DefaultSystemChannelTypeProvider.SYSTEM_VOLUME.toString();
+    public static final String CHANNEL_TYPE_MUTE = DefaultSystemChannelTypeProvider.SYSTEM_MUTE.toString();
+    public static final String CHANNEL_TYPE_LOUDNESS = (new ChannelTypeUID(BINDING_ID, "loudness")).toString();
+
+    public static final String ITEM_TYPE_VOLUME = "Dimmer";
+    public static final String ITEM_TYPE_MUTE = "Switch";
+    public static final String ITEM_TYPE_LOUDNESS = "Switch";
+
+    // Command options for playlist and favorite actions
+    public static final String RESTORE = "RESTORE";
+    public static final String SAVE = "SAVE";
+    public static final String APPEND = "APPEND";
+    public static final String DELETE = "DELETE";
+
+    // Channels that are duplicated on server to control current renderer
+    public static final Set<String> SERVER_CONTROL_CHANNELS = Set.of(VOLUME, MUTE, CONTROL, STOP);
+
+    // Master volume and mute identifier
+    public static final String UPNP_MASTER = "Master";
+
+    // Filepath and extension defaults and constants for playlists and favorites
+    public static final String DEFAULT_PATH = OpenHAB.getUserDataFolder() + File.separator + BINDING_ID
+            + File.separator;
+    public static final String PLAYLIST_FILE_EXTENSION = ".lst";
+    public static final String FAVORITE_FILE_EXTENSION = ".fav";
+
+    // Notification audio sink name extension
+    public static final String NOTIFICATION_AUDIOSINK_EXTENSION = "-notify";
 }
index d5a5a8d38f9cf8544a8645347f3fad511527ea85..17b412d88f7ee638343866ed019e777cb8c86a7e 100644 (file)
@@ -15,15 +15,27 @@ package org.openhab.binding.upnpcontrol.internal;
 import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
 
 import java.util.Hashtable;
+import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
+import org.jupnp.UpnpService;
+import org.jupnp.model.meta.LocalDevice;
+import org.jupnp.model.meta.RemoteDevice;
+import org.jupnp.registry.Registry;
+import org.jupnp.registry.RegistryListener;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSink;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSinkReg;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpNotificationAudioSink;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
+import org.openhab.binding.upnpcontrol.internal.handler.UpnpHandler;
 import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
 import org.openhab.binding.upnpcontrol.internal.handler.UpnpServerHandler;
 import org.openhab.core.audio.AudioHTTPServer;
 import org.openhab.core.audio.AudioSink;
+import org.openhab.core.config.core.Configuration;
 import org.openhab.core.io.transport.upnp.UpnpIOService;
 import org.openhab.core.net.HttpServiceUtil;
 import org.openhab.core.net.NetworkAddressService;
@@ -35,6 +47,8 @@ import org.openhab.core.thing.binding.ThingHandlerFactory;
 import org.osgi.framework.ServiceRegistration;
 import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Modified;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -47,15 +61,19 @@ import org.slf4j.LoggerFactory;
  */
 @Component(service = ThingHandlerFactory.class, configurationPid = "binding.upnpcontrol")
 @NonNullByDefault
-public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implements UpnpAudioSinkReg {
+public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implements UpnpAudioSinkReg, RegistryListener {
+    final UpnpControlBindingConfiguration configuration = new UpnpControlBindingConfiguration();
 
-    private final Logger logger = LoggerFactory.getLogger(getClass());
+    private final Logger logger = LoggerFactory.getLogger(UpnpControlHandlerFactory.class);
 
     private ConcurrentMap<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
     private ConcurrentMap<String, UpnpRendererHandler> upnpRenderers = new ConcurrentHashMap<>();
     private ConcurrentMap<String, UpnpServerHandler> upnpServers = new ConcurrentHashMap<>();
+    private ConcurrentMap<String, UpnpHandler> handlers = new ConcurrentHashMap<>();
+    private ConcurrentMap<String, RemoteDevice> devices = new ConcurrentHashMap<>();
 
     private final UpnpIOService upnpIOService;
+    private final UpnpService upnpService;
     private final AudioHTTPServer audioHTTPServer;
     private final NetworkAddressService networkAddressService;
     private final UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
@@ -64,16 +82,36 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
     private String callbackUrl = "";
 
     @Activate
-    public UpnpControlHandlerFactory(final @Reference UpnpIOService upnpIOService,
+    public UpnpControlHandlerFactory(final @Reference UpnpIOService upnpIOService, @Reference UpnpService upnpService,
             final @Reference AudioHTTPServer audioHTTPServer,
             final @Reference NetworkAddressService networkAddressService,
             final @Reference UpnpDynamicStateDescriptionProvider dynamicStateDescriptionProvider,
-            final @Reference UpnpDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider) {
+            final @Reference UpnpDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider,
+            Map<String, Object> config) {
         this.upnpIOService = upnpIOService;
+        this.upnpService = upnpService;
         this.audioHTTPServer = audioHTTPServer;
         this.networkAddressService = networkAddressService;
         this.upnpStateDescriptionProvider = dynamicStateDescriptionProvider;
         this.upnpCommandDescriptionProvider = dynamicCommandDescriptionProvider;
+
+        upnpService.getRegistry().addListener(this);
+
+        modified(config);
+    }
+
+    @Modified
+    protected void modified(Map<String, Object> config) {
+        // We update instead of replace the configuration object, so that if the user updates the
+        // configuration, the values are automatically available in all handlers. Because they all
+        // share the same instance.
+        configuration.update(new Configuration(config).as(UpnpControlBindingConfiguration.class));
+        logger.debug("Updated binding configuration to {}", configuration);
+    }
+
+    @Deactivate
+    protected void deActivate() {
+        upnpService.getRegistry().removeListener(this);
     }
 
     @Override
@@ -108,38 +146,78 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
 
     private UpnpServerHandler addServer(Thing thing) {
         UpnpServerHandler handler = new UpnpServerHandler(thing, upnpIOService, upnpRenderers,
-                upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
+                upnpStateDescriptionProvider, upnpCommandDescriptionProvider, configuration);
         String key = thing.getUID().toString();
         upnpServers.put(key, handler);
-        logger.debug("Media server handler created for {}", thing.getLabel());
+        logger.debug("Media server handler created for {} with UID {}", thing.getLabel(), thing.getUID());
+
+        String udn = handler.getUDN();
+        if (udn != null) {
+            handlers.put(udn, handler);
+            remoteDeviceUpdated(null, devices.get(udn));
+        }
+
         return handler;
     }
 
     private UpnpRendererHandler addRenderer(Thing thing) {
         callbackUrl = createCallbackUrl();
-        UpnpRendererHandler handler = new UpnpRendererHandler(thing, upnpIOService, this);
+        UpnpRendererHandler handler = new UpnpRendererHandler(thing, upnpIOService, this, upnpStateDescriptionProvider,
+                upnpCommandDescriptionProvider, configuration);
         String key = thing.getUID().toString();
         upnpRenderers.put(key, handler);
         upnpServers.forEach((thingId, value) -> value.addRendererOption(key));
-        logger.debug("Media renderer handler created for {}", thing.getLabel());
+        logger.debug("Media renderer handler created for {} with UID {}", thing.getLabel(), thing.getUID());
+
+        String udn = handler.getUDN();
+        if (udn != null) {
+            handlers.put(udn, handler);
+            remoteDeviceUpdated(null, devices.get(udn));
+        }
 
         return handler;
     }
 
     private void removeServer(String key) {
-        logger.debug("Removing media server handler for {}", upnpServers.get(key).getThing().getLabel());
+        UpnpHandler handler = upnpServers.get(key);
+        if (handler == null) {
+            return;
+        }
+        logger.debug("Removing media server handler for {} with UID {}", handler.getThing().getLabel(),
+                handler.getThing().getUID());
+        handlers.remove(handler.getUDN());
         upnpServers.remove(key);
     }
 
     private void removeRenderer(String key) {
-        logger.debug("Removing media renderer handler for {}", upnpRenderers.get(key).getThing().getLabel());
+        UpnpHandler handler = upnpServers.get(key);
+        if (handler == null) {
+            return;
+        }
+        logger.debug("Removing media renderer handler for {} with UID {}", handler.getThing().getLabel(),
+                handler.getThing().getUID());
+
         if (audioSinkRegistrations.containsKey(key)) {
-            logger.debug("Removing audio sink registration for {}", upnpRenderers.get(key).getThing().getLabel());
+            logger.debug("Removing audio sink registration for {}", handler.getThing().getLabel());
             ServiceRegistration<AudioSink> reg = audioSinkRegistrations.get(key);
-            reg.unregister();
+            if (reg != null) {
+                reg.unregister();
+            }
             audioSinkRegistrations.remove(key);
         }
+
+        String notificationKey = key + NOTIFICATION_AUDIOSINK_EXTENSION;
+        if (audioSinkRegistrations.containsKey(notificationKey)) {
+            logger.debug("Removing notification audio sink registration for {}", handler.getThing().getLabel());
+            ServiceRegistration<AudioSink> reg = audioSinkRegistrations.get(notificationKey);
+            if (reg != null) {
+                reg.unregister();
+            }
+            audioSinkRegistrations.remove(notificationKey);
+        }
+
         upnpServers.forEach((thingId, value) -> value.removeRendererOption(key));
+        handlers.remove(handler.getUDN());
         upnpRenderers.remove(key);
     }
 
@@ -153,6 +231,14 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
             Thing thing = handler.getThing();
             audioSinkRegistrations.put(thing.getUID().toString(), reg);
             logger.debug("Audio sink added for media renderer {}", thing.getLabel());
+
+            UpnpNotificationAudioSink notificationAudioSink = new UpnpNotificationAudioSink(handler, audioHTTPServer,
+                    callbackUrl);
+            @SuppressWarnings("unchecked")
+            ServiceRegistration<AudioSink> notificationReg = (ServiceRegistration<AudioSink>) bundleContext
+                    .registerService(AudioSink.class.getName(), notificationAudioSink, new Hashtable<String, Object>());
+            audioSinkRegistrations.put(thing.getUID().toString() + NOTIFICATION_AUDIOSINK_EXTENSION, notificationReg);
+            logger.debug("Notification audio sink added for media renderer {}", thing.getLabel());
         }
     }
 
@@ -173,4 +259,67 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
         }
         return "http://" + ipAddress + ":" + port;
     }
+
+    @Override
+    public void remoteDeviceDiscoveryStarted(@Nullable Registry registry, @Nullable RemoteDevice device) {
+    }
+
+    @Override
+    public void remoteDeviceDiscoveryFailed(@Nullable Registry registry, @Nullable RemoteDevice device,
+            @Nullable Exception ex) {
+    }
+
+    @Override
+    public void remoteDeviceAdded(@Nullable Registry registry, @Nullable RemoteDevice device) {
+        if (device == null) {
+            return;
+        }
+
+        String udn = device.getIdentity().getUdn().getIdentifierString();
+        if ("MediaServer".equals(device.getType().getType()) || "MediaRenderer".equals(device.getType().getType())) {
+            devices.put(udn, device);
+        }
+
+        if (handlers.containsKey(udn)) {
+            remoteDeviceUpdated(registry, device);
+        }
+    }
+
+    @Override
+    public void remoteDeviceUpdated(@Nullable Registry registry, @Nullable RemoteDevice device) {
+        if (device == null) {
+            return;
+        }
+
+        String udn = device.getIdentity().getUdn().getIdentifierString();
+        UpnpHandler handler = handlers.get(udn);
+        if (handler != null) {
+            handler.updateDeviceConfig(device);
+        }
+    }
+
+    @Override
+    public void remoteDeviceRemoved(@Nullable Registry registry, @Nullable RemoteDevice device) {
+        if (device == null) {
+            return;
+        }
+        devices.remove(device.getIdentity().getUdn().getIdentifierString());
+    }
+
+    @Override
+    public void localDeviceAdded(@Nullable Registry registry, @Nullable LocalDevice device) {
+    }
+
+    @Override
+    public void localDeviceRemoved(@Nullable Registry registry, @Nullable LocalDevice device) {
+    }
+
+    @Override
+    public void beforeShutdown(@Nullable Registry registry) {
+        devices = new ConcurrentHashMap<>();
+    }
+
+    @Override
+    public void afterShutdown() {
+    }
 }
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntry.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntry.java
deleted file mode 100644 (file)
index a33102b..0000000
+++ /dev/null
@@ -1,202 +0,0 @@
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-
-import org.apache.commons.lang.StringEscapeUtils;
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-
-/**
- *
- * @author Mark Herwege - Initial contribution
- * @author Karel Goderis - Based on UPnP logic in Sonos binding
- */
-@NonNullByDefault
-public class UpnpEntry {
-
-    private static final String DIRECTORY_ROOT = "0";
-
-    private static final Pattern CONTAINER_PATTERN = Pattern.compile("object.container");
-
-    private String id;
-    private String refId;
-    private String parentId;
-    private String upnpClass;
-    private String title = "";
-    private List<UpnpEntryRes> resList = new ArrayList<>();
-    private String album = "";
-    private String albumArtUri = "";
-    private String creator = "";
-    private String artist = "";
-    private String publisher = "";
-    private String genre = "";
-    private @Nullable Integer originalTrackNumber;
-
-    private boolean isContainer;
-
-    public UpnpEntry(String id, String refId, String parentId, String upnpClass) {
-        this.id = id;
-        this.refId = refId;
-        this.parentId = parentId;
-        this.upnpClass = upnpClass;
-
-        Matcher matcher = CONTAINER_PATTERN.matcher(upnpClass);
-        isContainer = matcher.find();
-    }
-
-    public UpnpEntry withTitle(String title) {
-        this.title = title;
-        return this;
-    }
-
-    public UpnpEntry withAlbum(String album) {
-        this.album = album;
-        return this;
-    }
-
-    public UpnpEntry withAlbumArtUri(String albumArtUri) {
-        this.albumArtUri = albumArtUri;
-        return this;
-    }
-
-    public UpnpEntry withCreator(String creator) {
-        this.creator = creator;
-        return this;
-    }
-
-    public UpnpEntry withArtist(String artist) {
-        this.artist = artist;
-        return this;
-    }
-
-    public UpnpEntry withPublisher(String publisher) {
-        this.publisher = publisher;
-        return this;
-    }
-
-    public UpnpEntry withGenre(String genre) {
-        this.genre = genre;
-        return this;
-    }
-
-    public UpnpEntry withResList(List<UpnpEntryRes> resList) {
-        this.resList = resList;
-        return this;
-    }
-
-    public UpnpEntry withTrackNumber(@Nullable Integer originalTrackNumber) {
-        this.originalTrackNumber = originalTrackNumber;
-        return this;
-    }
-
-    /**
-     * @return the title of the entry.
-     */
-    @Override
-    public String toString() {
-        return title;
-    }
-
-    /**
-     * @return the unique identifier of this entry.
-     */
-    public String getId() {
-        return id;
-    }
-
-    /**
-     * @return the title of the entry.
-     */
-    public String getTitle() {
-        return title;
-    }
-
-    /**
-     * @return the identifier of the entry this reference intry refers to.
-     */
-    public String getRefId() {
-        return refId;
-    }
-
-    /**
-     * @return the unique identifier of the parent of this entry.
-     */
-    public String getParentId() {
-        return parentId.isEmpty() ? DIRECTORY_ROOT : parentId;
-    }
-
-    /**
-     * @return a URI for this entry. Thumbnail resources are not considered.
-     */
-    public String getRes() {
-        return resList.stream().filter(res -> !res.isThumbnailRes()).map(UpnpEntryRes::getRes).findAny().orElse("");
-    }
-
-    public List<String> getProtocolList() {
-        return resList.stream().map(UpnpEntryRes::getProtocolInfo).collect(Collectors.toList());
-    }
-
-    /**
-     * @return the UPnP classname for this entry.
-     */
-    public String getUpnpClass() {
-        return upnpClass;
-    }
-
-    public boolean isContainer() {
-        return isContainer;
-    }
-
-    /**
-     * @return the name of the album.
-     */
-    public String getAlbum() {
-        return album;
-    }
-
-    /**
-     * @return the URI for the album art.
-     */
-    public String getAlbumArtUri() {
-        return StringEscapeUtils.unescapeXml(albumArtUri);
-    }
-
-    /**
-     * @return the name of the artist who created the entry.
-     */
-    public String getCreator() {
-        return creator;
-    }
-
-    public String getArtist() {
-        return artist;
-    }
-
-    public String getPublisher() {
-        return publisher;
-    }
-
-    public String getGenre() {
-        return genre;
-    }
-
-    public @Nullable Integer getOriginalTrackNumber() {
-        return originalTrackNumber;
-    }
-}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntryRes.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpEntryRes.java
deleted file mode 100644 (file)
index 7c0944a..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-
-/**
- *
- * @author Mark Herwege - Initial contribution
- */
-@NonNullByDefault
-class UpnpEntryRes {
-
-    private String protocolInfo;
-    private @Nullable Long size;
-    private String duration;
-    private String importUri;
-    private String res = "";
-
-    UpnpEntryRes(String protocolInfo, @Nullable Long size, @Nullable String duration, @Nullable String importUri) {
-        this.protocolInfo = protocolInfo;
-        this.size = size;
-        this.duration = (duration == null) ? "" : duration;
-        this.importUri = (importUri == null) ? "" : importUri;
-    }
-
-    /**
-     * @return the res
-     */
-    public String getRes() {
-        return res;
-    }
-
-    /**
-     * @param res the res to set
-     */
-    public void setRes(String res) {
-        this.res = res;
-    }
-
-    public String getProtocolInfo() {
-        return protocolInfo;
-    }
-
-    /**
-     * @return the size
-     */
-    public @Nullable Long getSize() {
-        return size;
-    }
-
-    /**
-     * @return the duration
-     */
-    public String getDuration() {
-        return duration;
-    }
-
-    /**
-     * @return the importUri
-     */
-    public String getImportUri() {
-        return importUri;
-    }
-
-    /**
-     * @return true if this resource defines a thumbnail as specified in the DLNA specs
-     */
-    public boolean isThumbnailRes() {
-        return getProtocolInfo().toLowerCase().contains("dlna.org_pn=jpeg_tn");
-    }
-}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpProtocolMatcher.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpProtocolMatcher.java
deleted file mode 100644 (file)
index 11f4fe8..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.stream.Collectors;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- *
- * @author Mark Herwege - Initial contribution
- */
-@NonNullByDefault
-public final class UpnpProtocolMatcher {
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(UpnpProtocolMatcher.class);
-
-    private UpnpProtocolMatcher() {
-    }
-
-    /**
-     * Test if an UPnP protocol matches the object class. This method is used to filter resources for the primary
-     * resource.
-     *
-     * @param protocol format: <protocol>:<network>:<contentFormat>:<additionalInfo>
-     *            e.g. http-get:*:audio/mpeg:*
-     * @param objectClass e.g. object.item.audioItem.musicTrack
-     * @return true if protocol matches objectClass
-     */
-    public static boolean testProtocol(String protocol, String objectClass) {
-        String[] protocolDetails = protocol.split(":");
-        if (protocolDetails.length < 3) {
-            LOGGER.debug("Protocol string {} not valid", protocol);
-            return false;
-        }
-        String protocolType = protocolDetails[2].toLowerCase();
-        int index = protocolType.indexOf("/");
-        if (index <= 0) {
-            LOGGER.debug("Protocol string {} not valid", protocol);
-            return false;
-        }
-        protocolType = protocolType.substring(0, index);
-
-        String[] objectClassDetails = objectClass.split("\\.");
-        if (objectClassDetails.length < 3) {
-            LOGGER.debug("Object class {} not valid", objectClass);
-            return false;
-        }
-        String objectType = objectClassDetails[2].toLowerCase();
-
-        LOGGER.debug("Matching protocol type '{}' with object type '{}'", protocolType, objectType);
-        return objectType.startsWith(protocolType);
-    }
-
-    /**
-     * Test if a UPnP protocol is in a set of protocols.
-     * Ignore vendor specific additionalInfo part in UPnP protocol string.
-     * Do all comparisons in lower case.
-     *
-     * @param protocol format: <protocol>:<network>:<contentFormat>:<additionalInfo>
-     * @param protocolSet
-     * @return true if protocol in protocolSet
-     */
-    public static boolean testProtocol(String protocol, List<String> protocolSet) {
-        int index = protocol.lastIndexOf(":");
-        if (index <= 0) {
-            LOGGER.debug("Protocol {} not valid", protocol);
-            return false;
-        }
-        String p = protocol.toLowerCase().substring(0, index);
-        List<String> pSet = new ArrayList<>();
-        protocolSet.forEach(f -> {
-            int i = f.lastIndexOf(":");
-            if (i <= 0) {
-                LOGGER.debug("Protocol {} from set not valid", f);
-            } else {
-                pSet.add(f.toLowerCase().substring(0, i));
-            }
-        });
-        LOGGER.trace("Testing {} in {}", p, pSet);
-        return pSet.contains(p);
-    }
-
-    /**
-     * Test if any of the UPnP protocols in protocolList can be found in a set of protocols.
-     *
-     * @param protocolList
-     * @param protocolSet
-     * @return true if one of the protocols in protocolSet
-     */
-    public static boolean testProtocolList(List<String> protocolList, List<String> protocolSet) {
-        return protocolList.stream().anyMatch(p -> testProtocol(p, protocolSet));
-    }
-
-    /**
-     * Return all UPnP protocols from protocolList that are part of a set of protocols.
-     *
-     * @param protocolList
-     * @param protocolSet
-     * @return sublist of protocolList
-     */
-    public static List<String> getProtocols(List<String> protocolList, List<String> protocolSet) {
-        return protocolList.stream().filter(p -> testProtocol(p, protocolSet)).collect(Collectors.toList());
-    }
-}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpXMLParser.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/UpnpXMLParser.java
deleted file mode 100644 (file)
index f983180..0000000
+++ /dev/null
@@ -1,364 +0,0 @@
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.upnpcontrol.internal;
-
-import java.io.IOException;
-import java.io.StringReader;
-import java.text.MessageFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import javax.xml.parsers.ParserConfigurationException;
-import javax.xml.parsers.SAXParser;
-import javax.xml.parsers.SAXParserFactory;
-
-import org.apache.commons.lang.StringEscapeUtils;
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.xml.sax.Attributes;
-import org.xml.sax.InputSource;
-import org.xml.sax.SAXException;
-import org.xml.sax.helpers.DefaultHandler;
-
-/**
- *
- * @author Mark Herwege - Initial contribution
- * @author Karel Goderis - Based on UPnP logic in Sonos binding
- */
-@NonNullByDefault
-public class UpnpXMLParser {
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(UpnpXMLParser.class);
-
-    private static final MessageFormat METADATA_FORMAT = new MessageFormat(
-            "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" "
-                    + "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" "
-                    + "xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">"
-                    + "<item id=\"{0}\" parentID=\"{1}\" restricted=\"true\">" + "<dc:title>{2}</dc:title>"
-                    + "<upnp:class>{3}</upnp:class>" + "<upnp:album>{4}</upnp:album>"
-                    + "<upnp:albumArtURI>{5}</upnp:albumArtURI>" + "<dc:creator>{6}</dc:creator>"
-                    + "<upnp:artist>{7}</upnp:artist>" + "<dc:publisher>{8}</dc:publisher>"
-                    + "<upnp:genre>{9}</upnp:genre>" + "<upnp:originalTrackNumber>{10}</upnp:originalTrackNumber>"
-                    + "</item></DIDL-Lite>");
-
-    private enum Element {
-        TITLE,
-        CLASS,
-        ALBUM,
-        ALBUM_ART_URI,
-        CREATOR,
-        ARTIST,
-        PUBLISHER,
-        GENRE,
-        TRACK_NUMBER,
-        RES
-    }
-
-    public static Map<String, String> getAVTransportFromXML(String xml) {
-        if (xml.isEmpty()) {
-            LOGGER.debug("Could not parse AV Transport from empty xml");
-            return Collections.emptyMap();
-        }
-        AVTransportEventHandler handler = new AVTransportEventHandler();
-        try {
-            SAXParserFactory factory = SAXParserFactory.newInstance();
-            SAXParser saxParser = factory.newSAXParser();
-            saxParser.parse(new InputSource(new StringReader(xml)), handler);
-        } catch (IOException e) {
-            // This should never happen - we're not performing I/O!
-            LOGGER.error("Could not parse AV Transport from string '{}'", xml, e);
-        } catch (SAXException | ParserConfigurationException s) {
-            LOGGER.debug("Could not parse AV Transport from string '{}'", xml, s);
-        }
-        return handler.getChanges();
-    }
-
-    /**
-     * @param xml
-     * @return a list of Entries from the given xml string.
-     * @throws IOException
-     * @throws SAXException
-     */
-    public static List<UpnpEntry> getEntriesFromXML(String xml) {
-        if (xml.isEmpty()) {
-            LOGGER.debug("Could not parse Entries from empty xml");
-            return Collections.emptyList();
-        }
-        EntryHandler handler = new EntryHandler();
-        try {
-            SAXParserFactory factory = SAXParserFactory.newInstance();
-            SAXParser saxParser = factory.newSAXParser();
-            saxParser.parse(new InputSource(new StringReader(xml)), handler);
-        } catch (IOException e) {
-            // This should never happen - we're not performing I/O!
-            LOGGER.error("Could not parse Entries from string '{}'", xml, e);
-        } catch (SAXException | ParserConfigurationException s) {
-            LOGGER.debug("Could not parse Entries from string '{}'", xml, s);
-        }
-        return handler.getEntries();
-    }
-
-    private static class AVTransportEventHandler extends DefaultHandler {
-
-        private final Map<String, String> changes = new HashMap<String, String>();
-
-        AVTransportEventHandler() {
-            // shouldn't be used outside of this package.
-        }
-
-        @Override
-        public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
-                @Nullable Attributes atts) throws SAXException {
-            /*
-             * The events are all of the form <qName val="value"/> so we can get all
-             * the info we need from here.
-             */
-            if ((qName != null) && (atts != null) && (atts.getValue("val") != null)) {
-                changes.put(qName, atts.getValue("val"));
-            }
-        }
-
-        public Map<String, String> getChanges() {
-            return changes;
-        }
-    }
-
-    private static class EntryHandler extends DefaultHandler {
-
-        // Maintain a set of elements it is not useful to complain about.
-        // This list will be initialized on the first failure case.
-        private static List<String> ignore = new ArrayList<String>();
-
-        private String id = "";
-        private String refId = "";
-        private String parentId = "";
-        private StringBuilder upnpClass = new StringBuilder();
-        private List<UpnpEntryRes> resList = new ArrayList<>();
-        private StringBuilder res = new StringBuilder();
-        private StringBuilder title = new StringBuilder();
-        private StringBuilder album = new StringBuilder();
-        private StringBuilder albumArtUri = new StringBuilder();
-        private StringBuilder creator = new StringBuilder();
-        private StringBuilder artist = new StringBuilder();
-        private List<String> artistList = new ArrayList<>();
-        private StringBuilder publisher = new StringBuilder();
-        private StringBuilder genre = new StringBuilder();
-        private StringBuilder trackNumber = new StringBuilder();
-        private @Nullable Element element = null;
-
-        private List<UpnpEntry> entries = new ArrayList<>();
-
-        EntryHandler() {
-            // shouldn't be used outside of this package.
-        }
-
-        @Override
-        public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
-                @Nullable Attributes attributes) throws SAXException {
-            if (qName == null) {
-                element = null;
-                return;
-            }
-            switch (qName) {
-                case "container":
-                case "item":
-                    if (attributes != null) {
-                        if (attributes.getValue("id") != null) {
-                            id = attributes.getValue("id");
-                        }
-                        if (attributes.getValue("refID") != null) {
-                            refId = attributes.getValue("refID");
-                        }
-                        if (attributes.getValue("parentID") != null) {
-                            parentId = attributes.getValue("parentID");
-                        }
-                    }
-                    break;
-                case "res":
-                    if (attributes != null) {
-                        String protocolInfo = attributes.getValue("protocolInfo");
-                        Long size;
-                        try {
-                            size = Long.parseLong(attributes.getValue("size"));
-                        } catch (NumberFormatException e) {
-                            size = null;
-                        }
-                        String duration = attributes.getValue("duration");
-                        String importUri = attributes.getValue("importUri");
-                        resList.add(0, new UpnpEntryRes(protocolInfo, size, duration, importUri));
-                        element = Element.RES;
-                    }
-                    break;
-                case "dc:title":
-                    element = Element.TITLE;
-                    break;
-                case "upnp:class":
-                    element = Element.CLASS;
-                    break;
-                case "dc:creator":
-                    element = Element.CREATOR;
-                    break;
-                case "upnp:artist":
-                    element = Element.ARTIST;
-                    break;
-                case "dc:publisher":
-                    element = Element.PUBLISHER;
-                    break;
-                case "upnp:genre":
-                    element = Element.GENRE;
-                    break;
-                case "upnp:album":
-                    element = Element.ALBUM;
-                    break;
-                case "upnp:albumArtURI":
-                    element = Element.ALBUM_ART_URI;
-                    break;
-                case "upnp:originalTrackNumber":
-                    element = Element.TRACK_NUMBER;
-                    break;
-                default:
-                    if (ignore.isEmpty()) {
-                        ignore.add("");
-                        ignore.add("DIDL-Lite");
-                        ignore.add("type");
-                        ignore.add("ordinal");
-                        ignore.add("description");
-                        ignore.add("writeStatus");
-                        ignore.add("storageUsed");
-                        ignore.add("supported");
-                        ignore.add("pushSource");
-                        ignore.add("icon");
-                        ignore.add("playlist");
-                        ignore.add("date");
-                        ignore.add("rating");
-                        ignore.add("userrating");
-                        ignore.add("episodeSeason");
-                        ignore.add("childCountContainer");
-                        ignore.add("modificationTime");
-                        ignore.add("containerContent");
-                    }
-                    if (!ignore.contains(localName)) {
-                        LOGGER.debug("Did not recognise element named {}", localName);
-                    }
-                    element = null;
-            }
-        }
-
-        @Override
-        public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
-            Element el = element;
-            if (el == null || ch == null) {
-                return;
-            }
-            switch (el) {
-                case TITLE:
-                    title.append(ch, start, length);
-                    break;
-                case CLASS:
-                    upnpClass.append(ch, start, length);
-                    break;
-                case RES:
-                    res.append(ch, start, length);
-                    break;
-                case ALBUM:
-                    album.append(ch, start, length);
-                    break;
-                case ALBUM_ART_URI:
-                    albumArtUri.append(ch, start, length);
-                    break;
-                case CREATOR:
-                    creator.append(ch, start, length);
-                    break;
-                case ARTIST:
-                    artist.append(ch, start, length);
-                    break;
-                case PUBLISHER:
-                    publisher.append(ch, start, length);
-                    break;
-                case GENRE:
-                    genre.append(ch, start, length);
-                    break;
-                case TRACK_NUMBER:
-                    trackNumber.append(ch, start, length);
-                    break;
-            }
-        }
-
-        @Override
-        public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
-                throws SAXException {
-            if ("container".equals(qName) || "item".equals(qName)) {
-                element = null;
-
-                Integer trackNumberVal;
-                try {
-                    trackNumberVal = Integer.parseInt(trackNumber.toString());
-                } catch (NumberFormatException e) {
-                    trackNumberVal = null;
-                }
-
-                entries.add(new UpnpEntry(id, refId, parentId, upnpClass.toString()).withTitle(title.toString())
-                        .withAlbum(album.toString()).withAlbumArtUri(albumArtUri.toString())
-                        .withCreator(creator.toString())
-                        .withArtist(artistList.size() > 0 ? artistList.get(0) : artist.toString())
-                        .withPublisher(publisher.toString()).withGenre(genre.toString()).withTrackNumber(trackNumberVal)
-                        .withResList(resList));
-
-                title = new StringBuilder();
-                upnpClass = new StringBuilder();
-                resList = new ArrayList<>();
-                album = new StringBuilder();
-                albumArtUri = new StringBuilder();
-                creator = new StringBuilder();
-                artistList = new ArrayList<>();
-                publisher = new StringBuilder();
-                genre = new StringBuilder();
-                trackNumber = new StringBuilder();
-            } else if ("res".equals(qName)) {
-                resList.get(0).setRes(res.toString());
-                res = new StringBuilder();
-            } else if ("upnp:artist".equals(qName)) {
-                artistList.add(artist.toString());
-                artist = new StringBuilder();
-            }
-        }
-
-        public List<UpnpEntry> getEntries() {
-            return entries;
-        }
-    }
-
-    public static String compileMetadataString(UpnpEntry entry) {
-        String id = entry.getId();
-        String parentId = entry.getParentId();
-        String title = StringEscapeUtils.escapeXml(entry.getTitle());
-        String upnpClass = entry.getUpnpClass();
-        String album = StringEscapeUtils.escapeXml(entry.getAlbum());
-        String albumArtUri = entry.getAlbumArtUri();
-        String creator = StringEscapeUtils.escapeXml(entry.getCreator());
-        String artist = StringEscapeUtils.escapeXml(entry.getArtist());
-        String publisher = StringEscapeUtils.escapeXml(entry.getPublisher());
-        String genre = StringEscapeUtils.escapeXml(entry.getGenre());
-        Integer trackNumber = entry.getOriginalTrackNumber();
-
-        String metadata = METADATA_FORMAT.format(new Object[] { id, parentId, title, upnpClass, album, albumArtUri,
-                creator, artist, publisher, genre, trackNumber });
-
-        return metadata;
-    }
-}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSink.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSink.java
new file mode 100644 (file)
index 0000000..fd80703
--- /dev/null
@@ -0,0 +1,125 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.audiosink;
+
+import java.io.IOException;
+import java.util.Locale;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
+import org.openhab.core.audio.AudioFormat;
+import org.openhab.core.audio.AudioHTTPServer;
+import org.openhab.core.audio.AudioSink;
+import org.openhab.core.audio.AudioStream;
+import org.openhab.core.audio.FixedLengthAudioStream;
+import org.openhab.core.audio.URLAudioStream;
+import org.openhab.core.audio.UnsupportedAudioFormatException;
+import org.openhab.core.audio.UnsupportedAudioStreamException;
+import org.openhab.core.library.types.PercentType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpAudioSink implements AudioSink {
+
+    private final Logger logger = LoggerFactory.getLogger(UpnpAudioSink.class);
+
+    private static final Set<Class<? extends AudioStream>> SUPPORTED_STREAMS = Stream
+            .of(AudioStream.class, FixedLengthAudioStream.class).collect(Collectors.toSet());
+    protected UpnpRendererHandler handler;
+    protected AudioHTTPServer audioHTTPServer;
+    protected String callbackUrl;
+
+    public UpnpAudioSink(UpnpRendererHandler handler, AudioHTTPServer audioHTTPServer, String callbackUrl) {
+        this.handler = handler;
+        this.audioHTTPServer = audioHTTPServer;
+        this.callbackUrl = callbackUrl;
+    }
+
+    @Override
+    public String getId() {
+        return handler.getThing().getUID().toString();
+    }
+
+    @Override
+    public @Nullable String getLabel(@Nullable Locale locale) {
+        return handler.getThing().getLabel();
+    }
+
+    @Override
+    public void process(@Nullable AudioStream audioStream)
+            throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
+        if (audioStream == null) {
+            stopMedia();
+            return;
+        }
+
+        String url = null;
+        if (audioStream instanceof URLAudioStream) {
+            URLAudioStream urlAudioStream = (URLAudioStream) audioStream;
+            url = urlAudioStream.getURL();
+        } else if (!callbackUrl.isEmpty()) {
+            String relativeUrl = audioStream instanceof FixedLengthAudioStream
+                    ? audioHTTPServer.serve((FixedLengthAudioStream) audioStream, 20)
+                    : audioHTTPServer.serve(audioStream);
+            url = String.valueOf(this.callbackUrl) + relativeUrl;
+        } else {
+            logger.warn("We do not have any callback url, so {} cannot play the audio stream!", handler.getUDN());
+            return;
+        }
+        playMedia(url);
+    }
+
+    @Override
+    public Set<AudioFormat> getSupportedFormats() {
+        return handler.getSupportedAudioFormats();
+    }
+
+    @Override
+    public Set<Class<? extends AudioStream>> getSupportedStreams() {
+        return SUPPORTED_STREAMS;
+    }
+
+    @Override
+    public PercentType getVolume() throws IOException {
+        return handler.getCurrentVolume();
+    }
+
+    @Override
+    public void setVolume(@Nullable PercentType volume) throws IOException {
+        if (volume != null) {
+            handler.setVolume(volume);
+        }
+    }
+
+    protected void stopMedia() {
+        handler.stop();
+    }
+
+    protected void playMedia(String url) {
+        String newUrl = url;
+        if (!url.startsWith("x-") && !url.startsWith("http")) {
+            newUrl = "x-file-cifs:" + url;
+        }
+        handler.setCurrentURI(newUrl, "");
+        handler.play();
+    }
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSinkReg.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpAudioSinkReg.java
new file mode 100644 (file)
index 0000000..30bba76
--- /dev/null
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.audiosink;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.upnpcontrol.internal.UpnpControlHandlerFactory;
+import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
+
+/**
+ * Interface class to be implemented in {@link UpnpControlHandlerFactory}, allows a {UpnpRendererHandler} to register
+ * itself as an audio sink when it supports audio. If it supports audio is only known after the communication with the
+ * renderer is established.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public interface UpnpAudioSinkReg {
+
+    /**
+     * Implemented method should create a new {@link UpnpAudioSink} and register the handler parameter as an audio sink.
+     *
+     * @param handler
+     */
+    void registerAudioSink(UpnpRendererHandler handler);
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpNotificationAudioSink.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/audiosink/UpnpNotificationAudioSink.java
new file mode 100644 (file)
index 0000000..4756c32
--- /dev/null
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.audiosink;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.NOTIFICATION_AUDIOSINK_EXTENSION;
+
+import java.io.IOException;
+import java.util.Locale;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
+import org.openhab.core.audio.AudioHTTPServer;
+import org.openhab.core.library.types.PercentType;
+
+/**
+ *
+ * This class works as a standard audio sink for openHAB, but with specific behavior for the audio players. It is only
+ * meant to be used for playing notifications. When sending audio through this sink, the previously playing media will
+ * be interrupted and will automatically resume after playing the notification. If no volume is specified, the
+ * notification volume will be controlled by the media player notification volume configuration.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpNotificationAudioSink extends UpnpAudioSink {
+
+    public UpnpNotificationAudioSink(UpnpRendererHandler handler, AudioHTTPServer audioHTTPServer, String callbackUrl) {
+        super(handler, audioHTTPServer, callbackUrl);
+    }
+
+    @Override
+    public String getId() {
+        return handler.getThing().getUID().toString() + NOTIFICATION_AUDIOSINK_EXTENSION;
+    }
+
+    @Override
+    public @Nullable String getLabel(@Nullable Locale locale) {
+        return handler.getThing().getLabel() + NOTIFICATION_AUDIOSINK_EXTENSION;
+    }
+
+    @Override
+    public void setVolume(@Nullable PercentType volume) throws IOException {
+        if (volume != null) {
+            handler.setNotificationVolume(volume);
+        }
+    }
+
+    @Override
+    protected void playMedia(String url) {
+        String newUrl = url;
+        if (!url.startsWith("x-") && !url.startsWith("http")) {
+            newUrl = "x-file-cifs:" + url;
+        }
+        handler.playNotification(newUrl);
+    }
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlBindingConfiguration.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlBindingConfiguration.java
new file mode 100644 (file)
index 0000000..78efb8e
--- /dev/null
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.config;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.DEFAULT_PATH;
+
+import java.io.File;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class containing the binding configuration parameters. Some helper methods take care of updating the relevant classes
+ * with parameter changes.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpControlBindingConfiguration {
+    private final Logger logger = LoggerFactory.getLogger(UpnpControlBindingConfiguration.class);
+
+    public String path = DEFAULT_PATH;
+
+    public void update(UpnpControlBindingConfiguration newConfig) {
+        String newPath = newConfig.path;
+
+        if (newPath.isEmpty()) {
+            path = DEFAULT_PATH;
+        } else {
+            File file = new File(newPath);
+            if (!file.isDirectory()) {
+                file = file.getParentFile();
+            }
+            if (file.exists()) {
+                if (!(newPath.endsWith(File.separator) || newPath.endsWith("/"))) {
+                    newPath = newPath + File.separator;
+                }
+                path = newPath;
+            } else {
+                path = DEFAULT_PATH;
+            }
+        }
+
+        logger.debug("Storage path updated to {}", path);
+
+        UpnpControlUtil.bindingConfigurationChanged(path);
+    }
+}
index c62bb692c4be376ce9a65bea7557c87a316ea870..9717ce08a1459adf939d2c0851e44aa2438f6b25 100644 (file)
@@ -23,4 +23,6 @@ import org.eclipse.jdt.annotation.Nullable;
 @NonNullByDefault
 public class UpnpControlConfiguration {
     public @Nullable String udn;
+    public int refresh = 60;
+    public int responseTimeout = 2500;
 }
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlRendererConfiguration.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/config/UpnpControlRendererConfiguration.java
new file mode 100644 (file)
index 0000000..b179700
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpControlRendererConfiguration extends UpnpControlConfiguration {
+    public int notificationVolumeAdjustment = 10;
+    public int maxNotificationDuration = 15;
+    public int seekStep = 5;
+}
index 6ec2812a4297e9ead30546fea6fecd131ef29294..3c26dc008d211f945becb8b5906926e087b30f58 100644 (file)
@@ -21,5 +21,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
 @NonNullByDefault
 public class UpnpControlServerConfiguration extends UpnpControlConfiguration {
     public boolean filter = false;
-    public String sortcriteria = "+dc:title";
+    public String sortCriteria = "+dc:title";
+    public boolean browseDown = true;
+    public boolean searchFromRoot = false;
 }
index 8987b7eb0095e5195f30b125cc49ac2602475c6b..dc78a67176bce316942d7de5e688e7c2268a968e 100644 (file)
@@ -14,6 +14,7 @@ package org.openhab.binding.upnpcontrol.internal.discovery;
 
 import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
 
+import java.net.URL;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Set;
@@ -21,6 +22,7 @@ import java.util.Set;
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.jupnp.model.meta.RemoteDevice;
+import org.jupnp.model.meta.RemoteService;
 import org.openhab.core.config.discovery.DiscoveryResult;
 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
 import org.openhab.core.config.discovery.upnp.UpnpDiscoveryParticipant;
@@ -53,8 +55,17 @@ public class UpnpControlDiscoveryParticipant implements UpnpDiscoveryParticipant
             String label = device.getDetails().getFriendlyName().isEmpty() ? device.getDisplayString()
                     : device.getDetails().getFriendlyName();
             Map<String, Object> properties = new HashMap<>();
-            properties.put("ipAddress", device.getIdentity().getDescriptorURL().getHost());
+            URL descriptorURL = device.getIdentity().getDescriptorURL();
+            properties.put("ipAddress", descriptorURL.getHost());
             properties.put("udn", device.getIdentity().getUdn().getIdentifierString());
+            properties.put("deviceDescrURL", descriptorURL.toString());
+            URL baseURL = device.getDetails().getBaseURL();
+            if (baseURL != null) {
+                properties.put("baseURL", device.getDetails().getBaseURL().toString());
+            }
+            for (RemoteService service : device.getServices()) {
+                properties.put(service.getServiceType().getType() + "DescrURI", service.getDescriptorURI().toString());
+            }
             result = DiscoveryResultBuilder.create(thingUid).withLabel(label).withProperties(properties)
                     .withRepresentationProperty("udn").build();
         }
@@ -68,9 +79,10 @@ public class UpnpControlDiscoveryParticipant implements UpnpDiscoveryParticipant
         String manufacturer = device.getDetails().getManufacturerDetails().getManufacturer();
         String model = device.getDetails().getModelDetails().getModelName();
         String serialNumber = device.getDetails().getSerialNumber();
+        String udn = device.getIdentity().getUdn().getIdentifierString();
 
-        logger.debug("Device type {}, manufacturer {}, model {}, SN# {}", deviceType, manufacturer, model,
-                serialNumber);
+        logger.debug("Device type {}, manufacturer {}, model {}, SN# {}, UDN {}", deviceType, manufacturer, model,
+                serialNumber, udn);
 
         if (deviceType.equalsIgnoreCase("MediaRenderer")) {
             this.logger.debug("Media renderer found: {}, {}", manufacturer, model);
index ee5a3483348d7a1c91fa2a91dc0919d72f84dfd0..2b3430b7bc350a42d9cc0688f65293f653a578b6 100644 (file)
  */
 package org.openhab.binding.upnpcontrol.internal.handler;
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
+import org.jupnp.model.meta.RemoteDevice;
+import org.jupnp.registry.RegistryListener;
+import org.openhab.binding.upnpcontrol.internal.UpnpChannelName;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlConfiguration;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpPlaylistsListener;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
+import org.openhab.core.common.ThreadPoolManager;
 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
 import org.openhab.core.io.transport.upnp.UpnpIOService;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
 import org.openhab.core.thing.Thing;
 import org.openhab.core.thing.ThingStatus;
 import org.openhab.core.thing.ThingStatusDetail;
 import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.CommandDescription;
+import org.openhab.core.types.CommandDescriptionBuilder;
+import org.openhab.core.types.CommandOption;
+import org.openhab.core.types.StateDescription;
+import org.openhab.core.types.StateDescriptionFragmentBuilder;
+import org.openhab.core.types.StateOption;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * The {@link UpnpHandler} is the base class for {@link UpnpRendererHandler} and {@link UpnpServerHandler}.
+ * The {@link UpnpHandler} is the base class for {@link UpnpRendererHandler} and {@link UpnpServerHandler}. The base
+ * class implements UPnPConnectionManager service actions.
  *
  * @author Mark Herwege - Initial contribution
  * @author Karel Goderis - Based on UPnP logic in Sonos binding
  */
 @NonNullByDefault
-public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOParticipant {
+public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOParticipant, UpnpPlaylistsListener {
 
     private final Logger logger = LoggerFactory.getLogger(UpnpHandler.class);
 
-    protected UpnpIOService service;
-    protected volatile String transportState = "";
-    protected volatile int connectionId;
-    protected volatile int avTransportId;
-    protected volatile int rcsId;
-    protected @NonNullByDefault({}) UpnpControlConfiguration config;
+    // UPnP constants
+    static final String CONNECTION_MANAGER = "ConnectionManager";
+    static final String CONNECTION_ID = "ConnectionID";
+    static final String AV_TRANSPORT_ID = "AVTransportID";
+    static final String RCS_ID = "RcsID";
+    static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)");
 
-    public UpnpHandler(Thing thing, UpnpIOService upnpIOService) {
+    protected UpnpIOService upnpIOService;
+
+    protected volatile @Nullable RemoteDevice device;
+
+    // The handlers can potentially create an important number of tasks, therefore put them in a separate thread pool
+    protected ScheduledExecutorService upnpScheduler = ThreadPoolManager.getScheduledPool("binding-upnpcontrol");
+
+    private boolean updateChannels;
+    private final List<Channel> updatedChannels = new ArrayList<>();
+    private final List<ChannelUID> updatedChannelUIDs = new ArrayList<>();
+
+    protected volatile int connectionId = 0; // UPnP Connection Id
+    protected volatile int avTransportId = 0; // UPnP AVTtransport Id
+    protected volatile int rcsId = 0; // UPnP Rendering Control Id
+
+    protected UpnpControlBindingConfiguration bindingConfig;
+    protected UpnpControlConfiguration config;
+
+    protected final Object invokeActionLock = new Object();
+
+    protected @Nullable ScheduledFuture<?> pollingJob;
+    protected final Object jobLock = new Object();
+
+    protected volatile @Nullable CompletableFuture<Boolean> isConnectionIdSet;
+    protected volatile @Nullable CompletableFuture<Boolean> isAvTransportIdSet;
+    protected volatile @Nullable CompletableFuture<Boolean> isRcsIdSet;
+
+    protected static final int SUBSCRIPTION_DURATION_SECONDS = 3600;
+    protected List<String> serviceSubscriptions = new ArrayList<>();
+    protected volatile @Nullable ScheduledFuture<?> subscriptionRefreshJob;
+    protected final Runnable subscriptionRefresh = () -> {
+        for (String subscription : serviceSubscriptions) {
+            removeSubscription(subscription);
+            addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
+        }
+    };
+    protected volatile boolean upnpSubscribed;
+
+    protected UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
+    protected UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
+
+    public UpnpHandler(Thing thing, UpnpIOService upnpIOService, UpnpControlBindingConfiguration configuration,
+            UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
+            UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
         super(thing);
 
-        upnpIOService.registerParticipant(this);
-        this.service = upnpIOService;
+        this.upnpIOService = upnpIOService;
+
+        this.bindingConfig = configuration;
+
+        this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
+        this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
+
+        // Get this in constructor, so the UDN is immediately available from the config. The concrete classes should
+        // update the config from the initialize method.
+        config = getConfigAs(UpnpControlConfiguration.class);
     }
 
     @Override
     public void initialize() {
         config = getConfigAs(UpnpControlConfiguration.class);
-        service.registerParticipant(this);
+
+        upnpIOService.registerParticipant(this);
+
+        UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
+        UpnpControlUtil.playlistsSubscribe(this);
     }
 
     @Override
     public void dispose() {
-        service.unregisterParticipant(this);
+        cancelPollingJob();
+        removeSubscriptions();
+
+        UpnpControlUtil.playlistsUnsubscribe(this);
+
+        CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
+        if (connectionIdFuture != null) {
+            connectionIdFuture.complete(false);
+            isConnectionIdSet = null;
+        }
+        CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
+        if (avTransportIdFuture != null) {
+            avTransportIdFuture.complete(false);
+            isAvTransportIdSet = null;
+        }
+        CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
+        if (rcsIdFuture != null) {
+            rcsIdFuture.complete(false);
+            isRcsIdSet = null;
+        }
+
+        updateChannels = false;
+        updatedChannels.clear();
+        updatedChannelUIDs.clear();
+
+        upnpIOService.removeStatusListener(this);
+        upnpIOService.unregisterParticipant(this);
+    }
+
+    private void cancelPollingJob() {
+        ScheduledFuture<?> job = pollingJob;
+
+        if (job != null) {
+            job.cancel(true);
+        }
+        pollingJob = null;
+    }
+
+    /**
+     * To be called from implementing classes when initializing the device, to start initialization refresh
+     */
+    protected void initDevice() {
+        String udn = getUDN();
+        if ((udn != null) && !udn.isEmpty()) {
+            if (config.refresh == 0) {
+                upnpScheduler.submit(this::initJob);
+            } else {
+                pollingJob = upnpScheduler.scheduleWithFixedDelay(this::initJob, 0, config.refresh, TimeUnit.SECONDS);
+            }
+        } else {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "No UDN configured for " + thing.getLabel());
+        }
+    }
+
+    /**
+     * Job to be executed in an asynchronous process when initializing a device. This checks if the connection id's are
+     * correctly set up for the connection. It can also be called from a polling job to get the thing back online when
+     * connection is lost.
+     */
+    protected abstract void initJob();
+
+    @Override
+    protected void updateStatus(ThingStatus status) {
+        ThingStatus currentStatus = thing.getStatus();
+
+        super.updateStatus(status);
+
+        // When status changes to ThingStatus.ONLINE, make sure to refresh all linked channels
+        if (!status.equals(currentStatus) && status.equals(ThingStatus.ONLINE)) {
+            thing.getChannels().forEach(channel -> {
+                if (isLinked(channel.getUID())) {
+                    channelLinked(channel.getUID());
+                }
+            });
+        }
+    }
+
+    /**
+     * Method called when a the remote device represented by the thing for this handler is added to the jupnp
+     * {@link RegistryListener} or is updated. Configuration info can be retrieved from the {@link RemoteDevice}.
+     *
+     * @param device
+     */
+    public void updateDeviceConfig(RemoteDevice device) {
+        this.device = device;
+    };
+
+    protected void updateStateDescription(ChannelUID channelUID, List<StateOption> stateOptionList) {
+        StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
+                .withOptions(stateOptionList).build().toStateDescription();
+        upnpStateDescriptionProvider.setDescription(channelUID, stateDescription);
+    }
+
+    protected void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
+        CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
+                .build();
+        upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
+    }
+
+    protected void createChannel(@Nullable UpnpChannelName upnpChannelName) {
+        if ((upnpChannelName != null)) {
+            createChannel(upnpChannelName.getChannelId(), upnpChannelName.getLabel(), upnpChannelName.getDescription(),
+                    upnpChannelName.getItemType(), upnpChannelName.getChannelType());
+        }
+    }
+
+    protected void createChannel(String channelId, String label, String description, String itemType,
+            String channelType) {
+        ChannelUID channelUID = new ChannelUID(thing.getUID(), channelId);
+
+        if (thing.getChannel(channelUID) != null) {
+            // channel already exists
+            logger.trace("UPnP device {}, channel {} already exists", thing.getLabel(), channelId);
+            return;
+        }
+
+        ChannelTypeUID channelTypeUID = new ChannelTypeUID(channelType);
+        Channel channel = ChannelBuilder.create(channelUID).withLabel(label).withDescription(description)
+                .withAcceptedItemType(itemType).withType(channelTypeUID).build();
+
+        logger.debug("UPnP device {}, created channel {}", thing.getLabel(), channelId);
+
+        updatedChannels.add(channel);
+        updatedChannelUIDs.add(channelUID);
+        updateChannels = true;
+    }
+
+    protected void updateChannels() {
+        if (updateChannels) {
+            List<Channel> channels = thing.getChannels().stream().filter(c -> !updatedChannelUIDs.contains(c.getUID()))
+                    .collect(Collectors.toList());
+            channels.addAll(updatedChannels);
+            final ThingBuilder thingBuilder = editThing();
+            thingBuilder.withChannels(channels);
+            updateThing(thingBuilder.build());
+        }
+        updatedChannels.clear();
+        updatedChannelUIDs.clear();
+        updateChannels = false;
     }
 
     /**
@@ -74,36 +300,84 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
      */
     protected void prepareForConnection(String remoteProtocolInfo, String peerConnectionManager, int peerConnectionId,
             String direction) {
+        CompletableFuture<Boolean> settingConnection = isConnectionIdSet;
+        CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
+        CompletableFuture<Boolean> settingRcs = isRcsIdSet;
+        if (settingConnection != null) {
+            settingConnection.complete(false);
+        }
+        if (settingAVTransport != null) {
+            settingAVTransport.complete(false);
+        }
+        if (settingRcs != null) {
+            settingRcs.complete(false);
+        }
+
+        // Set new futures, so we don't try to use service when connection id's are not known yet
+        isConnectionIdSet = new CompletableFuture<Boolean>();
+        isAvTransportIdSet = new CompletableFuture<Boolean>();
+        isRcsIdSet = new CompletableFuture<Boolean>();
+
         HashMap<String, String> inputs = new HashMap<String, String>();
         inputs.put("RemoteProtocolInfo", remoteProtocolInfo);
         inputs.put("PeerConnectionManager", peerConnectionManager);
         inputs.put("PeerConnectionID", Integer.toString(peerConnectionId));
         inputs.put("Direction", direction);
 
-        invokeAction("ConnectionManager", "PrepareForConnection", inputs);
+        invokeAction(CONNECTION_MANAGER, "PrepareForConnection", inputs);
     }
 
     /**
      * Invoke ConnectionComplete on UPnP Connection Manager.
-     *
-     * @param connectionId
      */
-    protected void connectionComplete(int connectionId) {
-        HashMap<String, String> inputs = new HashMap<String, String>();
-        inputs.put("ConnectionID", String.valueOf(connectionId));
+    protected void connectionComplete() {
+        Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
 
-        invokeAction("ConnectionManager", "ConnectionComplete", inputs);
+        invokeAction(CONNECTION_MANAGER, "ConnectionComplete", inputs);
     }
 
     /**
-     * Invoke GetTransportState on UPnP AV Transport.
+     * Invoke GetCurrentConnectionIDs on the UPnP Connection Manager.
      * Result is received in {@link onValueReceived}.
      */
-    protected void getTransportState() {
-        HashMap<String, String> inputs = new HashMap<String, String>();
-        inputs.put("InstanceID", Integer.toString(avTransportId));
+    protected void getCurrentConnectionIDs() {
+        Map<String, String> inputs = Collections.emptyMap();
+
+        invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionIDs", inputs);
+    }
+
+    /**
+     * Invoke GetCurrentConnectionInfo on the UPnP Connection Manager.
+     * Result is received in {@link onValueReceived}.
+     */
+    protected void getCurrentConnectionInfo() {
+        CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
+        CompletableFuture<Boolean> settingRcs = isRcsIdSet;
+        if (settingAVTransport != null) {
+            settingAVTransport.complete(false);
+        }
+        if (settingRcs != null) {
+            settingRcs.complete(false);
+        }
+
+        // Set new futures, so we don't try to use service when connection id's are not known yet
+        isAvTransportIdSet = new CompletableFuture<Boolean>();
+        isRcsIdSet = new CompletableFuture<Boolean>();
 
-        invokeAction("AVTransport", "GetTransportInfo", inputs);
+        // ConnectionID will default to 0 if not set through prepareForConnection method
+        Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
+
+        invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionInfo", inputs);
+    }
+
+    /**
+     * Invoke GetFeatureList on the UPnP Connection Manager.
+     * Result is received in {@link onValueReceived}.
+     */
+    protected void getFeatureList() {
+        Map<String, String> inputs = Collections.emptyMap();
+
+        invokeAction(CONNECTION_MANAGER, "GetFeatureList", inputs);
     }
 
     /**
@@ -111,32 +385,33 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
      * Result is received in {@link onValueReceived}.
      */
     protected void getProtocolInfo() {
-        Map<String, String> inputs = new HashMap<>();
+        Map<String, String> inputs = Collections.emptyMap();
 
-        invokeAction("ConnectionManager", "GetProtocolInfo", inputs);
+        invokeAction(CONNECTION_MANAGER, "GetProtocolInfo", inputs);
     }
 
     @Override
     public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
-        logger.debug("Upnp device {} received subscription reply {} from service {}", thing.getLabel(), succeeded,
+        logger.debug("UPnP device {} received subscription reply {} from service {}", thing.getLabel(), succeeded,
                 service);
+        if (!succeeded) {
+            upnpSubscribed = false;
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                    "Could not subscribe to service " + service + "for" + thing.getLabel());
+        }
     }
 
     @Override
     public void onStatusChanged(boolean status) {
+        logger.debug("UPnP device {} received status update {}", thing.getLabel(), status);
         if (status) {
-            updateStatus(ThingStatus.ONLINE);
+            initJob();
         } else {
             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
                     "Communication lost with " + thing.getLabel());
         }
     }
 
-    @Override
-    public @Nullable String getUDN() {
-        return config.udn;
-    }
-
     /**
      * This method wraps {@link org.openhab.core.io.transport.upnp.UpnpIOService.invokeAction}. It schedules and
      * submits the call and calls {@link onValueReceived} upon completion. All state updates or other actions depending
@@ -148,14 +423,28 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
      * @param inputs
      */
     protected void invokeAction(String serviceId, String actionId, Map<String, String> inputs) {
-        scheduler.submit(() -> {
-            Map<String, String> result = service.invokeAction(this, serviceId, actionId, inputs);
-            if (logger.isDebugEnabled() && !"GetPositionInfo".equals(actionId)) {
-                // don't log position info refresh every second
-                logger.debug("Upnp device {} invoke upnp action {} on service {} with inputs {}", thing.getLabel(),
-                        actionId, serviceId, inputs);
-                logger.debug("Upnp device {} invoke upnp action {} on service {} reply {}", thing.getLabel(), actionId,
-                        serviceId, result);
+        upnpScheduler.submit(() -> {
+            Map<String, @Nullable String> result;
+            synchronized (invokeActionLock) {
+                if (logger.isDebugEnabled() && !"GetPositionInfo".equals(actionId)) {
+                    // don't log position info refresh every second
+                    logger.debug("UPnP device {} invoke upnp action {} on service {} with inputs {}", thing.getLabel(),
+                            actionId, serviceId, inputs);
+                }
+                result = upnpIOService.invokeAction(this, serviceId, actionId, inputs);
+                if (logger.isDebugEnabled() && !"GetPositionInfo".equals(actionId)) {
+                    // don't log position info refresh every second
+                    logger.debug("UPnP device {} invoke upnp action {} on service {} reply {}", thing.getLabel(),
+                            actionId, serviceId, result);
+                }
+
+                if (!result.isEmpty()) {
+                    // We can be sure a non-empty result means the device is online.
+                    // An empty result could be expected for certain actions, but could also be hiding an exception.
+                    updateStatus(ThingStatus.ONLINE);
+                }
+
+                result = preProcessInvokeActionResult(inputs, serviceId, actionId, result);
             }
             for (String variable : result.keySet()) {
                 onValueReceived(variable, result.get(variable), serviceId);
@@ -163,31 +452,133 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
         });
     }
 
+    /**
+     * Some received values need info on inputs of action. Therefore we allow to pre-process in a separate step. The
+     * method will return an adjusted result list. The default implementation will copy over the received result without
+     * additional processing. Derived classes can add additional logic.
+     *
+     * @param inputs
+     * @param service
+     * @param result
+     * @return
+     */
+    protected Map<String, @Nullable String> preProcessInvokeActionResult(Map<String, String> inputs,
+            @Nullable String service, @Nullable String action, Map<String, @Nullable String> result) {
+        Map<String, @Nullable String> newResult = new HashMap<>();
+        for (String variable : result.keySet()) {
+            String newVariable = preProcessValueReceived(inputs, variable, result.get(variable), service, action);
+            if (newVariable != null) {
+                newResult.put(newVariable, result.get(variable));
+            }
+        }
+        return newResult;
+    }
+
+    /**
+     * Some received values need info on inputs of action. Therefore we allow to pre-process in a separate step. The
+     * default implementation will return the original value. Derived classes can implement additional logic.
+     *
+     * @param inputs
+     * @param variable
+     * @param value
+     * @param service
+     * @return
+     */
+    protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
+            @Nullable String value, @Nullable String service, @Nullable String action) {
+        return variable;
+    }
+
     @Override
     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
         if (variable == null || value == null) {
             return;
         }
         switch (variable) {
-            case "CurrentTransportState":
-                if (!value.isEmpty()) {
-                    transportState = value;
-                }
+            case CONNECTION_ID:
+                onValueReceivedConnectionId(value);
                 break;
-            case "ConnectionID":
-                connectionId = Integer.parseInt(value);
+            case AV_TRANSPORT_ID:
+                onValueReceivedAVTransportId(value);
                 break;
-            case "AVTransportID":
-                avTransportId = Integer.parseInt(value);
+            case RCS_ID:
+                onValueReceivedRcsId(value);
                 break;
-            case "RcsID":
-                rcsId = Integer.parseInt(value);
+            case "Source":
+            case "Sink":
+                if (!value.isEmpty()) {
+                    updateProtocolInfo(value);
+                }
                 break;
             default:
                 break;
         }
     }
 
+    private void onValueReceivedConnectionId(@Nullable String value) {
+        try {
+            connectionId = (value == null) ? 0 : Integer.parseInt(value);
+        } catch (NumberFormatException e) {
+            connectionId = 0;
+        }
+        CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
+        if (connectionIdFuture != null) {
+            connectionIdFuture.complete(true);
+        }
+    }
+
+    private void onValueReceivedAVTransportId(@Nullable String value) {
+        try {
+            avTransportId = (value == null) ? 0 : Integer.parseInt(value);
+        } catch (NumberFormatException e) {
+            avTransportId = 0;
+        }
+        CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
+        if (avTransportIdFuture != null) {
+            avTransportIdFuture.complete(true);
+        }
+    }
+
+    private void onValueReceivedRcsId(@Nullable String value) {
+        try {
+            rcsId = (value == null) ? 0 : Integer.parseInt(value);
+        } catch (NumberFormatException e) {
+            rcsId = 0;
+        }
+        CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
+        if (rcsIdFuture != null) {
+            rcsIdFuture.complete(true);
+        }
+    }
+
+    @Override
+    public @Nullable String getUDN() {
+        return config.udn;
+    }
+
+    protected boolean checkForConnectionIds() {
+        return checkForConnectionId(isConnectionIdSet) & checkForConnectionId(isAvTransportIdSet)
+                & checkForConnectionId(isRcsIdSet);
+    }
+
+    private boolean checkForConnectionId(@Nullable CompletableFuture<Boolean> future) {
+        try {
+            if (future != null) {
+                return future.get(config.responseTimeout, TimeUnit.MILLISECONDS);
+            }
+        } catch (InterruptedException | ExecutionException | TimeoutException e) {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Update internal representation of supported protocols, needs to be implemented in derived classes.
+     *
+     * @param value
+     */
+    protected abstract void updateProtocolInfo(String value);
+
     /**
      * Subscribe this handler as a participant to a GENA subscription.
      *
@@ -195,8 +586,10 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
      * @param duration
      */
     protected void addSubscription(String serviceId, int duration) {
-        logger.debug("Upnp device {} add upnp subscription on {}", thing.getLabel(), serviceId);
-        service.addSubscription(this, serviceId, duration);
+        if (upnpIOService.isRegistered(this)) {
+            logger.debug("UPnP device {} add upnp subscription on {}", thing.getLabel(), serviceId);
+            upnpIOService.addSubscription(this, serviceId, duration);
+        }
     }
 
     /**
@@ -205,8 +598,55 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
      * @param serviceId
      */
     protected void removeSubscription(String serviceId) {
-        if (service.isRegistered(this)) {
-            service.removeSubscription(this, serviceId);
+        if (upnpIOService.isRegistered(this)) {
+            upnpIOService.removeSubscription(this, serviceId);
         }
     }
+
+    protected void addSubscriptions() {
+        upnpSubscribed = true;
+
+        for (String subscription : serviceSubscriptions) {
+            addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
+        }
+        subscriptionRefreshJob = upnpScheduler.scheduleWithFixedDelay(subscriptionRefresh,
+                SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
+
+        // This action should exist on all media devices and return a result, so a good candidate for testing the
+        // connection.
+        upnpIOService.addStatusListener(this, CONNECTION_MANAGER, "GetCurrentConnectionIDs", config.refresh);
+    }
+
+    protected void removeSubscriptions() {
+        cancelSubscriptionRefreshJob();
+
+        for (String subscription : serviceSubscriptions) {
+            removeSubscription(subscription);
+        }
+
+        upnpIOService.removeStatusListener(this);
+
+        upnpSubscribed = false;
+    }
+
+    private void cancelSubscriptionRefreshJob() {
+        ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
+
+        if (refreshJob != null) {
+            refreshJob.cancel(true);
+        }
+        subscriptionRefreshJob = null;
+    }
+
+    @Override
+    public abstract void playlistsListChanged();
+
+    /**
+     * Get access to all device info through the UPnP {@link RemoteDevice}.
+     *
+     * @return UPnP RemoteDevice
+     */
+    protected @Nullable RemoteDevice getDevice() {
+        return device;
+    }
 }
index 3bbe561a72a57eb21613a0743b3afa57d2bf5069..d521f403f23f2fdb248787118fc0f7ac82a3e43b 100644 (file)
@@ -14,29 +14,44 @@ package org.openhab.binding.upnpcontrol.internal.handler;
 
 import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
 
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.ListIterator;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.upnpcontrol.internal.UpnpAudioSink;
-import org.openhab.binding.upnpcontrol.internal.UpnpAudioSinkReg;
-import org.openhab.binding.upnpcontrol.internal.UpnpEntry;
-import org.openhab.binding.upnpcontrol.internal.UpnpXMLParser;
+import org.jupnp.model.meta.RemoteDevice;
+import org.openhab.binding.upnpcontrol.internal.UpnpChannelName;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSink;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSinkReg;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlRendererConfiguration;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpFavorite;
+import org.openhab.binding.upnpcontrol.internal.services.UpnpRenderingControlConfiguration;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser;
 import org.openhab.core.audio.AudioFormat;
 import org.openhab.core.io.net.http.HttpUtil;
 import org.openhab.core.io.transport.upnp.UpnpIOService;
@@ -49,11 +64,13 @@ import org.openhab.core.library.types.QuantityType;
 import org.openhab.core.library.types.RewindFastforwardType;
 import org.openhab.core.library.types.StringType;
 import org.openhab.core.library.unit.SmartHomeUnits;
+import org.openhab.core.thing.Channel;
 import org.openhab.core.thing.ChannelUID;
 import org.openhab.core.thing.Thing;
 import org.openhab.core.thing.ThingStatus;
 import org.openhab.core.thing.ThingStatusDetail;
 import org.openhab.core.types.Command;
+import org.openhab.core.types.CommandOption;
 import org.openhab.core.types.RefreshType;
 import org.openhab.core.types.State;
 import org.openhab.core.types.UnDefType;
@@ -62,7 +79,8 @@ import org.slf4j.LoggerFactory;
 
 /**
  * The {@link UpnpRendererHandler} is responsible for handling commands sent to the UPnP Renderer. It extends
- * {@link UpnpHandler} with UPnP renderer specific logic.
+ * {@link UpnpHandler} with UPnP renderer specific logic. It implements UPnP AVTransport and RenderingControl service
+ * actions.
  *
  * @author Mark Herwege - Initial contribution
  * @author Karel Goderis - Based on UPnP logic in Sonos binding
@@ -72,10 +90,10 @@ public class UpnpRendererHandler extends UpnpHandler {
 
     private final Logger logger = LoggerFactory.getLogger(UpnpRendererHandler.class);
 
-    private static final int SUBSCRIPTION_DURATION_SECONDS = 3600;
-
-    // UPnP protocol pattern
-    private static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)");
+    // UPnP constants
+    static final String RENDERING_CONTROL = "RenderingControl";
+    static final String AV_TRANSPORT = "AVTransport";
+    static final String INSTANCE_ID = "InstanceID";
 
     private volatile boolean audioSupport;
     protected volatile Set<AudioFormat> supportedAudioFormats = new HashSet<>();
@@ -83,101 +101,76 @@ public class UpnpRendererHandler extends UpnpHandler {
 
     private volatile UpnpAudioSinkReg audioSinkReg;
 
-    private volatile boolean upnpSubscribed;
+    private volatile Set<UpnpServerHandler> serverHandlers = ConcurrentHashMap.newKeySet();
+
+    protected @NonNullByDefault({}) UpnpControlRendererConfiguration config;
+    private UpnpRenderingControlConfiguration renderingControlConfiguration = new UpnpRenderingControlConfiguration();
+
+    private volatile List<CommandOption> favoriteCommandOptionList = List.of();
+    private volatile List<CommandOption> playlistCommandOptionList = List.of();
 
-    private static final String UPNP_CHANNEL = "Master";
+    private @NonNullByDefault({}) ChannelUID favoriteSelectChannelUID;
+    private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID;
 
-    private volatile OnOffType soundMute = OnOffType.OFF;
     private volatile PercentType soundVolume = new PercentType();
+    private @Nullable volatile PercentType notificationVolume;
     private volatile List<String> sink = new ArrayList<>();
 
-    private volatile ArrayList<UpnpEntry> currentQueue = new ArrayList<>();
-    private volatile UpnpIterator<UpnpEntry> queueIterator = new UpnpIterator<>(currentQueue.listIterator());
-    private volatile @Nullable UpnpEntry currentEntry = null;
-    private volatile @Nullable UpnpEntry nextEntry = null;
-    private volatile boolean playerStopped;
-    private volatile boolean playing;
-    private volatile @Nullable CompletableFuture<Boolean> isSettingURI;
+    private volatile String favoriteName = ""; // Currently selected favorite
+
+    private volatile boolean repeat;
+    private volatile boolean shuffle;
+    private volatile boolean onlyplayone; // Set to true if we only want to play one at a time
+
+    // Queue as received from server and current and next media entries for playback
+    private volatile UpnpEntryQueue currentQueue = new UpnpEntryQueue();
+    volatile @Nullable UpnpEntry currentEntry = null;
+    volatile @Nullable UpnpEntry nextEntry = null;
+
+    // Group of fields representing current state of player
+    private volatile String nowPlayingUri = ""; // Used to block waiting for setting URI when it is the same as current
+                                                // as some players will not send URI update when it is the same as
+                                                // previous
+    private volatile String transportState = ""; // Current transportState to be able to refresh the control
+    volatile boolean playerStopped; // Set if the player is stopped from OH command or code, allows to identify
+                                    // if STOP came from other source when receiving STOP state from GENA event
+    volatile boolean playing; // Set to false when a STOP is received, so we can filter two consecutive STOPs
+                              // and not play next entry second time
+    private volatile @Nullable ScheduledFuture<?> paused; // Set when a pause command is given, to compensate for
+                                                          // renderers that cannot pause playback
+    private volatile @Nullable CompletableFuture<Boolean> isSettingURI; // Set to wait for setting URI before starting
+                                                                        // to play or seeking
+    private volatile @Nullable CompletableFuture<Boolean> isStopping; // Set when stopping to be able to wait for stop
+                                                                      // confirmation for subsequent actions that need
+                                                                      // the player to be stopped
+    volatile boolean registeredQueue; // Set when registering a new queue. This allows to decide if we just
+                                      // need to play URI, or serve the first entry in a queue when a play
+                                      // command is given.
+    volatile boolean playingQueue; // Identifies if we are playing a queue received from a server. If so, a new
+                                   // queue received will be played after the currently playing entry
+    private volatile boolean oneplayed; // Set to true when the one entry is being played, allows to check if stop is
+                                        // needed when only playing one
+    volatile boolean playingNotification; // Set when playing a notification
+    private volatile @Nullable ScheduledFuture<?> playingNotificationFuture; // Set when playing a notification, allows
+                                                                             // timing out notification
+    private volatile String notificationUri = ""; // Used to check if the received URI is from the notification
+    private final Object notificationLock = new Object();
+
+    // Track position and duration fields
     private volatile int trackDuration = 0;
     private volatile int trackPosition = 0;
+    private volatile long expectedTrackend = 0;
     private volatile @Nullable ScheduledFuture<?> trackPositionRefresh;
+    private volatile int posAtNotificationStart = 0;
 
-    private volatile @Nullable ScheduledFuture<?> subscriptionRefreshJob;
-    private final Runnable subscriptionRefresh = () -> {
-        removeSubscription("AVTransport");
-        addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS);
-    };
-
-    /**
-     * The {@link ListIterator} class does not keep a cursor position and therefore will not give the previous element
-     * when next was called before, or give the next element when previous was called before. This iterator will always
-     * go to previous/next.
-     */
-    private static class UpnpIterator<T> {
-        private final ListIterator<T> listIterator;
-
-        private boolean nextWasCalled = false;
-        private boolean previousWasCalled = false;
-
-        public UpnpIterator(ListIterator<T> listIterator) {
-            this.listIterator = listIterator;
-        }
-
-        public T next() {
-            if (previousWasCalled) {
-                previousWasCalled = false;
-                listIterator.next();
-            }
-            nextWasCalled = true;
-            return listIterator.next();
-        }
-
-        public T previous() {
-            if (nextWasCalled) {
-                nextWasCalled = false;
-                listIterator.previous();
-            }
-            previousWasCalled = true;
-            return listIterator.previous();
-        }
-
-        public boolean hasNext() {
-            if (previousWasCalled) {
-                return true;
-            } else {
-                return listIterator.hasNext();
-            }
-        }
-
-        public boolean hasPrevious() {
-            if (previousIndex() < 0) {
-                return false;
-            } else if (nextWasCalled) {
-                return true;
-            } else {
-                return listIterator.hasPrevious();
-            }
-        }
+    public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg,
+            UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
+            UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider,
+            UpnpControlBindingConfiguration configuration) {
+        super(thing, upnpIOService, configuration, upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
 
-        public int nextIndex() {
-            if (previousWasCalled) {
-                return listIterator.nextIndex() + 1;
-            } else {
-                return listIterator.nextIndex();
-            }
-        }
-
-        public int previousIndex() {
-            if (nextWasCalled) {
-                return listIterator.previousIndex() - 1;
-            } else {
-                return listIterator.previousIndex();
-            }
-        }
-    }
-
-    public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg) {
-        super(thing, upnpIOService);
+        serviceSubscriptions.add(AV_TRANSPORT);
+        serviceSubscriptions.add(RENDERING_CONTROL);
 
         this.audioSinkReg = audioSinkReg;
     }
@@ -185,112 +178,229 @@ public class UpnpRendererHandler extends UpnpHandler {
     @Override
     public void initialize() {
         super.initialize();
-
+        config = getConfigAs(UpnpControlRendererConfiguration.class);
+        if (config.seekStep < 1) {
+            config.seekStep = 1;
+        }
         logger.debug("Initializing handler for media renderer device {}", thing.getLabel());
 
-        if (config.udn != null) {
-            if (service.isRegistered(this)) {
-                initRenderer();
-            } else {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
-                        "Communication cannot be established with " + thing.getLabel());
-            }
+        Channel favoriteSelectChannel = thing.getChannel(FAVORITE_SELECT);
+        if (favoriteSelectChannel != null) {
+            favoriteSelectChannelUID = favoriteSelectChannel.getUID();
+        } else {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "Channel " + FAVORITE_SELECT + " not defined");
+            return;
+        }
+        Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT);
+        if (playlistSelectChannel != null) {
+            playlistSelectChannelUID = playlistSelectChannel.getUID();
         } else {
             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
-                    "No UDN configured for " + thing.getLabel());
+                    "Channel " + PLAYLIST_SELECT + " not defined");
+            return;
         }
+
+        initDevice();
     }
 
     @Override
     public void dispose() {
-        cancelSubscriptionRefreshJob();
-        removeSubscription("AVTransport");
+        logger.debug("Disposing handler for media renderer device {}", thing.getLabel());
 
         cancelTrackPositionRefresh();
+        resetPaused();
+        CompletableFuture<Boolean> settingURI = isSettingURI;
+        if (settingURI != null) {
+            settingURI.complete(false);
+        }
 
         super.dispose();
     }
 
-    private void cancelSubscriptionRefreshJob() {
-        ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
+    @Override
+    protected void initJob() {
+        synchronized (jobLock) {
+            if (!upnpIOService.isRegistered(this)) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                        "UPnP device with UDN " + getUDN() + " not yet registered");
+                return;
+            }
 
-        if (refreshJob != null) {
-            refreshJob.cancel(true);
-        }
-        subscriptionRefreshJob = null;
+            if (!ThingStatus.ONLINE.equals(thing.getStatus())) {
+                getProtocolInfo();
+
+                getCurrentConnectionInfo();
+                if (!checkForConnectionIds()) {
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                            "No connection Id's set for UPnP device with UDN " + getUDN());
+                    return;
+                }
+
+                getTransportState();
+
+                updateFavoritesList();
+                playlistsListChanged();
+
+                RemoteDevice device = getDevice();
+                if (device != null) { // The handler factory will update the device config later when it has not been
+                                      // set yet
+                    updateDeviceConfig(device);
+                }
+
+                updateStatus(ThingStatus.ONLINE);
+            }
 
-        upnpSubscribed = false;
+            if (!upnpSubscribed) {
+                addSubscriptions();
+            }
+        }
     }
 
-    private void initRenderer() {
-        if (!upnpSubscribed) {
-            addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS);
-            upnpSubscribed = true;
+    @Override
+    public void updateDeviceConfig(RemoteDevice device) {
+        super.updateDeviceConfig(device);
 
-            subscriptionRefreshJob = scheduler.scheduleWithFixedDelay(subscriptionRefresh,
-                    SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
+        UpnpRenderingControlConfiguration config = new UpnpRenderingControlConfiguration(device);
+        renderingControlConfiguration = config;
+        for (String audioChannel : config.audioChannels) {
+            createAudioChannels(audioChannel);
         }
-        getProtocolInfo();
-        getTransportState();
 
-        updateStatus(ThingStatus.ONLINE);
+        updateChannels();
+    }
+
+    private void createAudioChannels(String audioChannel) {
+        UpnpRenderingControlConfiguration config = renderingControlConfiguration;
+        if (config.volume && !UPNP_MASTER.equals(audioChannel)) {
+            String name = audioChannel + "volume";
+            if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
+                createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
+            } else {
+                createChannel(name, name, "Vendor specific UPnP volume channel", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME);
+            }
+        }
+        if (config.mute && !UPNP_MASTER.equals(audioChannel)) {
+            String name = audioChannel + "mute";
+            if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
+                createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
+            } else {
+                createChannel(name, name, "Vendor specific  UPnP mute channel", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE);
+            }
+        }
+        if (config.loudness) {
+            String name = (UPNP_MASTER.equals(audioChannel) ? "" : audioChannel) + "loudness";
+            if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
+                createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
+            } else {
+                createChannel(name, name, "Vendor specific  UPnP loudness channel", ITEM_TYPE_LOUDNESS,
+                        CHANNEL_TYPE_LOUDNESS);
+            }
+        }
     }
 
     /**
      * Invoke Stop on UPnP AV Transport.
      */
     public void stop() {
-        Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
+        playerStopped = true;
+
+        if (playing) {
+            CompletableFuture<Boolean> stopping = isStopping;
+            if (stopping != null) {
+                stopping.complete(false);
+            }
+            isStopping = new CompletableFuture<Boolean>(); // set this so we can check if stop confirmation has been
+                                                           // received
+        }
+
+        Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
 
-        invokeAction("AVTransport", "Stop", inputs);
+        invokeAction(AV_TRANSPORT, "Stop", inputs);
     }
 
     /**
      * Invoke Play on UPnP AV Transport.
      */
     public void play() {
-        CompletableFuture<Boolean> setting = isSettingURI;
+        CompletableFuture<Boolean> settingURI = isSettingURI;
+        boolean uriSet = true;
         try {
-            if ((setting == null) || (setting.get(2500, TimeUnit.MILLISECONDS))) {
+            if (settingURI != null) {
                 // wait for maximum 2.5s until the media URI is set before playing
-                Map<String, String> inputs = new HashMap<>();
-                inputs.put("InstanceID", Integer.toString(avTransportId));
-                inputs.put("Speed", "1");
-
-                invokeAction("AVTransport", "Play", inputs);
-            } else {
-                logger.debug("Cannot play, cancelled setting URI in the renderer");
+                uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS);
             }
         } catch (InterruptedException | ExecutionException | TimeoutException e) {
-            logger.debug("Cannot play, media URI not yet set in the renderer");
+            logger.debug("Timeout exception, media URI not yet set in renderer {}, trying to play anyway",
+                    thing.getLabel());
+        }
+
+        if (uriSet) {
+            Map<String, String> inputs = new HashMap<>();
+            inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
+            inputs.put("Speed", "1");
+
+            invokeAction(AV_TRANSPORT, "Play", inputs);
+        } else {
+            logger.debug("Cannot play, cancelled setting URI in the renderer {}", thing.getLabel());
         }
     }
 
     /**
      * Invoke Pause on UPnP AV Transport.
      */
-    public void pause() {
-        Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
+    protected void pause() {
+        Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
 
-        invokeAction("AVTransport", "Pause", inputs);
+        invokeAction(AV_TRANSPORT, "Pause", inputs);
     }
 
     /**
      * Invoke Next on UPnP AV Transport.
      */
     protected void next() {
-        Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
+        Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
 
-        invokeAction("AVTransport", "Next", inputs);
+        invokeAction(AV_TRANSPORT, "Next", inputs);
     }
 
     /**
      * Invoke Previous on UPnP AV Transport.
      */
     protected void previous() {
-        Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
+        Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
+
+        invokeAction(AV_TRANSPORT, "Previous", inputs);
+    }
 
-        invokeAction("AVTransport", "Previous", inputs);
+    /**
+     * Invoke Seek on UPnP AV Transport.
+     *
+     * @param seekTarget relative position in current track, format HH:mm:ss
+     */
+    protected void seek(String seekTarget) {
+        CompletableFuture<Boolean> settingURI = isSettingURI;
+        boolean uriSet = true;
+        try {
+            if (settingURI != null) {
+                // wait for maximum 2.5s until the media URI is set before seeking
+                uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS);
+            }
+        } catch (InterruptedException | ExecutionException | TimeoutException e) {
+            logger.debug("Timeout exception, media URI not yet set in renderer {}, skipping seek", thing.getLabel());
+            return;
+        }
+
+        if (uriSet) {
+            Map<String, String> inputs = new HashMap<>();
+            inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
+            inputs.put("Unit", "REL_TIME");
+            inputs.put("Target", seekTarget);
+
+            invokeAction(AV_TRANSPORT, "Seek", inputs);
+        } else {
+            logger.debug("Cannot seek, cancelled setting URI in the renderer {}", thing.getLabel());
+        }
     }
 
     /**
@@ -300,22 +410,31 @@ public class UpnpRendererHandler extends UpnpHandler {
      * @param URIMetaData
      */
     public void setCurrentURI(String URI, String URIMetaData) {
-        CompletableFuture<Boolean> setting = isSettingURI;
-        if (setting != null) {
-            setting.complete(false);
+        String uri = "";
+        try {
+            uri = URLDecoder.decode(URI.trim(), StandardCharsets.UTF_8.name());
+            // Some renderers don't send a URI Last Changed event when the same URI is requested, so don't wait for it
+            // before starting to play
+            if (!uri.equals(nowPlayingUri) && !playingNotification) {
+                CompletableFuture<Boolean> settingURI = isSettingURI;
+                if (settingURI != null) {
+                    settingURI.complete(false);
+                }
+                isSettingURI = new CompletableFuture<Boolean>(); // set this so we don't start playing when not finished
+                                                                 // setting URI
+            } else {
+                logger.debug("New URI {} is same as previous on renderer {}", nowPlayingUri, thing.getLabel());
+            }
+        } catch (UnsupportedEncodingException ignore) {
+            uri = URI;
         }
-        isSettingURI = new CompletableFuture<Boolean>(); // set this so we don't start playing when not finished setting
-                                                         // URI
+
         Map<String, String> inputs = new HashMap<>();
-        try {
-            inputs.put("InstanceID", Integer.toString(avTransportId));
-            inputs.put("CurrentURI", URI);
-            inputs.put("CurrentURIMetaData", URIMetaData);
+        inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
+        inputs.put("CurrentURI", uri);
+        inputs.put("CurrentURIMetaData", URIMetaData);
 
-            invokeAction("AVTransport", "SetAVTransportURI", inputs);
-        } catch (NumberFormatException ex) {
-            logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
-        }
+        invokeAction(AV_TRANSPORT, "SetAVTransportURI", inputs);
     }
 
     /**
@@ -324,26 +443,43 @@ public class UpnpRendererHandler extends UpnpHandler {
      * @param nextURI
      * @param nextURIMetaData
      */
-    public void setNextURI(String nextURI, String nextURIMetaData) {
+    protected void setNextURI(String nextURI, String nextURIMetaData) {
         Map<String, String> inputs = new HashMap<>();
-        try {
-            inputs.put("InstanceID", Integer.toString(avTransportId));
-            inputs.put("NextURI", nextURI);
-            inputs.put("NextURIMetaData", nextURIMetaData);
+        inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
+        inputs.put("NextURI", nextURI);
+        inputs.put("NextURIMetaData", nextURIMetaData);
 
-            invokeAction("AVTransport", "SetNextAVTransportURI", inputs);
-        } catch (NumberFormatException ex) {
-            logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
-        }
+        invokeAction(AV_TRANSPORT, "SetNextAVTransportURI", inputs);
     }
 
     /**
-     * Retrieves the current audio channel ('Master' by default).
-     *
-     * @return current audio channel
+     * Invoke GetTransportState on UPnP AV Transport.
+     * Result is received in {@link onValueReceived}.
      */
-    public String getCurrentChannel() {
-        return UPNP_CHANNEL;
+    protected void getTransportState() {
+        Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
+
+        invokeAction(AV_TRANSPORT, "GetTransportInfo", inputs);
+    }
+
+    /**
+     * Invoke getPositionInfo on UPnP AV Transport.
+     * Result is received in {@link onValueReceived}.
+     */
+    protected void getPositionInfo() {
+        Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
+
+        invokeAction(AV_TRANSPORT, "GetPositionInfo", inputs);
+    }
+
+    /**
+     * Invoke GetMediaInfo on UPnP AV Transport.
+     * Result is received in {@link onValueReceived}.
+     */
+    protected void getMediaInfo() {
+        Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
+
+        invokeAction(AV_TRANSPORT, "smarthome:audio stream http://icecast.vrtcdn.be/stubru_tijdloze-high.mp3", inputs);
     }
 
     /**
@@ -364,10 +500,10 @@ public class UpnpRendererHandler extends UpnpHandler {
      */
     protected void getVolume(String channel) {
         Map<String, String> inputs = new HashMap<>();
-        inputs.put("InstanceID", Integer.toString(rcsId));
+        inputs.put(INSTANCE_ID, Integer.toString(rcsId));
         inputs.put("Channel", channel);
 
-        invokeAction("RenderingControl", "GetVolume", inputs);
+        invokeAction(RENDERING_CONTROL, "GetVolume", inputs);
     }
 
     /**
@@ -376,13 +512,25 @@ public class UpnpRendererHandler extends UpnpHandler {
      * @param channel
      * @param volume
      */
-    public void setVolume(String channel, PercentType volume) {
+    protected void setVolume(String channel, PercentType volume) {
+        UpnpRenderingControlConfiguration config = renderingControlConfiguration;
+
+        long newVolume = volume.intValue() * config.maxvolume / 100;
         Map<String, String> inputs = new HashMap<>();
-        inputs.put("InstanceID", Integer.toString(rcsId));
+        inputs.put(INSTANCE_ID, Integer.toString(rcsId));
         inputs.put("Channel", channel);
-        inputs.put("DesiredVolume", String.valueOf(volume.intValue()));
+        inputs.put("DesiredVolume", String.valueOf(newVolume));
 
-        invokeAction("RenderingControl", "SetVolume", inputs);
+        invokeAction(RENDERING_CONTROL, "SetVolume", inputs);
+    }
+
+    /**
+     * Invoke SetVolume for Master channel on UPnP Rendering Control.
+     *
+     * @param volume
+     */
+    public void setVolume(PercentType volume) {
+        setVolume(UPNP_MASTER, volume);
     }
 
     /**
@@ -393,10 +541,10 @@ public class UpnpRendererHandler extends UpnpHandler {
      */
     protected void getMute(String channel) {
         Map<String, String> inputs = new HashMap<>();
-        inputs.put("InstanceID", Integer.toString(rcsId));
+        inputs.put(INSTANCE_ID, Integer.toString(rcsId));
         inputs.put("Channel", channel);
 
-        invokeAction("RenderingControl", "GetMute", inputs);
+        invokeAction(RENDERING_CONTROL, "GetMute", inputs);
     }
 
     /**
@@ -407,119 +555,508 @@ public class UpnpRendererHandler extends UpnpHandler {
      */
     protected void setMute(String channel, OnOffType mute) {
         Map<String, String> inputs = new HashMap<>();
-        inputs.put("InstanceID", Integer.toString(rcsId));
+        inputs.put(INSTANCE_ID, Integer.toString(rcsId));
         inputs.put("Channel", channel);
         inputs.put("DesiredMute", mute == OnOffType.ON ? "1" : "0");
 
-        invokeAction("RenderingControl", "SetMute", inputs);
+        invokeAction(RENDERING_CONTROL, "SetMute", inputs);
+    }
+
+    /**
+     * Invoke getMute on UPnP Rendering Control.
+     * Result is received in {@link onValueReceived}.
+     *
+     * @param channel
+     */
+    protected void getLoudness(String channel) {
+        Map<String, String> inputs = new HashMap<>();
+        inputs.put(INSTANCE_ID, Integer.toString(rcsId));
+        inputs.put("Channel", channel);
+
+        invokeAction(RENDERING_CONTROL, "GetLoudness", inputs);
+    }
+
+    /**
+     * Invoke SetMute on UPnP Rendering Control.
+     *
+     * @param channel
+     * @param mute
+     */
+    protected void setLoudness(String channel, OnOffType mute) {
+        Map<String, String> inputs = new HashMap<>();
+        inputs.put(INSTANCE_ID, Integer.toString(rcsId));
+        inputs.put("Channel", channel);
+        inputs.put("DesiredLoudness", mute == OnOffType.ON ? "1" : "0");
+
+        invokeAction(RENDERING_CONTROL, "SetLoudness", inputs);
+    }
+
+    /**
+     * Called from server handler for renderer to be able to send back status to server handler
+     *
+     * @param handler
+     */
+    protected void setServerHandler(UpnpServerHandler handler) {
+        logger.debug("Set server handler {} on renderer {}", handler.getThing().getLabel(), thing.getLabel());
+        serverHandlers.add(handler);
+    }
+
+    /**
+     * Should be called from server handler when server stops serving this renderer
+     */
+    protected void unsetServerHandler() {
+        logger.debug("Unset server handler on renderer {}", thing.getLabel());
+        for (UpnpServerHandler handler : serverHandlers) {
+            Thing serverThing = handler.getThing();
+            Channel serverChannel;
+            for (String channel : SERVER_CONTROL_CHANNELS) {
+                if ((serverChannel = serverThing.getChannel(channel)) != null) {
+                    handler.updateServerState(serverChannel.getUID(), UnDefType.UNDEF);
+                }
+            }
+
+            serverHandlers.remove(handler);
+        }
+    }
+
+    @Override
+    protected void updateState(ChannelUID channelUID, State state) {
+        // override to be able to propagate channel state updates to corresponding channels on the server
+        if (SERVER_CONTROL_CHANNELS.contains(channelUID.getId())) {
+            for (UpnpServerHandler handler : serverHandlers) {
+                Thing serverThing = handler.getThing();
+                Channel serverChannel = serverThing.getChannel(channelUID.getId());
+                if (serverChannel != null) {
+                    logger.debug("Update server {} channel {} with state {} from renderer {}", serverThing.getLabel(),
+                            state, channelUID, thing.getLabel());
+                    handler.updateServerState(serverChannel.getUID(), state);
+                }
+            }
+        }
+        super.updateState(channelUID, state);
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        logger.debug("Handle command {} for channel {} on renderer {}", command, channelUID, thing.getLabel());
+
+        String id = channelUID.getId();
+
+        if (id.endsWith("volume")) {
+            handleCommandVolume(command, id);
+        } else if (id.endsWith("mute")) {
+            handleCommandMute(command, id);
+        } else if (id.endsWith("loudness")) {
+            handleCommandLoudness(command, id);
+        } else {
+            switch (id) {
+                case STOP:
+                    handleCommandStop(command);
+                    break;
+                case CONTROL:
+                    handleCommandControl(channelUID, command);
+                    break;
+                case REPEAT:
+                    handleCommandRepeat(channelUID, command);
+                    break;
+                case SHUFFLE:
+                    handleCommandShuffle(channelUID, command);
+                case ONLY_PLAY_ONE:
+                    handleCommandOnlyPlayOne(channelUID, command);
+                    break;
+                case URI:
+                    handleCommandUri(channelUID, command);
+                    break;
+                case FAVORITE_SELECT:
+                    handleCommandFavoriteSelect(command);
+                    break;
+                case FAVORITE:
+                    handleCommandFavorite(channelUID, command);
+                    break;
+                case FAVORITE_ACTION:
+                    handleCommandFavoriteAction(command);
+                    break;
+                case PLAYLIST_SELECT:
+                    handleCommandPlaylistSelect(command);
+                    break;
+                case TRACK_POSITION:
+                    handleCommandTrackPosition(channelUID, command);
+                    break;
+                case REL_TRACK_POSITION:
+                    handleCommandRelTrackPosition(channelUID, command);
+                    break;
+                default:
+                    break;
+            }
+        }
+    }
+
+    private void handleCommandVolume(Command command, String id) {
+        if (command instanceof RefreshType) {
+            getVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", ""));
+        } else if (command instanceof PercentType) {
+            setVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", ""), (PercentType) command);
+        }
+    }
+
+    private void handleCommandMute(Command command, String id) {
+        if (command instanceof RefreshType) {
+            getMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", ""));
+        } else if (command instanceof OnOffType) {
+            setMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", ""), (OnOffType) command);
+        }
+    }
+
+    private void handleCommandLoudness(Command command, String id) {
+        if (command instanceof RefreshType) {
+            getLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", ""));
+        } else if (command instanceof OnOffType) {
+            setLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", ""), (OnOffType) command);
+        }
+    }
+
+    private void handleCommandStop(Command command) {
+        if (OnOffType.ON.equals(command)) {
+            updateState(CONTROL, PlayPauseType.PAUSE);
+            stop();
+            updateState(TRACK_POSITION, new QuantityType<>(0, SmartHomeUnits.SECOND));
+        }
+    }
+
+    private void handleCommandControl(ChannelUID channelUID, Command command) {
+        String state;
+        if (command instanceof RefreshType) {
+            state = transportState;
+            State newState = UnDefType.UNDEF;
+            if ("PLAYING".equals(state)) {
+                newState = PlayPauseType.PLAY;
+            } else if ("STOPPED".equals(state)) {
+                newState = PlayPauseType.PAUSE;
+            } else if ("PAUSED_PLAYBACK".equals(state)) {
+                newState = PlayPauseType.PAUSE;
+            }
+            updateState(channelUID, newState);
+        } else if (command instanceof PlayPauseType) {
+            if (PlayPauseType.PLAY.equals(command)) {
+                if (registeredQueue) {
+                    registeredQueue = false;
+                    playingQueue = true;
+                    oneplayed = false;
+                    serve();
+                } else {
+                    play();
+                }
+            } else if (PlayPauseType.PAUSE.equals(command)) {
+                checkPaused();
+                pause();
+            }
+        } else if (command instanceof NextPreviousType) {
+            if (NextPreviousType.NEXT.equals(command)) {
+                serveNext();
+            } else if (NextPreviousType.PREVIOUS.equals(command)) {
+                servePrevious();
+            }
+        } else if (command instanceof RewindFastforwardType) {
+            int pos = 0;
+            if (RewindFastforwardType.FASTFORWARD.equals(command)) {
+                pos = Integer.min(trackDuration, trackPosition + config.seekStep);
+            } else if (command == RewindFastforwardType.REWIND) {
+                pos = Integer.max(0, trackPosition - config.seekStep);
+            }
+            seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
+        }
+    }
+
+    private void handleCommandRepeat(ChannelUID channelUID, Command command) {
+        if (command instanceof RefreshType) {
+            updateState(channelUID, OnOffType.from(repeat));
+        } else {
+            repeat = (OnOffType.ON.equals(command));
+            currentQueue.setRepeat(repeat);
+            updateState(channelUID, OnOffType.from(repeat));
+            logger.debug("Repeat set to {} for {}", repeat, thing.getLabel());
+        }
+    }
+
+    private void handleCommandShuffle(ChannelUID channelUID, Command command) {
+        if (command instanceof RefreshType) {
+            updateState(channelUID, OnOffType.from(shuffle));
+        } else {
+            shuffle = (OnOffType.ON.equals(command));
+            currentQueue.setShuffle(shuffle);
+            if (!playing) {
+                resetToStartQueue();
+            }
+            updateState(channelUID, OnOffType.from(shuffle));
+            logger.debug("Shuffle set to {} for {}", shuffle, thing.getLabel());
+        }
+    }
+
+    private void handleCommandOnlyPlayOne(ChannelUID channelUID, Command command) {
+        if (command instanceof RefreshType) {
+            updateState(channelUID, OnOffType.from(onlyplayone));
+        } else {
+            onlyplayone = (OnOffType.ON.equals(command));
+            oneplayed = (onlyplayone && playing) ? true : false;
+            if (oneplayed) {
+                setNextURI("", "");
+            } else {
+                UpnpEntry next = nextEntry;
+                if (next != null) {
+                    setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
+                }
+            }
+            updateState(channelUID, OnOffType.from(onlyplayone));
+            logger.debug("OnlyPlayOne set to {} for {}", onlyplayone, thing.getLabel());
+        }
+    }
+
+    private void handleCommandUri(ChannelUID channelUID, Command command) {
+        if (command instanceof RefreshType) {
+            updateState(channelUID, StringType.valueOf(nowPlayingUri));
+        } else if (command instanceof StringType) {
+            setCurrentURI(command.toString(), "");
+            play();
+        }
+    }
+
+    private void handleCommandFavoriteSelect(Command command) {
+        if (command instanceof StringType) {
+            favoriteName = command.toString();
+            updateState(FAVORITE, StringType.valueOf(favoriteName));
+            playFavorite();
+        }
+    }
+
+    private void handleCommandFavorite(ChannelUID channelUID, Command command) {
+        if (command instanceof StringType) {
+            favoriteName = command.toString();
+            if (favoriteCommandOptionList.contains(new CommandOption(favoriteName, favoriteName))) {
+                playFavorite();
+            }
+        }
+        updateState(channelUID, StringType.valueOf(favoriteName));
+    }
+
+    private void handleCommandFavoriteAction(Command command) {
+        if (command instanceof StringType) {
+            switch (command.toString()) {
+                case SAVE:
+                    handleCommandFavoriteSave();
+                    break;
+                case DELETE:
+                    handleCommandFavoriteDelete();
+                    break;
+            }
+        }
+    }
+
+    private void handleCommandFavoriteSave() {
+        if (!favoriteName.isEmpty()) {
+            UpnpFavorite favorite = new UpnpFavorite(favoriteName, nowPlayingUri, currentEntry);
+            favorite.saveFavorite(favoriteName, bindingConfig.path);
+            updateFavoritesList();
+        }
+    }
+
+    private void handleCommandFavoriteDelete() {
+        if (!favoriteName.isEmpty()) {
+            UpnpControlUtil.deleteFavorite(favoriteName, bindingConfig.path);
+            updateFavoritesList();
+            updateState(FAVORITE, UnDefType.UNDEF);
+        }
+    }
+
+    private void handleCommandPlaylistSelect(Command command) {
+        if (command instanceof StringType) {
+            String playlistName = command.toString();
+            UpnpEntryQueue queue = new UpnpEntryQueue();
+            queue.restoreQueue(playlistName, null, bindingConfig.path);
+            registerQueue(queue);
+            resetToStartQueue();
+            playingQueue = true;
+            serve();
+        }
+    }
+
+    private void handleCommandTrackPosition(ChannelUID channelUID, Command command) {
+        if (command instanceof RefreshType) {
+            updateState(channelUID, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
+        } else if (command instanceof QuantityType<?>) {
+            QuantityType<?> position = ((QuantityType<?>) command).toUnit(SmartHomeUnits.SECOND);
+            if (position != null) {
+                int pos = Integer.min(trackDuration, position.intValue());
+                seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
+            }
+        }
+    }
+
+    private void handleCommandRelTrackPosition(ChannelUID channelUID, Command command) {
+        if (command instanceof RefreshType) {
+            int relPosition = (trackDuration != 0) ? (trackPosition * 100) / trackDuration : 0;
+            updateState(channelUID, new PercentType(relPosition));
+        } else if (command instanceof PercentType) {
+            int pos = ((PercentType) command).intValue() * trackDuration / 100;
+            seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
+        }
+    }
+
+    /**
+     * Set the volume for notifications.
+     *
+     * @param volume
+     */
+    public void setNotificationVolume(PercentType volume) {
+        notificationVolume = volume;
+    }
+
+    /**
+     * Play a notification. Previous state of the renderer will resume at the end of the notification, or after the
+     * maximum notification duration as defined in the renderer parameters.
+     *
+     * @param URI for notification sound
+     */
+    public void playNotification(String URI) {
+        synchronized (notificationLock) {
+            if (URI.isEmpty()) {
+                logger.debug("UPnP device {} received empty notification URI", thing.getLabel());
+                return;
+            }
+
+            notificationUri = URI;
+
+            logger.debug("UPnP device {} playing notification {}", thing.getLabel(), URI);
+
+            cancelTrackPositionRefresh();
+            getPositionInfo();
+
+            cancelPlayingNotificationFuture();
+
+            if (config.maxNotificationDuration > 0) {
+                playingNotificationFuture = upnpScheduler.schedule(this::stop, config.maxNotificationDuration,
+                        TimeUnit.SECONDS);
+            }
+            playingNotification = true;
+
+            setCurrentURI(URI, "");
+            setNextURI("", "");
+            PercentType volume = notificationVolume;
+            setVolume(volume == null
+                    ? new PercentType(Math.min(100,
+                            Math.max(0, (100 + config.notificationVolumeAdjustment) * soundVolume.intValue() / 100)))
+                    : volume);
+
+            CompletableFuture<Boolean> stopping = isStopping;
+            try {
+                if (stopping != null) {
+                    // wait for maximum 2.5s until the renderer stopped before playing
+                    stopping.get(config.responseTimeout, TimeUnit.MILLISECONDS);
+                }
+            } catch (InterruptedException | ExecutionException | TimeoutException e) {
+                logger.debug("Timeout exception, renderer {} didn't stop yet, trying to play anyway", thing.getLabel());
+            }
+            play();
+        }
+    }
+
+    private void cancelPlayingNotificationFuture() {
+        ScheduledFuture<?> future = playingNotificationFuture;
+        if (future != null) {
+            future.cancel(true);
+            playingNotificationFuture = null;
+        }
     }
 
-    /**
-     * Invoke getPositionInfo on UPnP Rendering Control.
-     * Result is received in {@link onValueReceived}.
-     */
-    protected void getPositionInfo() {
-        Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(rcsId));
+    private void resumeAfterNotification() {
+        synchronized (notificationLock) {
+            logger.debug("UPnP device {} resume after playing notification", thing.getLabel());
 
-        invokeAction("AVTransport", "GetPositionInfo", inputs);
-    }
+            setCurrentURI(nowPlayingUri, "");
+            setVolume(soundVolume);
 
-    @Override
-    public void handleCommand(ChannelUID channelUID, Command command) {
-        logger.debug("Handle command {} for channel {} on renderer {}", command, channelUID, thing.getLabel());
+            cancelPlayingNotificationFuture();
 
-        String transportState;
-        if (command instanceof RefreshType) {
-            switch (channelUID.getId()) {
-                case VOLUME:
-                    getVolume(getCurrentChannel());
-                    break;
-                case MUTE:
-                    getMute(getCurrentChannel());
-                    break;
-                case CONTROL:
-                    transportState = this.transportState;
-                    State newState = UnDefType.UNDEF;
-                    if ("PLAYING".equals(transportState)) {
-                        newState = PlayPauseType.PLAY;
-                    } else if ("STOPPED".equals(transportState)) {
-                        newState = PlayPauseType.PAUSE;
-                    } else if ("PAUSED_PLAYBACK".equals(transportState)) {
-                        newState = PlayPauseType.PAUSE;
-                    }
-                    updateState(channelUID, newState);
-                    break;
-            }
-            return;
-        } else {
-            switch (channelUID.getId()) {
-                case VOLUME:
-                    setVolume(getCurrentChannel(), (PercentType) command);
-                    break;
-                case MUTE:
-                    setMute(getCurrentChannel(), (OnOffType) command);
-                    break;
-                case STOP:
-                    if (command == OnOffType.ON) {
-                        updateState(CONTROL, PlayPauseType.PAUSE);
-                        playerStopped = true;
-                        stop();
-                        updateState(TRACK_POSITION, new QuantityType<>(0, SmartHomeUnits.SECOND));
-                    }
-                    break;
-                case CONTROL:
-                    playerStopped = false;
-                    if (command instanceof PlayPauseType) {
-                        if (command == PlayPauseType.PLAY) {
-                            play();
-                        } else if (command == PlayPauseType.PAUSE) {
-                            pause();
-                        }
-                    } else if (command instanceof NextPreviousType) {
-                        if (command == NextPreviousType.NEXT) {
-                            playerStopped = true;
-                            serveNext();
-                        } else if (command == NextPreviousType.PREVIOUS) {
-                            playerStopped = true;
-                            servePrevious();
-                        }
-                    } else if (command instanceof RewindFastforwardType) {
-                    }
-                    break;
+            playingNotification = false;
+            notificationVolume = null;
+            notificationUri = "";
+
+            if (playing) {
+                int pos = posAtNotificationStart;
+                seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
+                play();
             }
+            posAtNotificationStart = 0;
+        }
+    }
 
-            return;
+    private void playFavorite() {
+        UpnpFavorite favorite = new UpnpFavorite(favoriteName, bindingConfig.path);
+        String uri = favorite.getUri();
+        UpnpEntry entry = favorite.getUpnpEntry();
+        if (!uri.isEmpty()) {
+            String metadata = "";
+            if (entry != null) {
+                metadata = UpnpXMLParser.compileMetadataString(entry);
+            }
+            setCurrentURI(uri, metadata);
+            play();
         }
     }
 
+    void updateFavoritesList() {
+        favoriteCommandOptionList = UpnpControlUtil.favorites(bindingConfig.path).stream()
+                .map(p -> (new CommandOption(p, p))).collect(Collectors.toList());
+        updateCommandDescription(favoriteSelectChannelUID, favoriteCommandOptionList);
+    }
+
+    @Override
+    public void playlistsListChanged() {
+        playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p)))
+                .collect(Collectors.toList());
+        updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList);
+    }
+
     @Override
     public void onStatusChanged(boolean status) {
-        logger.debug("Renderer status changed to {}", status);
-        if (status) {
-            initRenderer();
-        } else {
-            cancelSubscriptionRefreshJob();
+        if (!status) {
+            removeSubscriptions();
 
             updateState(CONTROL, PlayPauseType.PAUSE);
             cancelTrackPositionRefresh();
-
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
-                    "Communication lost with " + thing.getLabel());
         }
         super.onStatusChanged(status);
     }
 
+    @Override
+    protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
+            @Nullable String value, @Nullable String service, @Nullable String action) {
+        if (variable == null) {
+            return null;
+        } else {
+            switch (variable) {
+                case "CurrentVolume":
+                    return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Volume";
+                case "CurrentMute":
+                    return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Mute";
+                case "CurrentLoudness":
+                    return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Loudness";
+                default:
+                    return variable;
+            }
+        }
+    }
+
     @Override
     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
         if (logger.isTraceEnabled()) {
-            logger.trace("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(),
+            logger.trace("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(),
                     variable, value, service);
         } else {
             if (logger.isDebugEnabled() && !("AbsTime".equals(variable) || "RelCount".equals(variable)
                     || "RelTime".equals(variable) || "AbsCount".equals(variable) || "Track".equals(variable)
                     || "TrackDuration".equals(variable))) {
                 // don't log all variables received when updating the track position every second
-                logger.debug("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(),
+                logger.debug("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(),
                         variable, value, service);
             }
         }
@@ -527,139 +1064,313 @@ public class UpnpRendererHandler extends UpnpHandler {
             return;
         }
 
-        switch (variable) {
-            case "CurrentMute":
-                if (!((value == null) || (value.isEmpty()))) {
-                    soundMute = OnOffType.from(Boolean.parseBoolean(value));
-                    updateState(MUTE, soundMute);
-                }
-                break;
-            case "CurrentVolume":
-                if (!((value == null) || (value.isEmpty()))) {
-                    soundVolume = PercentType.valueOf(value);
-                    updateState(VOLUME, soundVolume);
-                }
-                break;
-            case "Sink":
-                if (!((value == null) || (value.isEmpty()))) {
-                    updateProtocolInfo(value);
-                }
-                break;
-            case "LastChange":
-                // pre-process some variables, eg XML processing
-                if (!((value == null) || value.isEmpty())) {
-                    if ("AVTransport".equals(service)) {
-                        Map<String, String> parsedValues = UpnpXMLParser.getAVTransportFromXML(value);
-                        for (Map.Entry<String, String> entrySet : parsedValues.entrySet()) {
-                            // Update the transport state after the update of the media information
-                            // to not break the notification mechanism
-                            if (!"TransportState".equals(entrySet.getKey())) {
-                                onValueReceived(entrySet.getKey(), entrySet.getValue(), service);
-                            }
-                            if ("AVTransportURI".equals(entrySet.getKey())) {
+        if (variable.endsWith("Volume")) {
+            onValueReceivedVolume(variable, value);
+        } else if (variable.endsWith("Mute")) {
+            onValueReceivedMute(variable, value);
+        } else if (variable.endsWith("Loudness")) {
+            onValueReceivedLoudness(variable, value);
+        } else {
+            switch (variable) {
+                case "LastChange":
+                    onValueReceivedLastChange(value, service);
+                    break;
+                case "CurrentTransportState":
+                case "TransportState":
+                    onValueReceivedTransportState(value);
+                    break;
+                case "CurrentTrackURI":
+                case "CurrentURI":
+                    onValueReceivedCurrentURI(value);
+                    break;
+                case "CurrentTrackMetaData":
+                case "CurrentURIMetaData":
+                    onValueReceivedCurrentMetaData(value);
+                    break;
+                case "NextAVTransportURIMetaData":
+                case "NextURIMetaData":
+                    onValueReceivedNextMetaData(value);
+                    break;
+                case "CurrentTrackDuration":
+                case "TrackDuration":
+                    onValueReceivedDuration(value);
+                    break;
+                case "RelTime":
+                    onValueReceivedRelTime(value);
+                    break;
+                default:
+                    super.onValueReceived(variable, value, service);
+                    break;
+            }
+        }
+    }
+
+    private void onValueReceivedVolume(String variable, @Nullable String value) {
+        if (value != null && !value.isEmpty()) {
+            UpnpRenderingControlConfiguration config = renderingControlConfiguration;
+
+            long volume = Long.valueOf(value);
+            volume = volume * 100 / config.maxvolume;
+
+            String upnpChannel = variable.replace("Volume", "volume").replace("Master", "");
+            updateState(upnpChannel, new PercentType((int) volume));
+
+            if (!playingNotification && "volume".equals(upnpChannel)) {
+                soundVolume = new PercentType((int) volume);
+            }
+        }
+    }
+
+    private void onValueReceivedMute(String variable, @Nullable String value) {
+        if (value != null && !value.isEmpty()) {
+            String upnpChannel = variable.replace("Mute", "mute").replace("Master", "");
+            updateState(upnpChannel,
+                    ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF);
+        }
+    }
+
+    private void onValueReceivedLoudness(String variable, @Nullable String value) {
+        if (value != null && !value.isEmpty()) {
+            String upnpChannel = variable.replace("Loudness", "loudness").replace("Master", "");
+            updateState(upnpChannel,
+                    ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF);
+        }
+    }
+
+    private void onValueReceivedLastChange(@Nullable String value, @Nullable String service) {
+        // This is returned from a GENA subscription. The jupnp library does not allow receiving new GENA subscription
+        // messages as long as this thread has not finished. As we may trigger long running processes based on this
+        // result, we run it in a separate thread.
+        upnpScheduler.submit(() -> {
+            // pre-process some variables, eg XML processing
+            if (value != null && !value.isEmpty()) {
+                if (AV_TRANSPORT.equals(service)) {
+                    Map<String, String> parsedValues = UpnpXMLParser.getAVTransportFromXML(value);
+                    for (Map.Entry<String, String> entrySet : parsedValues.entrySet()) {
+                        switch (entrySet.getKey()) {
+                            case "TransportState":
+                                // Update the transport state after the update of the media information
+                                // to not break the notification mechanism
+                                break;
+                            case "AVTransportURI":
                                 onValueReceived("CurrentTrackURI", entrySet.getValue(), service);
-                            } else if ("AVTransportURIMetaData".equals(entrySet.getKey())) {
+                                break;
+                            case "AVTransportURIMetaData":
                                 onValueReceived("CurrentTrackMetaData", entrySet.getValue(), service);
-                            }
-                        }
-                        if (parsedValues.containsKey("TransportState")) {
-                            onValueReceived("TransportState", parsedValues.get("TransportState"), service);
+                                break;
+                            default:
+                                onValueReceived(entrySet.getKey(), entrySet.getValue(), service);
                         }
                     }
-                }
-                break;
-            case "TransportState":
-                transportState = (value == null) ? "" : value;
-                if ("STOPPED".equals(value)) {
-                    updateState(CONTROL, PlayPauseType.PAUSE);
-                    cancelTrackPositionRefresh();
-                    // playerStopped is true if stop came from openHAB. This allows us to identify if we played to the
-                    // end of an entry. We should then move to the next entry if the queue is not at the end already.
-                    if (playing && !playerStopped) {
-                        // Only go to next for first STOP command, then wait until we received PLAYING before moving
-                        // to next (avoids issues with renderers sending multiple stop states)
-                        playing = false;
-                        serveNext();
-                    } else {
-                        currentEntry = nextEntry; // Try to get the metadata for the next entry if controlled by an
-                                                  // external control point
-                        playing = false;
-                    }
-                } else if ("PLAYING".equals(value)) {
-                    playerStopped = false;
-                    playing = true;
-                    updateState(CONTROL, PlayPauseType.PLAY);
-                    scheduleTrackPositionRefresh();
-                } else if ("PAUSED_PLAYBACK".equals(value)) {
-                    updateState(CONTROL, PlayPauseType.PAUSE);
-                }
-                break;
-            case "CurrentTrackURI":
-                UpnpEntry current = currentEntry;
-                if (queueIterator.hasNext() && (current != null) && !current.getRes().equals(value)
-                        && currentQueue.get(queueIterator.nextIndex()).getRes().equals(value)) {
-                    // Renderer advanced to next entry independent of openHAB UPnP control point.
-                    // Advance in the queue to keep proper position status.
-                    // Make the next entry available to renderers that support it.
-                    updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
-                    logger.trace("Renderer moved from '{}' to next entry '{}' in queue", currentEntry,
-                            currentQueue.get(queueIterator.nextIndex()));
-                    currentEntry = queueIterator.next();
-                    if (queueIterator.hasNext()) {
-                        UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
-                        setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
+                    if (parsedValues.containsKey("TransportState")) {
+                        onValueReceived("TransportState", parsedValues.get("TransportState"), service);
                     }
-                }
-                if (isSettingURI != null) {
-                    isSettingURI.complete(true); // We have received current URI, so can allow play to start
-                }
-                break;
-            case "CurrentTrackMetaData":
-                if (!((value == null) || (value.isEmpty()))) {
-                    List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
-                    if (!list.isEmpty()) {
-                        updateMetaDataState(list.get(0));
+                } else if (RENDERING_CONTROL.equals(service)) {
+                    Map<String, @Nullable String> parsedValues = UpnpXMLParser.getRenderingControlFromXML(value);
+                    for (String parsedValue : parsedValues.keySet()) {
+                        onValueReceived(parsedValue, parsedValues.get(parsedValue), RENDERING_CONTROL);
                     }
                 }
-                break;
-            case "NextAVTransportURIMetaData":
-                if (!((value == null) || (value.isEmpty() || "NOT_IMPLEMENTED".equals(value)))) {
-                    List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
-                    if (!list.isEmpty()) {
-                        nextEntry = list.get(0);
+            }
+        });
+    }
+
+    private void onValueReceivedTransportState(@Nullable String value) {
+        transportState = (value == null) ? "" : value;
+
+        if ("STOPPED".equals(value)) {
+            CompletableFuture<Boolean> stopping = isStopping;
+            if (stopping != null) {
+                stopping.complete(true); // We have received stop confirmation
+                isStopping = null;
+            }
+
+            if (playingNotification) {
+                resumeAfterNotification();
+                return;
+            }
+
+            cancelCheckPaused();
+            updateState(CONTROL, PlayPauseType.PAUSE);
+            cancelTrackPositionRefresh();
+            // Only go to next for first STOP command, then wait until we received PLAYING before moving
+            // to next (avoids issues with renderers sending multiple stop states)
+            if (playing) {
+                playing = false;
+
+                // playerStopped is true if stop came from openHAB. This allows us to identify if we played to the
+                // end of an entry, because STOP would come from the player and not from openHAB. We should then
+                // move to the next entry if the queue is not at the end already.
+                if (!playerStopped) {
+                    if (Instant.now().toEpochMilli() >= expectedTrackend) {
+                        // If we are receiving track duration info, we know when the track is expected to end. If we
+                        // received STOP before track end, and it is not coming from openHAB, it must have been stopped
+                        // from the renderer directly, and we do not want to play the next entry.
+                        if (playingQueue) {
+                            serveNext();
+                        }
                     }
+                } else if (playingQueue) {
+                    playingQueue = false;
                 }
-                break;
-            case "CurrentTrackDuration":
-            case "TrackDuration":
-                // track duration and track position have format H+:MM:SS[.F+] or H+:MM:SS[.F0/F1]. We are not
-                // interested in the fractional seconds, so drop everything after . and calculate in seconds.
-                if ((value == null) || ("NOT_IMPLEMENTED".equals(value))) {
-                    trackDuration = 0;
-                    updateState(TRACK_DURATION, UnDefType.UNDEF);
-                } else {
-                    trackDuration = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
-                            .reduce(0, (n, m) -> n * 60 + m);
-                    updateState(TRACK_DURATION, new QuantityType<>(trackDuration, SmartHomeUnits.SECOND));
-                }
-                break;
-            case "RelTime":
-                if ((value == null) || ("NOT_IMPLEMENTED".equals(value))) {
-                    trackPosition = 0;
-                    updateState(TRACK_POSITION, UnDefType.UNDEF);
-                } else {
-                    trackPosition = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
-                            .reduce(0, (n, m) -> n * 60 + m);
-                    updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
+            }
+        } else if ("PLAYING".equals(value)) {
+            if (playingNotification) {
+                return;
+            }
+
+            playerStopped = false;
+            playing = true;
+            registeredQueue = false; // reset queue registration flag as we are playing something
+            updateState(CONTROL, PlayPauseType.PLAY);
+            scheduleTrackPositionRefresh();
+        } else if ("PAUSED_PLAYBACK".equals(value)) {
+            cancelCheckPaused();
+            updateState(CONTROL, PlayPauseType.PAUSE);
+        } else if ("NO_MEDIA_PRESENT".equals(value)) {
+            updateState(CONTROL, UnDefType.UNDEF);
+        }
+    }
+
+    private void onValueReceivedCurrentURI(@Nullable String value) {
+        CompletableFuture<Boolean> settingURI = isSettingURI;
+        if (settingURI != null) {
+            settingURI.complete(true); // We have received current URI, so can allow play to start
+        }
+
+        UpnpEntry current = currentEntry;
+        UpnpEntry next = nextEntry;
+
+        String uri = "";
+        String currentUri = "";
+        String nextUri = "";
+        try {
+            if (value != null) {
+                uri = URLDecoder.decode(value.trim(), StandardCharsets.UTF_8.name());
+            }
+            if (current != null) {
+                currentUri = URLDecoder.decode(current.getRes().trim(), StandardCharsets.UTF_8.name());
+            }
+            if (next != null) {
+                nextUri = URLDecoder.decode(next.getRes(), StandardCharsets.UTF_8.name());
+            }
+        } catch (UnsupportedEncodingException ignore) {
+            // If not valid current URI, we assume there is none
+        }
+
+        if (playingNotification && uri.equals(notificationUri)) {
+            // No need to update anything more if this is for playing a notification
+            return;
+        }
+
+        nowPlayingUri = uri;
+        updateState(URI, StringType.valueOf(uri));
+
+        logger.trace("Renderer {} received URI: {}", thing.getLabel(), uri);
+        logger.trace("Renderer {} current URI: {}, equal to received URI {}", thing.getLabel(), currentUri,
+                uri.equals(currentUri));
+        logger.trace("Renderer {} next URI: {}", thing.getLabel(), nextUri);
+
+        if (!uri.equals(currentUri)) {
+            if ((next != null) && uri.equals(nextUri)) {
+                // Renderer advanced to next entry independent of openHAB UPnP control point.
+                // Advance in the queue to keep proper position status.
+                // Make the next entry available to renderers that support it.
+                logger.trace("Renderer {} moved from '{}' to next entry '{}' in queue", thing.getLabel(), current,
+                        next);
+                currentEntry = currentQueue.next();
+                nextEntry = currentQueue.get(currentQueue.nextIndex());
+                logger.trace("Renderer {} auto move forward, current queue index: {}", thing.getLabel(),
+                        currentQueue.index());
+
+                updateMetaDataState(next);
+
+                // look one further to get next entry for next URI
+                next = nextEntry;
+                if ((next != null) && !onlyplayone) {
+                    setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
                 }
-                break;
-            default:
-                super.onValueReceived(variable, value, service);
-                break;
+            } else {
+                // A new entry is being served that does not match the next entry in the queue. This can be because a
+                // sound or stream is being played through an action, or another control point started a new entry.
+                // We should clear the metadata in this case and wait for new metadata to arrive.
+                clearMetaDataState();
+            }
+        }
+    }
+
+    private void onValueReceivedCurrentMetaData(@Nullable String value) {
+        if (playingNotification) {
+            // Don't update metadata when playing notification
+            return;
+        }
+
+        if (value != null && !value.isEmpty()) {
+            List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
+            if (!list.isEmpty()) {
+                updateMetaDataState(list.get(0));
+                return;
+            }
+        }
+        clearMetaDataState();
+    }
+
+    private void onValueReceivedNextMetaData(@Nullable String value) {
+        if (value != null && !value.isEmpty() && !"NOT_IMPLEMENTED".equals(value)) {
+            List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
+            if (!list.isEmpty()) {
+                nextEntry = list.get(0);
+            }
+        }
+    }
+
+    private void onValueReceivedDuration(@Nullable String value) {
+        // track duration and track position have format H+:MM:SS[.F+] or H+:MM:SS[.F0/F1]. We are not
+        // interested in the fractional seconds, so drop everything after . and calculate in seconds.
+        if (value == null || "NOT_IMPLEMENTED".equals(value)) {
+            trackDuration = 0;
+            updateState(TRACK_DURATION, UnDefType.UNDEF);
+            updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
+        } else {
+            try {
+                trackDuration = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
+                        .reduce(0, (n, m) -> n * 60 + m);
+                updateState(TRACK_DURATION, new QuantityType<>(trackDuration, SmartHomeUnits.SECOND));
+            } catch (NumberFormatException e) {
+                logger.debug("Illegal format for track duration {}", value);
+                return;
+            }
+        }
+        setExpectedTrackend();
+    }
+
+    private void onValueReceivedRelTime(@Nullable String value) {
+        if (value == null || "NOT_IMPLEMENTED".equals(value)) {
+            trackPosition = 0;
+            updateState(TRACK_POSITION, UnDefType.UNDEF);
+            updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
+        } else {
+            try {
+                trackPosition = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
+                        .reduce(0, (n, m) -> n * 60 + m);
+                updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
+                int relPosition = (trackDuration != 0) ? trackPosition * 100 / trackDuration : 0;
+                updateState(REL_TRACK_POSITION, new PercentType(relPosition));
+            } catch (NumberFormatException e) {
+                logger.trace("Illegal format for track position {}", value);
+                return;
+            }
+        }
+
+        if (playingNotification) {
+            posAtNotificationStart = trackPosition;
         }
+
+        setExpectedTrackend();
     }
 
-    private void updateProtocolInfo(String value) {
+    @Override
+    protected void updateProtocolInfo(String value) {
         sink.clear();
         supportedAudioFormats.clear();
         audioSupport = false;
@@ -686,37 +1397,19 @@ public class UpnpRendererHandler extends UpnpHandler {
         }
 
         if (audioSupport) {
-            logger.debug("Device {} supports audio", thing.getLabel());
+            logger.debug("Renderer {} supports audio", thing.getLabel());
             registerAudioSink();
         }
     }
 
-    private void registerAudioSink() {
-        if (audioSinkRegistered) {
-            logger.debug("Audio Sink already registered for renderer {}", thing.getLabel());
-            return;
-        } else if (!service.isRegistered(this)) {
-            logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel());
-            return;
-        }
-        logger.debug("Registering Audio Sink for renderer {}", thing.getLabel());
-        audioSinkReg.registerAudioSink(this);
-        audioSinkRegistered = true;
-    }
-
     private void clearCurrentEntry() {
-        updateState(TITLE, UnDefType.UNDEF);
-        updateState(ALBUM, UnDefType.UNDEF);
-        updateState(ALBUM_ART, UnDefType.UNDEF);
-        updateState(CREATOR, UnDefType.UNDEF);
-        updateState(ARTIST, UnDefType.UNDEF);
-        updateState(PUBLISHER, UnDefType.UNDEF);
-        updateState(GENRE, UnDefType.UNDEF);
-        updateState(TRACK_NUMBER, UnDefType.UNDEF);
+        clearMetaDataState();
+
         trackDuration = 0;
         updateState(TRACK_DURATION, UnDefType.UNDEF);
         trackPosition = 0;
         updateState(TRACK_POSITION, UnDefType.UNDEF);
+        updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
 
         currentEntry = null;
     }
@@ -728,26 +1421,28 @@ public class UpnpRendererHandler extends UpnpHandler {
      *
      * @param queue
      */
-    public void registerQueue(ArrayList<UpnpEntry> queue) {
+    protected void registerQueue(UpnpEntryQueue queue) {
+        if (currentQueue.equals(queue)) {
+            // We get the same queue, so do nothing
+            return;
+        }
+
         logger.debug("Registering queue on renderer {}", thing.getLabel());
+
+        registeredQueue = true;
         currentQueue = queue;
-        queueIterator = new UpnpIterator<>(currentQueue.listIterator());
-        if (playing) {
-            if (queueIterator.hasNext()) {
+        currentQueue.setRepeat(repeat);
+        currentQueue.setShuffle(shuffle);
+        if (playingQueue) {
+            nextEntry = currentQueue.get(currentQueue.nextIndex());
+            UpnpEntry next = nextEntry;
+            if ((next != null) && !onlyplayone) {
                 // make the next entry available to renderers that support it
-                logger.trace("Still playing, set new queue as next entry");
-                UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
+                logger.trace("Renderer {} still playing, set new queue as next entry", thing.getLabel());
                 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
             }
         } else {
-            if (queueIterator.hasNext()) {
-                UpnpEntry entry = queueIterator.next();
-                updateMetaDataState(entry);
-                setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
-                currentEntry = entry;
-            } else {
-                clearCurrentEntry();
-            }
+            resetToStartQueue();
         }
     }
 
@@ -755,23 +1450,16 @@ public class UpnpRendererHandler extends UpnpHandler {
      * Move to next position in queue and start playing.
      */
     private void serveNext() {
-        if (queueIterator.hasNext()) {
-            currentEntry = queueIterator.next();
+        if (currentQueue.hasNext()) {
+            currentEntry = currentQueue.next();
+            nextEntry = currentQueue.get(currentQueue.nextIndex());
             logger.debug("Serve next media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
+            logger.trace("Serve next, current queue index: {}", currentQueue.index());
+
             serve();
         } else {
             logger.debug("Cannot serve next, end of queue on renderer {}", thing.getLabel());
-            cancelTrackPositionRefresh();
-            stop();
-            queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue
-            if (currentQueue.isEmpty()) {
-                clearCurrentEntry();
-            } else {
-                updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
-                UpnpEntry entry = queueIterator.next();
-                setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
-                currentEntry = entry;
-            }
+            resetToStartQueue();
         }
     }
 
@@ -779,62 +1467,132 @@ public class UpnpRendererHandler extends UpnpHandler {
      * Move to previous position in queue and start playing.
      */
     private void servePrevious() {
-        if (queueIterator.hasPrevious()) {
-            currentEntry = queueIterator.previous();
+        if (currentQueue.hasPrevious()) {
+            currentEntry = currentQueue.previous();
+            nextEntry = currentQueue.get(currentQueue.nextIndex());
             logger.debug("Serve previous media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
+            logger.trace("Serve previous, current queue index: {}", currentQueue.index());
+
             serve();
         } else {
             logger.debug("Cannot serve previous, already at start of queue on renderer {}", thing.getLabel());
-            cancelTrackPositionRefresh();
-            stop();
-            queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue
-            if (currentQueue.isEmpty()) {
-                clearCurrentEntry();
-            } else {
-                updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
-                UpnpEntry entry = queueIterator.next();
-                setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
-                currentEntry = entry;
-            }
+            resetToStartQueue();
+        }
+    }
+
+    private void resetToStartQueue() {
+        logger.trace("Reset to start queue on renderer {}", thing.getLabel());
+
+        playingQueue = false;
+        registeredQueue = true;
+
+        stop();
+
+        currentQueue.resetIndex(); // reset to beginning of queue
+        currentEntry = currentQueue.next();
+        nextEntry = currentQueue.get(currentQueue.nextIndex());
+        logger.trace("Reset queue, current queue index: {}", currentQueue.index());
+        UpnpEntry current = currentEntry;
+        if (current != null) {
+            clearMetaDataState();
+            updateMetaDataState(current);
+            setCurrentURI(current.getRes(), UpnpXMLParser.compileMetadataString(current));
+        } else {
+            clearCurrentEntry();
+        }
+
+        UpnpEntry next = nextEntry;
+        if (onlyplayone) {
+            setNextURI("", "");
+        } else if (next != null) {
+            setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
         }
     }
 
     /**
-     * Play media.
+     * Serve media from a queue and play immediately when already playing.
      *
      * @param media
      */
     private void serve() {
+        logger.trace("Serve media on renderer {}", thing.getLabel());
+
         UpnpEntry entry = currentEntry;
         if (entry != null) {
-            logger.trace("Ready to play '{}' from queue", currentEntry);
-            updateMetaDataState(entry);
+            clearMetaDataState();
             String res = entry.getRes();
             if (res.isEmpty()) {
-                logger.debug("Cannot serve media '{}', no URI", currentEntry);
+                logger.debug("Renderer {} cannot serve media '{}', no URI", thing.getLabel(), currentEntry);
+                playingQueue = false;
                 return;
             }
+            updateMetaDataState(entry);
             setCurrentURI(res, UpnpXMLParser.compileMetadataString(entry));
-            play();
+
+            if ((playingQueue || playing) && !(onlyplayone && oneplayed)) {
+                logger.trace("Ready to play '{}' from queue", currentEntry);
+
+                trackDuration = 0;
+                trackPosition = 0;
+                expectedTrackend = 0;
+                play();
+
+                oneplayed = true;
+                playingQueue = true;
+            }
 
             // make the next entry available to renderers that support it
-            if (queueIterator.hasNext()) {
-                UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
-                setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
+            if (!onlyplayone) {
+                UpnpEntry next = nextEntry;
+                if (next != null) {
+                    setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
+                }
             }
         }
     }
 
+    /**
+     * Called before handling a pause CONTROL command. If we do not received PAUSED_PLAYBACK or STOPPED back within
+     * timeout, we will revert to playing state. This takes care of renderers that cannot pause playback.
+     */
+    private void checkPaused() {
+        paused = upnpScheduler.schedule(this::resetPaused, config.responseTimeout, TimeUnit.MILLISECONDS);
+    }
+
+    private void resetPaused() {
+        updateState(CONTROL, PlayPauseType.PLAY);
+    }
+
+    private void cancelCheckPaused() {
+        ScheduledFuture<?> future = paused;
+        if (future != null) {
+            future.cancel(true);
+            paused = null;
+        }
+    }
+
+    private void setExpectedTrackend() {
+        expectedTrackend = Instant.now().toEpochMilli() + (trackDuration - trackPosition) * 1000
+                - config.responseTimeout;
+    }
+
     /**
      * Update the current track position every second if the channel is linked.
      */
     private void scheduleTrackPositionRefresh() {
-        cancelTrackPositionRefresh();
-        if (!isLinked(TRACK_POSITION)) {
+        if (playingNotification) {
             return;
         }
-        if (trackPositionRefresh == null) {
-            trackPositionRefresh = scheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1, TimeUnit.SECONDS);
+
+        cancelTrackPositionRefresh();
+        if (!(isLinked(TRACK_POSITION) || isLinked(REL_TRACK_POSITION))) {
+            // only get it once, so we can use the track end to correctly identify STOP pressed directly on renderer
+            getPositionInfo();
+        } else {
+            if (trackPositionRefresh == null) {
+                trackPositionRefresh = upnpScheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1,
+                        TimeUnit.SECONDS);
+            }
         }
     }
 
@@ -848,6 +1606,8 @@ public class UpnpRendererHandler extends UpnpHandler {
 
         trackPosition = 0;
         updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
+        int relPosition = (trackDuration != 0) ? trackPosition / trackDuration : 0;
+        updateState(REL_TRACK_POSITION, new PercentType(relPosition));
     }
 
     /**
@@ -856,20 +1616,40 @@ public class UpnpRendererHandler extends UpnpHandler {
      * @param media
      */
     private void updateMetaDataState(UpnpEntry media) {
-        // The AVTransport passes the URI resource in the ID.
-        // We don't want to update metadata if the metadata from the AVTransport is empty for the current entry.
-        boolean isCurrent;
-        UpnpEntry entry = currentEntry;
-        if (entry == null) {
-            entry = new UpnpEntry(media.getId(), media.getId(), "", "object.item");
-            currentEntry = entry;
-            isCurrent = false;
+        // We don't want to update metadata if the metadata from the AVTransport is less complete than in the current
+        // entry.
+        boolean isCurrent = false;
+        UpnpEntry entry = null;
+        if (playingQueue) {
+            entry = currentEntry;
+        }
+
+        logger.trace("Renderer {}, received media ID: {}", thing.getLabel(), media.getId());
+
+        if ((entry != null) && entry.getId().equals(media.getId())) {
+            logger.trace("Current ID: {}", entry.getId());
+
+            isCurrent = true;
         } else {
-            isCurrent = media.getId().equals(entry.getRes());
+            // Sometimes we receive the media URL without the ID, then compare on URL
+            String mediaRes = media.getRes().trim();
+            String entryRes = (entry != null) ? entry.getRes().trim() : "";
+
+            try {
+                String mediaUrl = URLDecoder.decode(mediaRes, StandardCharsets.UTF_8.name());
+                String entryUrl = URLDecoder.decode(entryRes, StandardCharsets.UTF_8.name());
+                isCurrent = mediaUrl.equals(entryUrl);
+            } catch (UnsupportedEncodingException e) {
+                logger.debug("Renderer {} unsupported encoding for new {} or current {} res URL, trying string compare",
+                        thing.getLabel(), mediaRes, entryRes);
+                isCurrent = mediaRes.equals(entryRes);
+            }
+
+            logger.trace("Current queue res: {}", entryRes);
+            logger.trace("Updated media res: {}", mediaRes);
         }
-        logger.trace("Media ID: {}", media.getId());
-        logger.trace("Current queue res: {}", entry.getRes());
-        logger.trace("Updating current entry: {}", isCurrent);
+
+        logger.trace("Received meta data is for current entry: {}", isCurrent);
 
         if (!(isCurrent && media.getTitle().isEmpty())) {
             updateState(TITLE, StringType.valueOf(media.getTitle()));
@@ -912,6 +1692,17 @@ public class UpnpRendererHandler extends UpnpHandler {
         }
     }
 
+    private void clearMetaDataState() {
+        updateState(TITLE, UnDefType.UNDEF);
+        updateState(ALBUM, UnDefType.UNDEF);
+        updateState(ALBUM_ART, UnDefType.UNDEF);
+        updateState(CREATOR, UnDefType.UNDEF);
+        updateState(ARTIST, UnDefType.UNDEF);
+        updateState(PUBLISHER, UnDefType.UNDEF);
+        updateState(GENRE, UnDefType.UNDEF);
+        updateState(TRACK_NUMBER, UnDefType.UNDEF);
+    }
+
     /**
      * @return Audio formats supported by the renderer.
      */
@@ -919,6 +1710,19 @@ public class UpnpRendererHandler extends UpnpHandler {
         return supportedAudioFormats;
     }
 
+    private void registerAudioSink() {
+        if (audioSinkRegistered) {
+            logger.debug("Audio Sink already registered for renderer {}", thing.getLabel());
+            return;
+        } else if (!upnpIOService.isRegistered(this)) {
+            logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel());
+            return;
+        }
+        logger.debug("Registering Audio Sink for renderer {}", thing.getLabel());
+        audioSinkReg.registerAudioSink(this);
+        audioSinkRegistered = true;
+    }
+
     /**
      * @return UPnP sink definitions supported by the renderer.
      */
index 1b5715a4bfa943eadc94d435a69df2dc070fef07..11a78f77a8a154da10b8fa47bd8fc41acca8fe48 100644 (file)
@@ -22,7 +22,12 @@ import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.stream.Collectors;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -30,10 +35,13 @@ import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.binding.upnpcontrol.internal.UpnpControlHandlerFactory;
 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
-import org.openhab.binding.upnpcontrol.internal.UpnpEntry;
-import org.openhab.binding.upnpcontrol.internal.UpnpProtocolMatcher;
-import org.openhab.binding.upnpcontrol.internal.UpnpXMLParser;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlServerConfiguration;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpProtocolMatcher;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser;
 import org.openhab.core.io.transport.upnp.UpnpIOService;
 import org.openhab.core.library.types.StringType;
 import org.openhab.core.thing.Channel;
@@ -42,19 +50,17 @@ import org.openhab.core.thing.Thing;
 import org.openhab.core.thing.ThingStatus;
 import org.openhab.core.thing.ThingStatusDetail;
 import org.openhab.core.types.Command;
-import org.openhab.core.types.CommandDescription;
-import org.openhab.core.types.CommandDescriptionBuilder;
 import org.openhab.core.types.CommandOption;
 import org.openhab.core.types.RefreshType;
-import org.openhab.core.types.StateDescription;
-import org.openhab.core.types.StateDescriptionFragmentBuilder;
+import org.openhab.core.types.State;
 import org.openhab.core.types.StateOption;
 import org.openhab.core.types.UnDefType;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * The {@link UpnpServerHandler} is responsible for handling commands sent to the UPnP Server.
+ * The {@link UpnpServerHandler} is responsible for handling commands sent to the UPnP Server. It implements UPnP
+ * ContentDirectory service actions.
  *
  * @author Mark Herwege - Initial contribution
  * @author Karel Goderis - Based on UPnP logic in Sonos binding
@@ -62,41 +68,49 @@ import org.slf4j.LoggerFactory;
 @NonNullByDefault
 public class UpnpServerHandler extends UpnpHandler {
 
-    private static final String DIRECTORY_ROOT = "0";
-    private static final String UP = "..";
-
     private final Logger logger = LoggerFactory.getLogger(UpnpServerHandler.class);
 
-    private ConcurrentMap<String, UpnpRendererHandler> upnpRenderers;
+    // UPnP constants
+    static final String CONTENT_DIRECTORY = "ContentDirectory";
+    static final String DIRECTORY_ROOT = "0";
+    static final String UP = "..";
+
+    ConcurrentMap<String, UpnpRendererHandler> upnpRenderers;
     private volatile @Nullable UpnpRendererHandler currentRendererHandler;
     private volatile List<StateOption> rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
 
+    private volatile List<CommandOption> playlistCommandOptionList = List.of();
+
     private @NonNullByDefault({}) ChannelUID rendererChannelUID;
     private @NonNullByDefault({}) ChannelUID currentSelectionChannelUID;
+    private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID;
+
+    private volatile @Nullable CompletableFuture<Boolean> isBrowsing;
+    private volatile boolean browseUp = false; // used to avoid automatically going down a level if only one container
+                                               // entry found when going up in the hierarchy
 
-    private volatile UpnpEntry currentEntry = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
+    private static final UpnpEntry ROOT_ENTRY = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
             "object.container");
-    private volatile List<UpnpEntry> entries = Collections.synchronizedList(new ArrayList<>()); // current entry list in
-                                                                                                // selection
-    private volatile Map<String, UpnpEntry> parentMap = new HashMap<>(); // store parents in hierarchy separately to be
-                                                                         // able to move up in directory structure
+    volatile UpnpEntry currentEntry = ROOT_ENTRY;
+    // current entry list in selection
+    List<UpnpEntry> entries = Collections.synchronizedList(new ArrayList<>());
+    // store parents in hierarchy separately to be able to move up in directory structure
+    private ConcurrentMap<String, UpnpEntry> parentMap = new ConcurrentHashMap<>();
 
-    private UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
-    private UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
+    private volatile String playlistName = "";
 
     protected @NonNullByDefault({}) UpnpControlServerConfiguration config;
 
     public UpnpServerHandler(Thing thing, UpnpIOService upnpIOService,
             ConcurrentMap<String, UpnpRendererHandler> upnpRenderers,
             UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
-            UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
-        super(thing, upnpIOService);
+            UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider,
+            UpnpControlBindingConfiguration configuration) {
+        super(thing, upnpIOService, configuration, upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
         this.upnpRenderers = upnpRenderers;
-        this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
-        this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
 
         // put root as highest level in parent map
-        parentMap.put(currentEntry.getId(), currentEntry);
+        parentMap.put(ROOT_ENTRY.getId(), ROOT_ENTRY);
     }
 
     @Override
@@ -122,34 +136,151 @@ public class UpnpServerHandler extends UpnpHandler {
                     "Channel " + BROWSE + " not defined");
             return;
         }
-        if (config.udn != null) {
-            if (service.isRegistered(this)) {
-                initServer();
-            } else {
+        Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT);
+        if (playlistSelectChannel != null) {
+            playlistSelectChannelUID = playlistSelectChannel.getUID();
+        } else {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "Channel " + PLAYLIST_SELECT + " not defined");
+            return;
+        }
+
+        initDevice();
+    }
+
+    @Override
+    public void dispose() {
+        logger.debug("Disposing handler for media server device {}", thing.getLabel());
+
+        CompletableFuture<Boolean> browsingFuture = isBrowsing;
+        if (browsingFuture != null) {
+            browsingFuture.complete(false);
+            isBrowsing = null;
+        }
+
+        super.dispose();
+    }
+
+    @Override
+    protected void initJob() {
+        synchronized (jobLock) {
+            if (!upnpIOService.isRegistered(this)) {
                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
-                        "Communication cannot be established with " + thing.getLabel());
+                        "UPnP device with UDN " + getUDN() + " not yet registered");
+                return;
+            }
+
+            if (!ThingStatus.ONLINE.equals(thing.getStatus())) {
+                rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
+                synchronized (rendererStateOptionList) {
+                    upnpRenderers.forEach((key, value) -> {
+                        StateOption stateOption = new StateOption(key, value.getThing().getLabel());
+                        rendererStateOptionList.add(stateOption);
+                    });
+                }
+                updateStateDescription(rendererChannelUID, rendererStateOptionList);
+                getProtocolInfo();
+                browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
+                playlistsListChanged();
+                updateStatus(ThingStatus.ONLINE);
+            }
+
+            if (!upnpSubscribed) {
+                addSubscriptions();
+            }
+        }
+    }
+
+    /**
+     * Method that does a UPnP browse on a content directory. Results will be retrieved in the {@link onValueReceived}
+     * method.
+     *
+     * @param objectID content directory object
+     * @param browseFlag BrowseMetaData or BrowseDirectChildren
+     * @param filter properties to be returned
+     * @param startingIndex starting index of objects to return
+     * @param requestedCount number of objects to return, 0 for all
+     * @param sortCriteria sort criteria, example: +dc:title
+     */
+    protected void browse(String objectID, String browseFlag, String filter, String startingIndex,
+            String requestedCount, String sortCriteria) {
+        CompletableFuture<Boolean> browsing = isBrowsing;
+        boolean browsed = true;
+        try {
+            if (browsing != null) {
+                // wait for maximum 2.5s until browsing is finished
+                browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
             }
+        } catch (InterruptedException | ExecutionException | TimeoutException e) {
+            logger.debug("Exception, previous server query on {} interrupted or timed out, trying new browse anyway",
+                    thing.getLabel());
+        }
+
+        if (browsed) {
+            isBrowsing = new CompletableFuture<Boolean>();
+
+            Map<String, String> inputs = new HashMap<>();
+            inputs.put("ObjectID", objectID);
+            inputs.put("BrowseFlag", browseFlag);
+            inputs.put("Filter", filter);
+            inputs.put("StartingIndex", startingIndex);
+            inputs.put("RequestedCount", requestedCount);
+            inputs.put("SortCriteria", sortCriteria);
+
+            invokeAction(CONTENT_DIRECTORY, "Browse", inputs);
         } else {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
-                    "No UDN configured for " + thing.getLabel());
+            logger.debug("Cannot browse, cancelled querying server {}", thing.getLabel());
         }
     }
 
-    private void initServer() {
-        rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
-        synchronized (rendererStateOptionList) {
-            upnpRenderers.forEach((key, value) -> {
-                StateOption stateOption = new StateOption(key, value.getThing().getLabel());
-                rendererStateOptionList.add(stateOption);
-            });
+    /**
+     * Method that does a UPnP search on a content directory. Results will be retrieved in the {@link onValueReceived}
+     * method.
+     *
+     * @param containerID content directory container
+     * @param searchCriteria search criteria, examples:
+     *            dc:title contains "song"
+     *            dc:creator contains "Springsteen"
+     *            upnp:class = "object.item.audioItem"
+     *            upnp:album contains "Born in"
+     * @param filter properties to be returned
+     * @param startingIndex starting index of objects to return
+     * @param requestedCount number of objects to return, 0 for all
+     * @param sortCriteria sort criteria, example: +dc:title
+     */
+    protected void search(String containerID, String searchCriteria, String filter, String startingIndex,
+            String requestedCount, String sortCriteria) {
+        CompletableFuture<Boolean> browsing = isBrowsing;
+        boolean browsed = true;
+        try {
+            if (browsing != null) {
+                // wait for maximum 2.5s until browsing is finished
+                browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
+            }
+        } catch (InterruptedException | ExecutionException | TimeoutException e) {
+            logger.debug("Exception, previous server query on {} interrupted or timed out, trying new search anyway",
+                    thing.getLabel());
         }
-        updateStateDescription(rendererChannelUID, rendererStateOptionList);
 
-        getProtocolInfo();
+        if (browsed) {
+            isBrowsing = new CompletableFuture<Boolean>();
 
-        browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
+            Map<String, String> inputs = new HashMap<>();
+            inputs.put("ContainerID", containerID);
+            inputs.put("SearchCriteria", searchCriteria);
+            inputs.put("Filter", filter);
+            inputs.put("StartingIndex", startingIndex);
+            inputs.put("RequestedCount", requestedCount);
+            inputs.put("SortCriteria", sortCriteria);
 
-        updateStatus(ThingStatus.ONLINE);
+            invokeAction(CONTENT_DIRECTORY, "Search", inputs);
+        } else {
+            logger.debug("Cannot search, cancelled querying server {}", thing.getLabel());
+        }
+    }
+
+    protected void updateServerState(ChannelUID channelUID, State state) {
+        updateState(channelUID, state);
     }
 
     @Override
@@ -158,89 +289,256 @@ public class UpnpServerHandler extends UpnpHandler {
 
         switch (channelUID.getId()) {
             case UPNPRENDERER:
-                if (command instanceof StringType) {
-                    currentRendererHandler = (upnpRenderers.get(((StringType) command).toString()));
-                    if (config.filter) {
-                        // only refresh title list if filtering by renderer capabilities
-                        browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
-                    }
-                } else if (command instanceof RefreshType) {
-                    UpnpRendererHandler renderer = currentRendererHandler;
-                    if (renderer != null) {
-                        updateState(channelUID, StringType.valueOf(renderer.getThing().getLabel()));
-                    }
-                }
+                handleCommandUpnpRenderer(channelUID, command);
+                break;
+            case CURRENTTITLE:
+                handleCommandCurrentTitle(channelUID, command);
                 break;
-            case CURRENTID:
-                String currentId = "";
-                if (command instanceof StringType) {
-                    currentId = String.valueOf(command);
-                } else if (command instanceof RefreshType) {
-                    currentId = currentEntry.getId();
-                    updateState(channelUID, StringType.valueOf(currentId));
-                }
-                logger.debug("Setting currentId to {}", currentId);
-                if (!currentId.isEmpty()) {
-                    browse(currentId, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
-                }
             case BROWSE:
-                if (command instanceof StringType) {
-                    String browseTarget = command.toString();
-                    if (browseTarget != null) {
-                        if (!UP.equals(browseTarget)) {
-                            final String target = browseTarget;
-                            synchronized (entries) {
-                                Optional<UpnpEntry> current = entries.stream()
-                                        .filter(entry -> target.equals(entry.getId())).findFirst();
-                                if (current.isPresent()) {
-                                    currentEntry = current.get();
-                                } else {
-                                    logger.info("Trying to browse invalid target {}", browseTarget);
-                                    browseTarget = UP; // move up on invalid target
-                                }
-                            }
-                        }
-                        if (UP.equals(browseTarget)) {
-                            // Move up in tree
-                            browseTarget = currentEntry.getParentId();
-                            if (browseTarget.isEmpty()) {
-                                // No parent found, so make it the root directory
-                                browseTarget = DIRECTORY_ROOT;
-                            }
-                            UpnpEntry entry = parentMap.get(browseTarget);
-                            if (entry == null) {
-                                logger.info("Browse target not found. Exiting.");
-                                return;
-                            }
-                            currentEntry = entry;
+                handleCommandBrowse(channelUID, command);
+                break;
+            case SEARCH:
+                handleCommandSearch(command);
+                break;
+            case PLAYLIST_SELECT:
+                handleCommandPlaylistSelect(channelUID, command);
+                break;
+            case PLAYLIST:
+                handleCommandPlaylist(channelUID, command);
+                break;
+            case PLAYLIST_ACTION:
+                handleCommandPlaylistAction(command);
+                break;
+            case VOLUME:
+            case MUTE:
+            case CONTROL:
+            case STOP:
+                // Pass these on to the media renderer thing if one is selected
+                handleCommandInRenderer(channelUID, command);
+                break;
+        }
+    }
 
-                        }
-                        updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
-                        logger.debug("Browse target {}", browseTarget);
-                        browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
+    private void handleCommandUpnpRenderer(ChannelUID channelUID, Command command) {
+        UpnpRendererHandler renderer = null;
+        UpnpRendererHandler previousRenderer = currentRendererHandler;
+        if (command instanceof StringType) {
+            renderer = (upnpRenderers.get(((StringType) command).toString()));
+            currentRendererHandler = renderer;
+            if (config.filter) {
+                // only refresh title list if filtering by renderer capabilities
+                browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
+            } else {
+                serveMedia();
+            }
+        }
+
+        if ((renderer != null) && !renderer.equals(previousRenderer)) {
+            if (previousRenderer != null) {
+                previousRenderer.unsetServerHandler();
+            }
+            renderer.setServerHandler(this);
+
+            Channel channel;
+            if ((channel = thing.getChannel(VOLUME)) != null) {
+                handleCommand(channel.getUID(), RefreshType.REFRESH);
+            }
+            if ((channel = thing.getChannel(MUTE)) != null) {
+                handleCommand(channel.getUID(), RefreshType.REFRESH);
+            }
+            if ((channel = thing.getChannel(CONTROL)) != null) {
+                handleCommand(channel.getUID(), RefreshType.REFRESH);
+            }
+        }
+
+        if ((renderer = currentRendererHandler) != null) {
+            updateState(channelUID, StringType.valueOf(renderer.getThing().getUID().toString()));
+        } else {
+            updateState(channelUID, UnDefType.UNDEF);
+        }
+    }
+
+    private void handleCommandCurrentTitle(ChannelUID channelUID, Command command) {
+        if (command instanceof RefreshType) {
+            updateState(channelUID, StringType.valueOf(currentEntry.getTitle()));
+        }
+    }
+
+    private void handleCommandBrowse(ChannelUID channelUID, Command command) {
+        String browseTarget = "";
+        if (command instanceof StringType) {
+            browseTarget = command.toString();
+            if (!browseTarget.isEmpty()) {
+                if (UP.equals(browseTarget)) {
+                    // Move up in tree
+                    browseTarget = currentEntry.getParentId();
+                    if (browseTarget.isEmpty()) {
+                        // No parent found, so make it the root directory
+                        browseTarget = DIRECTORY_ROOT;
                     }
+                    browseUp = true;
                 }
-                break;
-            case SEARCH:
-                if (command instanceof StringType) {
-                    String criteria = command.toString();
-                    if (criteria != null) {
-                        String searchContainer = "";
-                        if (currentEntry.isContainer()) {
-                            searchContainer = currentEntry.getId();
+                UpnpEntry entry = parentMap.get(browseTarget);
+                if (entry != null) {
+                    currentEntry = entry;
+                } else {
+                    final String target = browseTarget;
+                    synchronized (entries) {
+                        Optional<UpnpEntry> current = entries.stream().filter(e -> target.equals(e.getId()))
+                                .findFirst();
+                        if (current.isPresent()) {
+                            currentEntry = current.get();
                         } else {
-                            searchContainer = currentEntry.getParentId();
-                        }
-                        if (searchContainer.isEmpty()) {
-                            // No parent found, so make it the root directory
-                            searchContainer = DIRECTORY_ROOT;
+                            // The real entry is not in the parentMap or options list yet, so construct a default one
+                            currentEntry = new UpnpEntry(browseTarget, browseTarget, DIRECTORY_ROOT,
+                                    "object.container");
                         }
-                        updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
-                        logger.debug("Search container {} for {}", searchContainer, criteria);
-                        search(searchContainer, criteria, "*", "0", "0", config.sortcriteria);
                     }
                 }
-                break;
+
+                logger.debug("Browse target {}", browseTarget);
+                logger.debug("Navigating to node {} on server {}", currentEntry.getId(), thing.getLabel());
+                updateState(channelUID, StringType.valueOf(browseTarget));
+                updateState(CURRENTTITLE, StringType.valueOf(currentEntry.getTitle()));
+                browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
+            }
+        } else if (command instanceof RefreshType) {
+            browseTarget = currentEntry.getId();
+            updateState(channelUID, StringType.valueOf(browseTarget));
+        }
+    }
+
+    private void handleCommandSearch(Command command) {
+        if (command instanceof StringType) {
+            String criteria = command.toString();
+            if (!criteria.isEmpty()) {
+                String searchContainer = "";
+                if (currentEntry.isContainer()) {
+                    searchContainer = currentEntry.getId();
+                } else {
+                    searchContainer = currentEntry.getParentId();
+                }
+                if (config.searchFromRoot || searchContainer.isEmpty()) {
+                    // Config option search from root or no parent found, so make it the root directory
+                    searchContainer = DIRECTORY_ROOT;
+                }
+                UpnpEntry entry = parentMap.get(searchContainer);
+                if (entry != null) {
+                    currentEntry = entry;
+                } else {
+                    // The real entry is not in the parentMap yet, so construct a default one
+                    currentEntry = new UpnpEntry(searchContainer, searchContainer, DIRECTORY_ROOT, "object.container");
+                }
+
+                logger.debug("Navigating to node {} on server {}", searchContainer, thing.getLabel());
+                updateState(BROWSE, StringType.valueOf(currentEntry.getId()));
+                logger.debug("Search container {} for {}", searchContainer, criteria);
+                search(searchContainer, criteria, "*", "0", "0", config.sortCriteria);
+            }
+        }
+    }
+
+    private void handleCommandPlaylistSelect(ChannelUID channelUID, Command command) {
+        if (command instanceof StringType) {
+            playlistName = command.toString();
+            updateState(PLAYLIST, StringType.valueOf(playlistName));
+        }
+    }
+
+    private void handleCommandPlaylist(ChannelUID channelUID, Command command) {
+        if (command instanceof StringType) {
+            playlistName = command.toString();
+        }
+        updateState(channelUID, StringType.valueOf(playlistName));
+    }
+
+    private void handleCommandPlaylistAction(Command command) {
+        if (command instanceof StringType) {
+            switch (command.toString()) {
+                case RESTORE:
+                    handleCommandPlaylistRestore();
+                    break;
+                case SAVE:
+                    handleCommandPlaylistSave(false);
+                    break;
+                case APPEND:
+                    handleCommandPlaylistSave(true);
+                    break;
+                case DELETE:
+                    handleCommandPlaylistDelete();
+                    break;
+            }
+        }
+    }
+
+    private void handleCommandPlaylistRestore() {
+        if (!playlistName.isEmpty()) {
+            // Don't immediately restore a playlist if a browse or search is still underway, or it could get overwritten
+            CompletableFuture<Boolean> browsing = isBrowsing;
+            try {
+                if (browsing != null) {
+                    // wait for maximum 2.5s until browsing is finished
+                    browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
+                }
+            } catch (InterruptedException | ExecutionException | TimeoutException e) {
+                logger.debug(
+                        "Exception, previous server on {} query interrupted or timed out, restoring playlist anyway",
+                        thing.getLabel());
+            }
+
+            UpnpEntryQueue queue = new UpnpEntryQueue();
+            queue.restoreQueue(playlistName, config.udn, bindingConfig.path);
+            updateTitleSelection(queue.getEntryList());
+
+            String parentId;
+            UpnpEntry current = queue.get(0);
+            if (current != null) {
+                parentId = current.getParentId();
+                UpnpEntry entry = parentMap.get(parentId);
+                if (entry != null) {
+                    currentEntry = entry;
+                } else {
+                    // The real entry is not in the parentMap yet, so construct a default one
+                    currentEntry = new UpnpEntry(parentId, parentId, DIRECTORY_ROOT, "object.container");
+                }
+            } else {
+                parentId = DIRECTORY_ROOT;
+                currentEntry = ROOT_ENTRY;
+            }
+
+            logger.debug("Restoring playlist to node {} on server {}", parentId, thing.getLabel());
+        }
+    }
+
+    private void handleCommandPlaylistSave(boolean append) {
+        if (!playlistName.isEmpty()) {
+            List<UpnpEntry> mediaQueue = new ArrayList<>();
+            mediaQueue.addAll(entries);
+            if (mediaQueue.isEmpty() && !currentEntry.isContainer()) {
+                mediaQueue.add(currentEntry);
+            }
+            UpnpEntryQueue queue = new UpnpEntryQueue(mediaQueue, config.udn);
+            queue.persistQueue(playlistName, append, bindingConfig.path);
+            UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
+        }
+    }
+
+    private void handleCommandPlaylistDelete() {
+        if (!playlistName.isEmpty()) {
+            UpnpControlUtil.deletePlaylist(playlistName, bindingConfig.path);
+            UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
+            updateState(PLAYLIST, UnDefType.UNDEF);
+        }
+    }
+
+    private void handleCommandInRenderer(ChannelUID channelUID, Command command) {
+        String channelId = channelUID.getId();
+        UpnpRendererHandler handler = currentRendererHandler;
+        Channel channel;
+        if ((handler != null) && (channel = handler.getThing().getChannel(channelId)) != null) {
+            handler.handleCommand(channel.getUID(), command);
+        } else if (!STOP.equals(channelId)) {
+            updateState(channelId, UnDefType.UNDEF);
         }
     }
 
@@ -252,7 +550,10 @@ public class UpnpServerHandler extends UpnpHandler {
      */
     public void addRendererOption(String key) {
         synchronized (rendererStateOptionList) {
-            rendererStateOptionList.add(new StateOption(key, upnpRenderers.get(key).getThing().getLabel()));
+            UpnpRendererHandler handler = upnpRenderers.get(key);
+            if (handler != null) {
+                rendererStateOptionList.add(new StateOption(key, handler.getThing().getLabel()));
+            }
         }
         updateStateDescription(rendererChannelUID, rendererStateOptionList);
         logger.debug("Renderer option {} added to {}", key, thing.getLabel());
@@ -277,27 +578,32 @@ public class UpnpServerHandler extends UpnpHandler {
         logger.debug("Renderer option {} removed from {}", key, thing.getLabel());
     }
 
-    private void updateTitleSelection(List<UpnpEntry> titleList) {
-        logger.debug("Navigating to node {} on server {}", currentEntry.getId(), thing.getLabel());
+    @Override
+    public void playlistsListChanged() {
+        playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p)))
+                .collect(Collectors.toList());
+        updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList);
+    }
 
+    private void updateTitleSelection(List<UpnpEntry> titleList) {
         // Optionally, filter only items that can be played on the renderer
         logger.debug("Filtering content on server {}: {}", thing.getLabel(), config.filter);
         List<UpnpEntry> resultList = config.filter ? filterEntries(titleList, true) : titleList;
 
-        List<CommandOption> commandOptionList = new ArrayList<>();
+        List<StateOption> stateOptionList = new ArrayList<>();
         // Add a directory up selector if not in the directory root
         if ((!resultList.isEmpty() && !(DIRECTORY_ROOT.equals(resultList.get(0).getParentId())))
                 || (resultList.isEmpty() && !DIRECTORY_ROOT.equals(currentEntry.getId()))) {
-            CommandOption commandOption = new CommandOption(UP, UP);
-            commandOptionList.add(commandOption);
+            StateOption stateOption = new StateOption(UP, UP);
+            stateOptionList.add(stateOption);
             logger.debug("UP added to selection list on server {}", thing.getLabel());
         }
 
         synchronized (entries) {
             entries.clear(); // always only keep the current selection in the entry map to keep memory usage down
             resultList.forEach((value) -> {
-                CommandOption commandOption = new CommandOption(value.getId(), value.getTitle());
-                commandOptionList.add(commandOption);
+                StateOption stateOption = new StateOption(value.getId(), value.getTitle());
+                stateOptionList.add(stateOption);
                 logger.trace("{} added to selection list on server {}", value.getId(), thing.getLabel());
 
                 // Keep the entries in a map so we can find the parent and container for the current selection to go
@@ -309,131 +615,47 @@ public class UpnpServerHandler extends UpnpHandler {
             });
         }
 
-        // Set the currentId to the parent of the first entry in the list
-        if (!resultList.isEmpty()) {
-            updateState(CURRENTID, StringType.valueOf(resultList.get(0).getId()));
-        }
-
-        logger.debug("{} entries added to selection list on server {}", commandOptionList.size(), thing.getLabel());
-        updateCommandDescription(currentSelectionChannelUID, commandOptionList);
+        logger.debug("{} entries added to selection list on server {}", stateOptionList.size(), thing.getLabel());
+        updateStateDescription(currentSelectionChannelUID, stateOptionList);
+        updateState(BROWSE, StringType.valueOf(currentEntry.getId()));
+        updateState(CURRENTTITLE, StringType.valueOf(currentEntry.getTitle()));
 
         serveMedia();
     }
 
     /**
-     * Filter a list of media and only keep the media that are playable on the currently selected renderer.
+     * Filter a list of media and only keep the media that are playable on the currently selected renderer. Return all
+     * if no renderer is selected.
      *
      * @param resultList
      * @param includeContainers
      * @return
      */
     private List<UpnpEntry> filterEntries(List<UpnpEntry> resultList, boolean includeContainers) {
-        logger.debug("Raw result list {}", resultList);
-        List<UpnpEntry> list = new ArrayList<>();
-        UpnpRendererHandler handler = currentRendererHandler;
-        if (handler != null) {
-            List<String> sink = handler.getSink();
-            list = resultList.stream()
-                    .filter(entry -> (includeContainers && entry.isContainer())
-                            || UpnpProtocolMatcher.testProtocolList(entry.getProtocolList(), sink))
-                    .collect(Collectors.toList());
-        }
-        logger.debug("Filtered result list {}", list);
-        return list;
-    }
-
-    private void updateStateDescription(ChannelUID channelUID, List<StateOption> stateOptionList) {
-        StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
-                .withOptions(stateOptionList).build().toStateDescription();
-        upnpStateDescriptionProvider.setDescription(channelUID, stateDescription);
-    }
-
-    private void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
-        CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
-                .build();
-        upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
-    }
+        logger.debug("Server {}, raw result list {}", thing.getLabel(), resultList);
 
-    /**
-     * Method that does a UPnP browse on a content directory. Results will be retrieved in the
-     * {@link #onValueReceived(String, String, String)} method.
-     *
-     * @param objectID content directory object
-     * @param browseFlag BrowseMetaData or BrowseDirectChildren
-     * @param filter properties to be returned
-     * @param startingIndex starting index of objects to return
-     * @param requestedCount number of objects to return, 0 for all
-     * @param sortCriteria sort criteria, example: +dc:title
-     */
-    public void browse(String objectID, String browseFlag, String filter, String startingIndex, String requestedCount,
-            String sortCriteria) {
-        Map<String, String> inputs = new HashMap<>();
-        inputs.put("ObjectID", objectID);
-        inputs.put("BrowseFlag", browseFlag);
-        inputs.put("Filter", filter);
-        inputs.put("StartingIndex", startingIndex);
-        inputs.put("RequestedCount", requestedCount);
-        inputs.put("SortCriteria", sortCriteria);
-
-        invokeAction("ContentDirectory", "Browse", inputs);
-    }
-
-    /**
-     * Method that does a UPnP search on a content directory. Results will be retrieved in the
-     * {@link #onValueReceived(String, String, String)} method.
-     *
-     * @param containerID content directory container
-     * @param searchCriteria search criteria, examples:
-     *            dc:title contains "song"
-     *            dc:creator contains "Springsteen"
-     *            upnp:class = "object.item.audioItem"
-     *            upnp:album contains "Born in"
-     * @param filter properties to be returned
-     * @param startingIndex starting index of objects to return
-     * @param requestedCount number of objects to return, 0 for all
-     * @param sortCriteria sort criteria, example: +dc:title
-     */
-    public void search(String containerID, String searchCriteria, String filter, String startingIndex,
-            String requestedCount, String sortCriteria) {
-        Map<String, String> inputs = new HashMap<>();
-        inputs.put("ContainerID", containerID);
-        inputs.put("SearchCriteria", searchCriteria);
-        inputs.put("Filter", filter);
-        inputs.put("StartingIndex", startingIndex);
-        inputs.put("RequestedCount", requestedCount);
-        inputs.put("SortCriteria", sortCriteria);
-
-        invokeAction("ContentDirectory", "Search", inputs);
-    }
+        UpnpRendererHandler handler = currentRendererHandler;
+        List<String> sink = (handler != null) ? handler.getSink() : null;
+        List<UpnpEntry> list = resultList.stream()
+                .filter(entry -> ((includeContainers && entry.isContainer()) || (sink == null) && !entry.isContainer())
+                        || ((sink != null) && UpnpProtocolMatcher.testProtocolList(entry.getProtocolList(), sink)))
+                .collect(Collectors.toList());
 
-    @Override
-    public void onStatusChanged(boolean status) {
-        logger.debug("Server status changed to {}", status);
-        if (status) {
-            initServer();
-        } else {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
-                    "Communication lost with " + thing.getLabel());
-        }
-        super.onStatusChanged(status);
+        logger.debug("Server {}, filtered result list {}", thing.getLabel(), list);
+        return list;
     }
 
     @Override
     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
-        logger.debug("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(), variable,
+        logger.debug("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(), variable,
                 value, service);
         if (variable == null) {
             return;
         }
         switch (variable) {
             case "Result":
-                if (!((value == null) || (value.isEmpty()))) {
-                    updateTitleSelection(removeDuplicates(UpnpXMLParser.getEntriesFromXML(value)));
-                } else {
-                    updateTitleSelection(new ArrayList<UpnpEntry>());
-                }
+                onValueReceivedResult(value);
                 break;
-            case "Source":
             case "NumberReturned":
             case "TotalMatches":
             case "UpdateID":
@@ -444,6 +666,39 @@ public class UpnpServerHandler extends UpnpHandler {
         }
     }
 
+    private void onValueReceivedResult(@Nullable String value) {
+        CompletableFuture<Boolean> browsing = isBrowsing;
+        if (!((value == null) || (value.isEmpty()))) {
+            List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
+            if (config.browseDown && (list.size() == 1) && list.get(0).isContainer() && !browseUp) {
+                // We only received one container entry, so we immediately browse to the next level if config.browsedown
+                // = true
+                if (browsing != null) {
+                    browsing.complete(true); // Clear previous browse flag before starting new browse
+                }
+                currentEntry = list.get(0);
+                String browseTarget = currentEntry.getId();
+                parentMap.put(browseTarget, currentEntry);
+                logger.debug("Server {}, browsing down one level to the unique container result {}", thing.getLabel(),
+                        browseTarget);
+                browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
+            } else {
+                updateTitleSelection(removeDuplicates(list));
+            }
+        } else {
+            updateTitleSelection(new ArrayList<UpnpEntry>());
+        }
+        browseUp = false;
+        if (browsing != null) {
+            browsing.complete(true); // We have received browse or search results, so can launch new browse or
+                                     // search
+        }
+    }
+
+    @Override
+    protected void updateProtocolInfo(String value) {
+    }
+
     /**
      * Remove double entries by checking the refId if it exists as Id in the list and only keeping the original entry if
      * available. If the original entry is not in the list, only keep one referring entry.
@@ -454,13 +709,10 @@ public class UpnpServerHandler extends UpnpHandler {
     private List<UpnpEntry> removeDuplicates(List<UpnpEntry> list) {
         List<UpnpEntry> newList = new ArrayList<>();
         Set<String> refIdSet = new HashSet<>();
-        final Set<String> idSet = list.stream().map(UpnpEntry::getId).collect(Collectors.toSet());
         list.forEach(entry -> {
             String refId = entry.getRefId();
-            if (refId.isEmpty() || (!idSet.contains(refId)) && !refIdSet.contains(refId)) {
+            if (refId.isEmpty() || !refIdSet.contains(refId)) {
                 newList.add(entry);
-            }
-            if (!refId.isEmpty()) {
                 refIdSet.add(refId);
             }
         });
@@ -470,7 +722,7 @@ public class UpnpServerHandler extends UpnpHandler {
     private void serveMedia() {
         UpnpRendererHandler handler = currentRendererHandler;
         if (handler != null) {
-            ArrayList<UpnpEntry> mediaQueue = new ArrayList<>();
+            List<UpnpEntry> mediaQueue = new ArrayList<>();
             mediaQueue.addAll(filterEntries(entries, false));
             if (mediaQueue.isEmpty() && !currentEntry.isContainer()) {
                 mediaQueue.add(currentEntry);
@@ -479,9 +731,14 @@ public class UpnpServerHandler extends UpnpHandler {
                 logger.debug("Nothing to serve from server {} to renderer {}", thing.getLabel(),
                         handler.getThing().getLabel());
             } else {
-                handler.registerQueue(mediaQueue);
+                UpnpEntryQueue queue = new UpnpEntryQueue(mediaQueue, getUDN());
+                handler.registerQueue(queue);
                 logger.debug("Serving media queue {} from server {} to renderer {}", mediaQueue, thing.getLabel(),
                         handler.getThing().getLabel());
+
+                // always keep a copy of current list that is being served
+                queue.persistQueue(bindingConfig.path);
+                UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
             }
         } else {
             logger.warn("Cannot serve media from server {}, no renderer selected", thing.getLabel());
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntry.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntry.java
new file mode 100644 (file)
index 0000000..1884a02
--- /dev/null
@@ -0,0 +1,206 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.queue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ * @author Karel Goderis - Based on UPnP logic in Sonos binding
+ */
+@NonNullByDefault
+public class UpnpEntry {
+
+    private static final String DIRECTORY_ROOT = "0";
+
+    private static final Pattern CONTAINER_PATTERN = Pattern.compile("object.container");
+
+    private String id;
+    private String refId;
+    private String parentId;
+    private String upnpClass;
+    private String title = "";
+    private List<UpnpEntryRes> resList = new ArrayList<>();
+    private String album = "";
+    private String albumArtUri = "";
+    private String creator = "";
+    private String artist = "";
+    private String publisher = "";
+    private String genre = "";
+    private @Nullable Integer originalTrackNumber;
+
+    private boolean isContainer;
+
+    public UpnpEntry() {
+        this("", "", "", "");
+    }
+
+    public UpnpEntry(String id, String refId, String parentId, String upnpClass) {
+        this.id = id;
+        this.refId = refId;
+        this.parentId = parentId;
+        this.upnpClass = upnpClass;
+
+        Matcher matcher = CONTAINER_PATTERN.matcher(upnpClass);
+        isContainer = matcher.find();
+    }
+
+    public UpnpEntry withTitle(String title) {
+        this.title = title;
+        return this;
+    }
+
+    public UpnpEntry withAlbum(String album) {
+        this.album = album;
+        return this;
+    }
+
+    public UpnpEntry withAlbumArtUri(String albumArtUri) {
+        this.albumArtUri = albumArtUri;
+        return this;
+    }
+
+    public UpnpEntry withCreator(String creator) {
+        this.creator = creator;
+        return this;
+    }
+
+    public UpnpEntry withArtist(String artist) {
+        this.artist = artist;
+        return this;
+    }
+
+    public UpnpEntry withPublisher(String publisher) {
+        this.publisher = publisher;
+        return this;
+    }
+
+    public UpnpEntry withGenre(String genre) {
+        this.genre = genre;
+        return this;
+    }
+
+    public UpnpEntry withResList(List<UpnpEntryRes> resList) {
+        this.resList = resList;
+        return this;
+    }
+
+    public UpnpEntry withTrackNumber(@Nullable Integer originalTrackNumber) {
+        this.originalTrackNumber = originalTrackNumber;
+        return this;
+    }
+
+    /**
+     * @return the title of the entry.
+     */
+    @Override
+    public String toString() {
+        return title;
+    }
+
+    /**
+     * @return the unique identifier of this entry.
+     */
+    public String getId() {
+        return id;
+    }
+
+    /**
+     * @return the title of the entry.
+     */
+    public String getTitle() {
+        return title;
+    }
+
+    /**
+     * @return the identifier of the entry this reference intry refers to.
+     */
+    public String getRefId() {
+        return refId;
+    }
+
+    /**
+     * @return the unique identifier of the parent of this entry.
+     */
+    public String getParentId() {
+        return parentId.isEmpty() ? DIRECTORY_ROOT : parentId;
+    }
+
+    /**
+     * @return a URI for this entry. Thumbnail resources are not considered.
+     */
+    public String getRes() {
+        return resList.stream().filter(res -> !res.isThumbnailRes()).map(UpnpEntryRes::getRes).findAny().orElse("");
+    }
+
+    public List<String> getProtocolList() {
+        return resList.stream().map(UpnpEntryRes::getProtocolInfo).collect(Collectors.toList());
+    }
+
+    /**
+     * @return the UPnP classname for this entry.
+     */
+    public String getUpnpClass() {
+        return upnpClass;
+    }
+
+    public boolean isContainer() {
+        return isContainer;
+    }
+
+    /**
+     * @return the name of the album.
+     */
+    public String getAlbum() {
+        return album;
+    }
+
+    /**
+     * @return the URI for the album art.
+     */
+    public String getAlbumArtUri() {
+        return StringEscapeUtils.unescapeXml(albumArtUri);
+    }
+
+    /**
+     * @return the name of the artist who created the entry.
+     */
+    public String getCreator() {
+        return creator;
+    }
+
+    public String getArtist() {
+        return artist;
+    }
+
+    public String getPublisher() {
+        return publisher;
+    }
+
+    public String getGenre() {
+        return genre;
+    }
+
+    public @Nullable Integer getOriginalTrackNumber() {
+        return originalTrackNumber;
+    }
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryQueue.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryQueue.java
new file mode 100644 (file)
index 0000000..9832056
--- /dev/null
@@ -0,0 +1,402 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.queue;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.PLAYLIST_FILE_EXTENSION;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+
+/**
+ * The class {@link UpnpEntryQueue} represents a queue of UPnP media entries to be played on a renderer. It keeps track
+ * of a current index in the queue. It has convenience methods to play previous/next entries, whereby the queue can be
+ * organized to play from first to last (with no repetition), to restart at the start when the end is reached (in a
+ * continuous loop), or to random shuffle the entries. Repeat and shuffle are off by default, but can be set using the
+ * {@link setRepeat} and {@link setShuffle} methods.
+ *
+ * @author Mark Herwege - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class UpnpEntryQueue {
+
+    private final Logger logger = LoggerFactory.getLogger(UpnpEntryQueue.class);
+
+    private volatile boolean repeat = false;
+    private volatile boolean shuffle = false;
+
+    private volatile int currentIndex = -1;
+
+    private class Playlist {
+        @SuppressWarnings("unused")
+        String name; // Used in serialization
+        volatile Map<String, List<UpnpEntry>> masterList;
+
+        Playlist(String name, Map<String, List<UpnpEntry>> masterList) {
+            this.name = name;
+            this.masterList = masterList;
+        }
+    }
+
+    private volatile Playlist playlist;
+
+    private volatile List<UpnpEntry> currentQueue;
+    private volatile List<UpnpEntry> shuffledQueue = Collections.emptyList();
+
+    private final Gson gson = new Gson();
+
+    public UpnpEntryQueue() {
+        this(Collections.emptyList());
+    }
+
+    /**
+     * @param queue
+     */
+    public UpnpEntryQueue(List<UpnpEntry> queue) {
+        this(queue, "");
+    }
+
+    /**
+     * @param queue
+     * @param udn Defines the UPnP media server source of the queue, could be used to re-query the server if URL
+     *            resources are out of date.
+     */
+    public UpnpEntryQueue(List<UpnpEntry> queue, @Nullable String udn) {
+        String serverUdn = (udn != null) ? udn : "";
+        Map<String, List<UpnpEntry>> masterList = Collections.synchronizedMap(new HashMap<>());
+        List<UpnpEntry> localQueue = new ArrayList<>(queue);
+        masterList.put(serverUdn, localQueue);
+        playlist = new Playlist("", masterList);
+        currentQueue = localQueue.stream().filter(e -> !e.isContainer()).collect(Collectors.toList());
+    }
+
+    /**
+     * Switch on/off repeat mode.
+     *
+     * @param repeat
+     */
+    public void setRepeat(boolean repeat) {
+        this.repeat = repeat;
+    }
+
+    /**
+     * Switch on/off shuffle mode.
+     *
+     * @param shuffle
+     */
+    public synchronized void setShuffle(boolean shuffle) {
+        if (shuffle) {
+            shuffle();
+        } else {
+            int index = currentIndex;
+            if (index != -1) {
+                currentIndex = currentQueue.indexOf(shuffledQueue.get(index));
+            }
+            this.shuffle = false;
+        }
+    }
+
+    private synchronized void shuffle() {
+        UpnpEntry current = null;
+        int index = currentIndex;
+        if (index != -1) {
+            current = this.shuffle ? shuffledQueue.get(index) : currentQueue.get(index);
+        }
+
+        // Shuffle the queue again
+        shuffledQueue = new ArrayList<UpnpEntry>(currentQueue);
+        Collections.shuffle(shuffledQueue);
+        if (current != null) {
+            // Put the current entry at the beginning of the shuffled queue
+            shuffledQueue.remove(current);
+            shuffledQueue.add(0, current);
+            currentIndex = 0;
+        }
+
+        this.shuffle = true;
+    }
+
+    /**
+     * @return will return the next element in the queue, or null when the end of the queue has been reached. With
+     *         repeat set, will restart at the beginning of the queue when the end has been reached. The method will
+     *         return null if the queue is empty.
+     */
+    public synchronized @Nullable UpnpEntry next() {
+        currentIndex++;
+        if (currentIndex >= size()) {
+            if (shuffle && repeat) {
+                currentIndex = -1;
+                shuffle();
+            }
+            currentIndex = repeat ? 0 : -1;
+        }
+        return currentIndex >= 0 ? get(currentIndex) : null;
+    }
+
+    /**
+     * @return will return the previous element in the queue, or null when the start of the queue has been reached. With
+     *         repeat set, will restart at the end of the queue when the start has been reached. The method will return
+     *         null if the queue is empty.
+     */
+    public synchronized @Nullable UpnpEntry previous() {
+        currentIndex--;
+        if (currentIndex < 0) {
+            if (shuffle && repeat) {
+                currentIndex = -1;
+                shuffle();
+            }
+            currentIndex = repeat ? (size() - 1) : -1;
+        }
+        return currentIndex >= 0 ? get(currentIndex) : null;
+    }
+
+    /**
+     * @return the index of the current element in the queue.
+     */
+    public int index() {
+        return currentIndex;
+    }
+
+    /**
+     * @return the index of the next element in the queue that will be served if {@link next} is called, or -1 if
+     *         nothing to serve for next.
+     */
+    public synchronized int nextIndex() {
+        int index = currentIndex + 1;
+        if (index >= size()) {
+            index = repeat ? 0 : -1;
+        }
+        return index;
+    }
+
+    /**
+     * @return the index of the previous element in the queue that will be served if {@link previous} is called, or -1
+     *         if nothing to serve for next.
+     */
+    public synchronized int previousIndex() {
+        int index = currentIndex - 1;
+        if (index < 0) {
+            index = repeat ? (size() - 1) : -1;
+        }
+        return index;
+    }
+
+    /**
+     * @return true if there is an element to server when calling {@link next}.
+     */
+    public synchronized boolean hasNext() {
+        int size = currentQueue.size();
+        if (repeat && (size > 0)) {
+            return true;
+        }
+        return (currentIndex < (size - 1));
+    }
+
+    /**
+     * @return true if there is an element to server when calling {@link previous}.
+     */
+    public synchronized boolean hasPrevious() {
+        int size = currentQueue.size();
+        if (repeat && (size > 0)) {
+            return true;
+        }
+        return (currentIndex > 0);
+    }
+
+    /**
+     * @param index
+     * @return the UpnpEntry at the index position in the queue, or null when none can be retrieved.
+     */
+    public @Nullable synchronized UpnpEntry get(int index) {
+        if ((index >= 0) && (index < size())) {
+            if (shuffle) {
+                return shuffledQueue.get(index);
+            } else {
+                return currentQueue.get(index);
+            }
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Reset the queue position to before the start of the queue (-1).
+     */
+    public synchronized void resetIndex() {
+        currentIndex = -1;
+        if (shuffle) {
+            shuffle();
+        }
+    }
+
+    /**
+     * @return number of element in the queue.
+     */
+    public synchronized int size() {
+        return currentQueue.size();
+    }
+
+    /**
+     * @return true if the queue is empty.
+     */
+    public synchronized boolean isEmpty() {
+        return currentQueue.isEmpty();
+    }
+
+    /**
+     * Persist queue as a playlist with name "current"
+     *
+     * @param path of playlist directory
+     */
+    public void persistQueue(String path) {
+        persistQueue("current", false, path);
+    }
+
+    /**
+     * Persist the queue as a playlist.
+     *
+     * @param name of the playlist
+     * @param append to the playlist if it already exists
+     * @param path of playlist directory
+     */
+    public synchronized void persistQueue(String name, boolean append, String path) {
+        String fileName = path + name + PLAYLIST_FILE_EXTENSION;
+        File file = new File(fileName);
+
+        String json;
+
+        try {
+            // ensure full path exists
+            file.getParentFile().mkdirs();
+
+            if (append && file.exists()) {
+                try {
+                    logger.debug("Reading contents of {} for appending", file.getAbsolutePath());
+                    final byte[] contents = Files.readAllBytes(file.toPath());
+                    json = new String(contents, StandardCharsets.UTF_8);
+                    Playlist appendList = gson.fromJson(json, Playlist.class);
+                    if (appendList == null) {
+                        // empty playlist file, so just overwrite
+                        playlist.name = name;
+                        json = gson.toJson(playlist);
+                    } else {
+                        // Merging masterList with persistList, overwriting persistList UpnpEntry objects with same id
+                        playlist.masterList.forEach((u, list) -> appendList.masterList.merge(u, list,
+                                (oldlist,
+                                        newlist) -> new ArrayList<>(Stream.of(oldlist, newlist).flatMap(List::stream)
+                                                .collect(Collectors.toMap(UpnpEntry::getId, entry -> entry,
+                                                        (UpnpEntry oldentry, UpnpEntry newentry) -> newentry))
+                                                .values())));
+
+                        json = gson.toJson(new Playlist(name, appendList.masterList));
+                    }
+                } catch (JsonParseException | UnsupportedOperationException e) {
+                    logger.debug("Could not append, JsonParseException reading {}: {}", file.toPath(), e.getMessage(),
+                            e);
+                    return;
+                } catch (IOException e) {
+                    logger.debug("Could not append, IOException reading playlist {} from {}", name, file.toPath());
+                    return;
+                }
+            } else {
+                playlist.name = name;
+                json = gson.toJson(playlist);
+            }
+
+            final byte[] contents = json.getBytes(StandardCharsets.UTF_8);
+            Files.write(file.toPath(), contents);
+        } catch (IOException e) {
+            logger.debug("IOException writing playlist {} to {}", name, file.toPath());
+        }
+    }
+
+    /**
+     * Replace the current queue with the playlist name and reset the queue index.
+     *
+     * @param name
+     * @param path directory containing playlist to restore
+     */
+    public void restoreQueue(String name, @Nullable String path) {
+        restoreQueue(name, null, path);
+    }
+
+    /**
+     * Replace the current queue with the playlist name and reset the queue index. Filter the content of the playlist on
+     * the server udn.
+     *
+     * @param name
+     * @param udn of the server the playlist entries were created on, all entries when null
+     * @param path of playlist directory
+     */
+    public synchronized void restoreQueue(String name, @Nullable String udn, @Nullable String path) {
+        if (path == null) {
+            return;
+        }
+
+        String fileName = path + name + PLAYLIST_FILE_EXTENSION;
+        File file = new File(fileName);
+
+        if (file.exists()) {
+            try {
+                logger.debug("Reading contents of {}", file.getAbsolutePath());
+                final byte[] contents = Files.readAllBytes(file.toPath());
+                final String json = new String(contents, StandardCharsets.UTF_8);
+
+                Playlist list = gson.fromJson(json, Playlist.class);
+                if (list == null) {
+                    logger.debug("Empty playlist file {}", file.getAbsolutePath());
+                    return;
+                }
+
+                playlist = list;
+
+                Stream<Entry<String, List<UpnpEntry>>> stream = playlist.masterList.entrySet().stream();
+                if (udn != null) {
+                    stream = stream.filter(u -> u.getKey().equals(udn));
+                }
+                currentQueue = stream.map(p -> p.getValue()).flatMap(List::stream).filter(e -> !e.isContainer())
+                        .collect(Collectors.toList());
+                resetIndex();
+            } catch (JsonParseException | UnsupportedOperationException e) {
+                logger.debug("JsonParseException reading {}: {}", file.toPath(), e.getMessage(), e);
+            } catch (IOException e) {
+                logger.debug("IOException reading playlist {} from {}", name, file.toPath());
+            }
+        }
+    }
+
+    /**
+     * @return list of all UpnpEntries in the queue.
+     */
+    public List<UpnpEntry> getEntryList() {
+        return currentQueue;
+    }
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryRes.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpEntryRes.java
new file mode 100644 (file)
index 0000000..845fc6e
--- /dev/null
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.queue;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpEntryRes {
+
+    private String protocolInfo;
+    private @Nullable Long size;
+    private String duration;
+    private String importUri;
+    private String res = "";
+
+    public UpnpEntryRes(String protocolInfo, @Nullable Long size, @Nullable String duration,
+            @Nullable String importUri) {
+        this.protocolInfo = protocolInfo.trim();
+        this.size = size;
+        this.duration = (duration == null) ? "" : duration.trim();
+        this.importUri = (importUri == null) ? "" : importUri.trim();
+    }
+
+    /**
+     * @return the res
+     */
+    public String getRes() {
+        return res;
+    }
+
+    /**
+     * @param res the res to set
+     */
+    public void setRes(String res) {
+        this.res = res.trim();
+    }
+
+    public String getProtocolInfo() {
+        return protocolInfo;
+    }
+
+    /**
+     * @return the size
+     */
+    public @Nullable Long getSize() {
+        return size;
+    }
+
+    /**
+     * @return the duration
+     */
+    public String getDuration() {
+        return duration;
+    }
+
+    /**
+     * @return the importUri
+     */
+    public String getImportUri() {
+        return importUri;
+    }
+
+    /**
+     * @return true if this resource defines a thumbnail as specified in the DLNA specs
+     */
+    public boolean isThumbnailRes() {
+        return getProtocolInfo().toLowerCase().contains("dlna.org_pn=jpeg_tn");
+    }
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpFavorite.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpFavorite.java
new file mode 100644 (file)
index 0000000..96e5961
--- /dev/null
@@ -0,0 +1,150 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.queue;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.FAVORITE_FILE_EXTENSION;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+
+/**
+ * Class used to model favorites, with and without full meta data. If metadata exists, it will be in UpnpEntry.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpFavorite {
+
+    private final Logger logger = LoggerFactory.getLogger(UpnpFavorite.class);
+
+    /**
+     * Inner class used for streaming a favorite to disk as a json object.
+     *
+     */
+    private class Favorite {
+        String name;
+        String uri;
+        @Nullable
+        UpnpEntry entry;
+
+        Favorite(String name, String uri, @Nullable UpnpEntry entry) {
+            this.name = name;
+            this.uri = uri;
+            this.entry = entry;
+        }
+    }
+
+    private volatile Favorite favorite;
+
+    private final Gson gson = new Gson();
+
+    /**
+     * Create a new favorite from provide URI and details. If {@link UpnpEntry} entry is null, no metadata will be
+     * available with the favorite.
+     *
+     * @param name
+     * @param uri
+     * @param entry
+     */
+    public UpnpFavorite(String name, String uri, @Nullable UpnpEntry entry) {
+        favorite = new Favorite(name, uri, entry);
+    }
+
+    /**
+     * Create a new favorite from a file copy stored on disk. If the favorite cannot be read from disk, an empty
+     * favorite is created.
+     *
+     * @param name
+     * @param path
+     */
+    public UpnpFavorite(String name, @Nullable String path) {
+        String fileName = path + name + FAVORITE_FILE_EXTENSION;
+        File file = new File(fileName);
+
+        Favorite fav = null;
+
+        if ((path != null) && file.exists()) {
+            try {
+                logger.debug("Reading contents of {}", file.getAbsolutePath());
+                final byte[] contents = Files.readAllBytes(file.toPath());
+                final String json = new String(contents, StandardCharsets.UTF_8);
+
+                fav = gson.fromJson(json, Favorite.class);
+            } catch (JsonParseException | UnsupportedOperationException e) {
+                logger.debug("JsonParseException reading {}: {}", file.toPath(), e.getMessage(), e);
+            } catch (IOException e) {
+                logger.debug("IOException reading favorite {} from {}", name, file.toPath());
+            }
+        }
+
+        favorite = (fav != null) ? fav : new Favorite(name, "", null);
+    }
+
+    /**
+     * @return name of favorite
+     */
+    public String getName() {
+        return favorite.name;
+    }
+
+    /**
+     * @return URI of favorite
+     */
+    public String getUri() {
+        return favorite.uri;
+    }
+
+    /**
+     * @return {@link UpnpEntry} known details of favorite
+     */
+    @Nullable
+    public UpnpEntry getUpnpEntry() {
+        return favorite.entry;
+    }
+
+    /**
+     * Save the favorite to disk.
+     *
+     * @param name
+     * @param path
+     */
+    public void saveFavorite(String name, @Nullable String path) {
+        if (path == null) {
+            return;
+        }
+
+        String fileName = path + name + FAVORITE_FILE_EXTENSION;
+        File file = new File(fileName);
+
+        try {
+            // ensure full path exists
+            file.getParentFile().mkdirs();
+
+            String json = gson.toJson(favorite);
+            final byte[] contents = json.getBytes(StandardCharsets.UTF_8);
+            Files.write(file.toPath(), contents);
+        } catch (IOException e) {
+            logger.debug("IOException writing favorite {} to {}", name, file.toPath());
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpPlaylistsListener.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/queue/UpnpPlaylistsListener.java
new file mode 100644 (file)
index 0000000..705ea6a
--- /dev/null
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.queue;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Interface for updating playlists list in multiple handlers.
+ *
+ * @author Mark Herwege - Initial contribution
+ *
+ */
+@NonNullByDefault
+public interface UpnpPlaylistsListener {
+
+    public void playlistsListChanged();
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/services/UpnpRenderingControlConfiguration.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/services/UpnpRenderingControlConfiguration.java
new file mode 100644 (file)
index 0000000..55e5b26
--- /dev/null
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.services;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.jupnp.model.meta.RemoteDevice;
+import org.jupnp.model.meta.RemoteService;
+import org.jupnp.model.types.ServiceId;
+
+/**
+ * Class representing the configuration of the renderer. Instantiation will get configuration parameters from UPnP
+ * {@link RemoteDevice}.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class UpnpRenderingControlConfiguration {
+    protected static final String UPNP_RENDERING_CONTROL_SCHEMA = "urn:schemas-upnp-org:service:RenderingControl";
+
+    public Set<String> audioChannels = Collections.emptySet();
+
+    public boolean volume;
+    public boolean mute;
+    public boolean loudness;
+
+    public long maxvolume = 100;
+
+    public UpnpRenderingControlConfiguration() {
+    }
+
+    public UpnpRenderingControlConfiguration(@Nullable RemoteDevice device) {
+        if (device == null) {
+            return;
+        }
+
+        RemoteService rcService = device.findService(ServiceId.valueOf(UPNP_RENDERING_CONTROL_SCHEMA));
+        if (rcService != null) {
+            volume = (rcService.getStateVariable("Volume") != null);
+            if (volume) {
+                maxvolume = rcService.getStateVariable("Volume").getTypeDetails().getAllowedValueRange().getMaximum();
+            }
+            mute = (rcService.getStateVariable("Mute") != null);
+            loudness = (rcService.getStateVariable("Loudness") != null);
+            if (rcService.getStateVariable("A_ARG_TYPE_Channel") != null) {
+                audioChannels = new HashSet<String>(Arrays
+                        .asList(rcService.getStateVariable("A_ARG_TYPE_Channel").getTypeDetails().getAllowedValues()));
+            }
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpControlUtil.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpControlUtil.java
new file mode 100644 (file)
index 0000000..d49a700
--- /dev/null
@@ -0,0 +1,129 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.util;
+
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpPlaylistsListener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class with some static utility methods for the upnpcontrol binding.
+ *
+ * @author Mark Herwege - Initial contribution
+ *
+ */
+@NonNullByDefault
+public final class UpnpControlUtil {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(UpnpControlUtil.class);
+
+    private static volatile List<String> playlistList = new ArrayList<>();
+    private static final Set<UpnpPlaylistsListener> PLAYLIST_SUBSCRIPTIONS = new CopyOnWriteArraySet<>();
+
+    public static void updatePlaylistsList(@Nullable String path) {
+        playlistList = list(path, PLAYLIST_FILE_EXTENSION);
+        PLAYLIST_SUBSCRIPTIONS.forEach(UpnpPlaylistsListener::playlistsListChanged);
+    }
+
+    public static void playlistsSubscribe(UpnpPlaylistsListener listener) {
+        PLAYLIST_SUBSCRIPTIONS.add(listener);
+    }
+
+    public static void playlistsUnsubscribe(UpnpPlaylistsListener listener) {
+        PLAYLIST_SUBSCRIPTIONS.remove(listener);
+    }
+
+    public static void bindingConfigurationChanged(@Nullable String path) {
+        updatePlaylistsList(path);
+    }
+
+    /**
+     * Get names of saved playlists.
+     *
+     * @return playlists
+     */
+    public static List<String> playlists() {
+        return playlistList;
+    }
+
+    /**
+     * Delete a saved playlist.
+     *
+     * @param name of playlist to delete
+     * @param path of playlist directory
+     */
+    public static void deletePlaylist(String name, @Nullable String path) {
+        delete(name, path, PLAYLIST_FILE_EXTENSION);
+    }
+
+    /**
+     * Get names of saved favorites.
+     *
+     * @param path of favorite directory
+     * @return favorites
+     */
+    public static List<String> favorites(@Nullable String path) {
+        return list(path, FAVORITE_FILE_EXTENSION);
+    }
+
+    /**
+     * Delete a saved favorite.
+     *
+     * @param name of favorite to delete
+     * @param path of favorite directory
+     */
+    public static void deleteFavorite(String name, @Nullable String path) {
+        delete(name, path, FAVORITE_FILE_EXTENSION);
+    }
+
+    private static List<String> list(@Nullable String path, String extension) {
+        if (path == null) {
+            LOGGER.debug("No path set for {} files", extension);
+            return Collections.emptyList();
+        }
+
+        File directory = new File(path);
+        File[] files = directory.listFiles((dir, name) -> name.toLowerCase().endsWith(extension));
+
+        if (files == null) {
+            LOGGER.debug("No {} files in {}", extension, path);
+            return Collections.emptyList();
+        }
+
+        List<String> result = (Arrays.asList(files)).stream().map(p -> p.getName().replace(extension, ""))
+                .collect(Collectors.toList());
+        return result;
+    }
+
+    private static void delete(String name, @Nullable String path, String extension) {
+        if (path == null) {
+            return;
+        }
+
+        File file = new File(path + name + extension);
+        file.delete();
+    }
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpProtocolMatcher.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpProtocolMatcher.java
new file mode 100644 (file)
index 0000000..2622975
--- /dev/null
@@ -0,0 +1,119 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.util;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public final class UpnpProtocolMatcher {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(UpnpProtocolMatcher.class);
+
+    private UpnpProtocolMatcher() {
+    }
+
+    /**
+     * Test if an UPnP protocol matches the object class. This method is used to filter resources for the primary
+     * resource.
+     *
+     * @param protocol format: <protocol>:<network>:<contentFormat>:<additionalInfo>
+     *            e.g. http-get:*:audio/mpeg:*
+     * @param objectClass e.g. object.item.audioItem.musicTrack
+     * @return true if protocol matches objectClass
+     */
+    public static boolean testProtocol(String protocol, String objectClass) {
+        String[] protocolDetails = protocol.split(":");
+        if (protocolDetails.length < 3) {
+            LOGGER.debug("Protocol string {} not valid", protocol);
+            return false;
+        }
+        String protocolType = protocolDetails[2].toLowerCase();
+        int index = protocolType.indexOf("/");
+        if (index <= 0) {
+            LOGGER.debug("Protocol string {} not valid", protocol);
+            return false;
+        }
+        protocolType = protocolType.substring(0, index);
+
+        String[] objectClassDetails = objectClass.split("\\.");
+        if (objectClassDetails.length < 3) {
+            LOGGER.debug("Object class {} not valid", objectClass);
+            return false;
+        }
+        String objectType = objectClassDetails[2].toLowerCase();
+
+        LOGGER.debug("Matching protocol type '{}' with object type '{}'", protocolType, objectType);
+        return objectType.startsWith(protocolType);
+    }
+
+    /**
+     * Test if a UPnP protocol is in a set of protocols.
+     * Ignore vendor specific additionalInfo part in UPnP protocol string.
+     * Do all comparisons in lower case.
+     *
+     * @param protocol format: <protocol>:<network>:<contentFormat>:<additionalInfo>
+     * @param protocolSet
+     * @return true if protocol in protocolSet
+     */
+    public static boolean testProtocol(String protocol, List<String> protocolSet) {
+        int index = protocol.lastIndexOf(":");
+        if (index <= 0) {
+            LOGGER.debug("Protocol {} not valid", protocol);
+            return false;
+        }
+        String p = protocol.toLowerCase().substring(0, index);
+        List<String> pSet = new ArrayList<>();
+        protocolSet.forEach(f -> {
+            int i = f.lastIndexOf(":");
+            if (i <= 0) {
+                LOGGER.debug("Protocol {} from set not valid", f);
+            } else {
+                pSet.add(f.toLowerCase().substring(0, i));
+            }
+        });
+        LOGGER.trace("Testing {} in {}", p, pSet);
+        return pSet.contains(p);
+    }
+
+    /**
+     * Test if any of the UPnP protocols in protocolList can be found in a set of protocols.
+     *
+     * @param protocolList
+     * @param protocolSet
+     * @return true if one of the protocols in protocolSet
+     */
+    public static boolean testProtocolList(List<String> protocolList, List<String> protocolSet) {
+        return protocolList.stream().anyMatch(p -> testProtocol(p, protocolSet));
+    }
+
+    /**
+     * Return all UPnP protocols from protocolList that are part of a set of protocols.
+     *
+     * @param protocolList
+     * @param protocolSet
+     * @return sublist of protocolList
+     */
+    public static List<String> getProtocols(List<String> protocolList, List<String> protocolSet) {
+        return protocolList.stream().filter(p -> testProtocol(p, protocolSet)).collect(Collectors.toList());
+    }
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpXMLParser.java b/bundles/org.openhab.binding.upnpcontrol/src/main/java/org/openhab/binding/upnpcontrol/internal/util/UpnpXMLParser.java
new file mode 100644 (file)
index 0000000..2691c1d
--- /dev/null
@@ -0,0 +1,416 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.util;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryRes;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.xml.sax.Attributes;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+/**
+ *
+ * @author Mark Herwege - Initial contribution
+ * @author Karel Goderis - Based on UPnP logic in Sonos binding
+ */
+@NonNullByDefault
+public class UpnpXMLParser {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(UpnpXMLParser.class);
+
+    private static final String METADATA_PATTERN = "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" "
+            + "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" "
+            + "xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">"
+            + "<item id=\"{0}\" parentID=\"{1}\" restricted=\"true\"><dc:title>{2}</dc:title>"
+            + "<upnp:class>{3}</upnp:class><upnp:album>{4}</upnp:album>"
+            + "<upnp:albumArtURI>{5}</upnp:albumArtURI><dc:creator>{6}</dc:creator>"
+            + "<upnp:artist>{7}</upnp:artist><dc:publisher>{8}</dc:publisher>"
+            + "<upnp:genre>{9}</upnp:genre><upnp:originalTrackNumber>{10}</upnp:originalTrackNumber>"
+            + "</item></DIDL-Lite>";
+
+    private enum Element {
+        TITLE,
+        CLASS,
+        ALBUM,
+        ALBUM_ART_URI,
+        CREATOR,
+        ARTIST,
+        PUBLISHER,
+        GENRE,
+        TRACK_NUMBER,
+        RES
+    }
+
+    public static Map<String, @Nullable String> getRenderingControlFromXML(String xml) {
+        if (xml.isEmpty()) {
+            LOGGER.debug("Could not parse Rendering Control from empty xml");
+            return Collections.emptyMap();
+        }
+        RenderingControlEventHandler handler = new RenderingControlEventHandler();
+        try {
+            SAXParserFactory factory = SAXParserFactory.newInstance();
+            SAXParser saxParser = factory.newSAXParser();
+            saxParser.parse(new InputSource(new StringReader(xml)), handler);
+        } catch (IOException e) {
+            // This should never happen - we're not performing I/O!
+            LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
+        } catch (SAXException | ParserConfigurationException s) {
+            LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
+        }
+        return handler.getChanges();
+    }
+
+    private static class RenderingControlEventHandler extends DefaultHandler {
+
+        private final Map<String, @Nullable String> changes = new HashMap<>();
+
+        RenderingControlEventHandler() {
+            // shouldn't be used outside of this package.
+        }
+
+        @Override
+        public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
+                @Nullable Attributes attributes) throws SAXException {
+            if (qName == null) {
+                return;
+            }
+            switch (qName) {
+                case "Volume":
+                case "Mute":
+                case "Loudness":
+                    String channel = attributes == null ? null : attributes.getValue("channel");
+                    String val = attributes == null ? null : attributes.getValue("val");
+                    if (channel != null && val != null) {
+                        changes.put(channel + qName, val);
+                    }
+                    break;
+                default:
+                    if ((attributes != null) && (attributes.getValue("val") != null)) {
+                        changes.put(qName, attributes.getValue("val"));
+                    }
+                    break;
+            }
+        }
+
+        public Map<String, @Nullable String> getChanges() {
+            return changes;
+        }
+    }
+
+    public static Map<String, String> getAVTransportFromXML(String xml) {
+        if (xml.isEmpty()) {
+            LOGGER.debug("Could not parse AV Transport from empty xml");
+            return Collections.emptyMap();
+        }
+        AVTransportEventHandler handler = new AVTransportEventHandler();
+        try {
+            SAXParserFactory factory = SAXParserFactory.newInstance();
+            SAXParser saxParser = factory.newSAXParser();
+            saxParser.parse(new InputSource(new StringReader(xml)), handler);
+        } catch (IOException e) {
+            // This should never happen - we're not performing I/O!
+            LOGGER.error("Could not parse AV Transport from string '{}'", xml, e);
+        } catch (SAXException | ParserConfigurationException s) {
+            LOGGER.debug("Could not parse AV Transport from string '{}'", xml, s);
+        }
+        return handler.getChanges();
+    }
+
+    private static class AVTransportEventHandler extends DefaultHandler {
+
+        private final Map<String, String> changes = new HashMap<String, String>();
+
+        AVTransportEventHandler() {
+            // shouldn't be used outside of this package.
+        }
+
+        @Override
+        public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
+                @Nullable Attributes attributes) throws SAXException {
+            /*
+             * The events are all of the form <qName val="value"/> so we can get all
+             * the info we need from here.
+             */
+            if ((qName != null) && (attributes != null) && (attributes.getValue("val") != null)) {
+                changes.put(qName, attributes.getValue("val"));
+            }
+        }
+
+        public Map<String, String> getChanges() {
+            return changes;
+        }
+    }
+
+    public static List<UpnpEntry> getEntriesFromXML(String xml) {
+        if (xml.isEmpty()) {
+            LOGGER.debug("Could not parse Entries from empty xml");
+            return Collections.emptyList();
+        }
+        EntryHandler handler = new EntryHandler();
+        try {
+            SAXParserFactory factory = SAXParserFactory.newInstance();
+            SAXParser saxParser = factory.newSAXParser();
+            saxParser.parse(new InputSource(new StringReader(xml)), handler);
+        } catch (IOException e) {
+            // This should never happen - we're not performing I/O!
+            LOGGER.error("Could not parse Entries from string '{}'", xml, e);
+        } catch (SAXException | ParserConfigurationException s) {
+            LOGGER.debug("Could not parse Entries from string '{}'", xml, s);
+        }
+        return handler.getEntries();
+    }
+
+    private static class EntryHandler extends DefaultHandler {
+
+        // Maintain a set of elements it is not useful to complain about.
+        // This list will be initialized on the first failure case.
+        private static List<String> ignore = new ArrayList<String>();
+
+        private String id = "";
+        private String refId = "";
+        private String parentId = "";
+        private StringBuilder upnpClass = new StringBuilder();
+        private List<UpnpEntryRes> resList = new ArrayList<>();
+        private StringBuilder res = new StringBuilder();
+        private StringBuilder title = new StringBuilder();
+        private StringBuilder album = new StringBuilder();
+        private StringBuilder albumArtUri = new StringBuilder();
+        private StringBuilder creator = new StringBuilder();
+        private StringBuilder artist = new StringBuilder();
+        private List<String> artistList = new ArrayList<>();
+        private StringBuilder publisher = new StringBuilder();
+        private StringBuilder genre = new StringBuilder();
+        private StringBuilder trackNumber = new StringBuilder();
+        private @Nullable Element element = null;
+
+        private List<UpnpEntry> entries = new ArrayList<>();
+
+        EntryHandler() {
+            // shouldn't be used outside of this package.
+        }
+
+        @Override
+        public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
+                @Nullable Attributes attributes) throws SAXException {
+            if (qName == null) {
+                element = null;
+                return;
+            }
+            switch (qName) {
+                case "container":
+                case "item":
+                    if (attributes != null) {
+                        if (attributes.getValue("id") != null) {
+                            id = attributes.getValue("id");
+                        }
+                        if (attributes.getValue("refID") != null) {
+                            refId = attributes.getValue("refID");
+                        }
+                        if (attributes.getValue("parentID") != null) {
+                            parentId = attributes.getValue("parentID");
+                        }
+                    }
+                    break;
+                case "res":
+                    if (attributes != null) {
+                        String protocolInfo = attributes.getValue("protocolInfo");
+                        Long size;
+                        try {
+                            size = Long.parseLong(attributes.getValue("size"));
+                        } catch (NumberFormatException e) {
+                            size = null;
+                        }
+                        String duration = attributes.getValue("duration");
+                        String importUri = attributes.getValue("importUri");
+                        resList.add(0, new UpnpEntryRes(protocolInfo, size, duration, importUri));
+                        element = Element.RES;
+                    }
+                    break;
+                case "dc:title":
+                    element = Element.TITLE;
+                    break;
+                case "upnp:class":
+                    element = Element.CLASS;
+                    break;
+                case "dc:creator":
+                    element = Element.CREATOR;
+                    break;
+                case "upnp:artist":
+                    element = Element.ARTIST;
+                    break;
+                case "dc:publisher":
+                    element = Element.PUBLISHER;
+                    break;
+                case "upnp:genre":
+                    element = Element.GENRE;
+                    break;
+                case "upnp:album":
+                    element = Element.ALBUM;
+                    break;
+                case "upnp:albumArtURI":
+                    element = Element.ALBUM_ART_URI;
+                    break;
+                case "upnp:originalTrackNumber":
+                    element = Element.TRACK_NUMBER;
+                    break;
+                default:
+                    if (ignore.isEmpty()) {
+                        ignore.add("");
+                        ignore.add("DIDL-Lite");
+                        ignore.add("type");
+                        ignore.add("ordinal");
+                        ignore.add("description");
+                        ignore.add("writeStatus");
+                        ignore.add("storageUsed");
+                        ignore.add("supported");
+                        ignore.add("pushSource");
+                        ignore.add("icon");
+                        ignore.add("playlist");
+                        ignore.add("date");
+                        ignore.add("rating");
+                        ignore.add("userrating");
+                        ignore.add("episodeSeason");
+                        ignore.add("childCountContainer");
+                        ignore.add("modificationTime");
+                        ignore.add("containerContent");
+                    }
+                    if (!ignore.contains(localName)) {
+                        LOGGER.debug("Did not recognise element named {}", localName);
+                    }
+                    element = null;
+            }
+        }
+
+        @Override
+        public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
+            Element el = element;
+            if (el == null || ch == null) {
+                return;
+            }
+            switch (el) {
+                case TITLE:
+                    title.append(ch, start, length);
+                    break;
+                case CLASS:
+                    upnpClass.append(ch, start, length);
+                    break;
+                case RES:
+                    res.append(ch, start, length);
+                    break;
+                case ALBUM:
+                    album.append(ch, start, length);
+                    break;
+                case ALBUM_ART_URI:
+                    albumArtUri.append(ch, start, length);
+                    break;
+                case CREATOR:
+                    creator.append(ch, start, length);
+                    break;
+                case ARTIST:
+                    artist.append(ch, start, length);
+                    break;
+                case PUBLISHER:
+                    publisher.append(ch, start, length);
+                    break;
+                case GENRE:
+                    genre.append(ch, start, length);
+                    break;
+                case TRACK_NUMBER:
+                    trackNumber.append(ch, start, length);
+                    break;
+            }
+        }
+
+        @Override
+        public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
+                throws SAXException {
+            if ("container".equals(qName) || "item".equals(qName)) {
+                element = null;
+
+                Integer trackNumberVal;
+                try {
+                    trackNumberVal = Integer.parseInt(trackNumber.toString());
+                } catch (NumberFormatException e) {
+                    trackNumberVal = null;
+                }
+
+                entries.add(new UpnpEntry(id, refId, parentId, upnpClass.toString()).withTitle(title.toString())
+                        .withAlbum(album.toString()).withAlbumArtUri(albumArtUri.toString())
+                        .withCreator(creator.toString())
+                        .withArtist(artistList.size() > 0 ? artistList.get(0) : artist.toString())
+                        .withPublisher(publisher.toString()).withGenre(genre.toString()).withTrackNumber(trackNumberVal)
+                        .withResList(resList));
+
+                title = new StringBuilder();
+                upnpClass = new StringBuilder();
+                resList = new ArrayList<>();
+                album = new StringBuilder();
+                albumArtUri = new StringBuilder();
+                creator = new StringBuilder();
+                artistList = new ArrayList<>();
+                publisher = new StringBuilder();
+                genre = new StringBuilder();
+                trackNumber = new StringBuilder();
+            } else if ("res".equals(qName)) {
+                resList.get(0).setRes(res.toString());
+                res = new StringBuilder();
+            } else if ("upnp:artist".equals(qName)) {
+                artistList.add(artist.toString());
+                artist = new StringBuilder();
+            }
+        }
+
+        public List<UpnpEntry> getEntries() {
+            return entries;
+        }
+    }
+
+    public static String compileMetadataString(UpnpEntry entry) {
+        String id = entry.getId();
+        String parentId = entry.getParentId();
+        String title = StringEscapeUtils.escapeXml(entry.getTitle());
+        String upnpClass = entry.getUpnpClass();
+        String album = StringEscapeUtils.escapeXml(entry.getAlbum());
+        String albumArtUri = entry.getAlbumArtUri();
+        String creator = StringEscapeUtils.escapeXml(entry.getCreator());
+        String artist = StringEscapeUtils.escapeXml(entry.getArtist());
+        String publisher = StringEscapeUtils.escapeXml(entry.getPublisher());
+        String genre = StringEscapeUtils.escapeXml(entry.getGenre());
+        Integer trackNumber = entry.getOriginalTrackNumber();
+
+        final MessageFormat messageFormat = new MessageFormat(METADATA_PATTERN);
+        String metadata = messageFormat.format(new Object[] { id, parentId, title, upnpClass, album, albumArtUri,
+                creator, artist, publisher, genre, trackNumber });
+
+        return metadata;
+    }
+}
index 0e0ff504260a424833a9c81512c5e5c9148830f4..dba295071bfc33f2e786d04267517ef8dedffd34 100644 (file)
@@ -6,5 +6,11 @@
        <name>UPnP Control Binding</name>
        <description>This binding acts as a UPnP Control Point that can query media server content directories and serve
                content to media renderers.</description>
-
+       <config-description>
+               <parameter name="path" type="text">
+                       <label>Storage Path</label>
+                       <description>Folder path for playlists and favourites. If not set, it will default to $OPENHAB_USERDATA/upnpcontrol.
+                               The folder will be created on first use when it does not exist.</description>
+               </parameter>
+       </config-description>
 </binding:binding>
index 821d6f642133923e5b9f52cd0fcfe45a0d368389..85af5763d0145199538fea969426513eb2df14ce 100644 (file)
                <channels>
                        <channel id="volume" typeId="system.volume"/>
                        <channel id="mute" typeId="system.mute"/>
+
                        <channel id="control" typeId="system.media-control"/>
                        <channel id="stop" typeId="stop"/>
+
+                       <channel id="repeat" typeId="repeat"/>
+                       <channel id="shuffle" typeId="shuffle"/>
+                       <channel id="onlyplayone" typeId="onlyplayone"/>
+
+                       <channel id="uri" typeId="uri"/>
+                       <channel id="favoriteselect" typeId="favoriteselect"/>
+                       <channel id="favorite" typeId="favorite"/>
+                       <channel id="favoriteaction" typeId="favoriteaction"/>
+
+                       <channel id="playlistselect" typeId="playlistselect"/>
+
                        <channel id="title" typeId="system.media-title"/>
                        <channel id="album" typeId="album"/>
                        <channel id="albumart" typeId="albumart"/>
                        <channel id="tracknumber" typeId="tracknumber"/>
                        <channel id="trackduration" typeId="trackduration"/>
                        <channel id="trackposition" typeId="trackposition"/>
+                       <channel id="reltrackposition" typeId="reltrackposition"/>
                </channels>
+               <representation-property>udn</representation-property>
                <config-description>
                        <parameter name="udn" type="text" required="true">
                                <label>Unique Device Name</label>
                                <description>The UDN identifies the UPnP Renderer</description>
                        </parameter>
+                       <parameter name="refresh" type="integer" unit="s">
+                               <label>Refresh Interval</label>
+                               <description>Specifies the refresh interval in seconds</description>
+                               <default>60</default>
+                       </parameter>
+                       <parameter name="notificationVolumeAdjustment" type="integer" min="-100" max="100" step="1" unit="%">
+                               <label>Notification Sound Volume Adjustment</label>
+                               <description>Specifies the percentage adjustment to the current sound volume when playing notifications</description>
+                               <default>10</default>
+                       </parameter>
+                       <parameter name="maxNotificationDuration" type="integer" unit="s">
+                               <label>Maximum Notification Duration</label>
+                               <description>Specifies the maximum duration for notifications, longer notification sounds will be interrupted. O
+                                       represents no maximum duration</description>
+                               <default>15</default>
+                       </parameter>
+                       <parameter name="seekStep" type="integer" min="1">
+                               <label>Fast Forward/Rewind Step</label>
+                               <description>Step in seconds for fast forward rewind</description>
+                               <default>5</default>
+                       </parameter>
+                       <parameter name="responseTimeout" type="integer" unit="ms">
+                               <label>UPnP Response Timeout</label>
+                               <description>Specifies the timeout in milliseconds when waiting for responses on UPnP actions</description>
+                               <default>2500</default>
+                               <advanced>true</advanced>
+                       </parameter>
                </config-description>
-
        </thing-type>
+
        <thing-type id="upnpserver">
                <label>UPnPServer</label>
                <description>UPnP AV Server</description>
                <channels>
                        <channel id="upnprenderer" typeId="upnprenderer"/>
-                       <channel id="currentid" typeId="currentid"/>
+                       <channel id="currenttitle" typeId="system.media-title"/>
                        <channel id="browse" typeId="browse"/>
                        <channel id="search" typeId="search"/>
+
+                       <channel id="playlistselect" typeId="playlistselect"/>
+                       <channel id="playlist" typeId="playlist"/>
+                       <channel id="playlistaction" typeId="playlistaction"/>
+
+                       <channel id="volume" typeId="system.volume"/>
+                       <channel id="mute" typeId="system.mute"/>
+                       <channel id="control" typeId="system.media-control"/>
+                       <channel id="stop" typeId="stop"/>
+
                </channels>
+               <representation-property>udn</representation-property>
                <config-description>
                        <parameter name="udn" type="text" required="true">
                                <label>Unique Device Name</label>
                                <description>The UDN identifies the UPnP Media Server</description>
                        </parameter>
-                       <parameter name="filter" type="boolean" required="false">
+                       <parameter name="refresh" type="integer" unit="s">
+                               <label>Refresh Interval</label>
+                               <description>Specifies the refresh interval in seconds</description>
+                               <default>60</default>
+                       </parameter>
+                       <parameter name="filter" type="boolean">
                                <label>Filter Content</label>
                                <description>Only list content which is playable on the selected renderer</description>
                                <default>false</default>
-                               <advanced>false</advanced>
                        </parameter>
-                       <parameter name="sortcriteria" type="text" required="false">
+                       <parameter name="sortCriteria" type="text">
                                <label>Sort Criteria</label>
                                <description>Sort criteria for the titles in the selection list and when sending for playing to a renderer. The
                                        criteria are defined in UPnP sort criteria format. Examples: +dc:title, -dc:creator, +upnp:album. Supported sort
                                        criteria will depend on the media server</description>
                                <default>+dc:title</default>
                        </parameter>
+                       <parameter name="browseDown" type="boolean">
+                               <label>Auto Browse Down</label>
+                               <description>When browse or search results in exactly one container entry, iteratively browse down until the
+                                       result
+                                       contains multiple container entries or at least one media entry</description>
+                               <default>true</default>
+                       </parameter>
+                       <parameter name="searchFromRoot" type="boolean">
+                               <label>Search From Root</label>
+                               <description>Always search from the root directory</description>
+                               <default>false</default>
+                       </parameter>
+                       <parameter name="responseTimeout" type="integer" unit="ms">
+                               <label>UPnP Response Timeout</label>
+                               <description>Specifies the timeout in milliseconds when waiting for responses on UPnP actions</description>
+                               <default>2500</default>
+                               <advanced>true</advanced>
+                       </parameter>
                </config-description>
        </thing-type>
 
        <!-- Channel Types -->
+       <channel-type id="loudness">
+               <item-type>Switch</item-type>
+               <label>Loudness</label>
+               <description>Loudness</description>
+               <category>SoundVolume</category>
+       </channel-type>
        <channel-type id="stop">
                <item-type>Switch</item-type>
                <label>Stop</label>
                <description>Stop the player</description>
                <autoUpdatePolicy>veto</autoUpdatePolicy>
        </channel-type>
+       <channel-type id="repeat">
+               <item-type>Switch</item-type>
+               <label>Repeat</label>
+               <description>Repeat the selection</description>
+       </channel-type>
+       <channel-type id="shuffle">
+               <item-type>Switch</item-type>
+               <label>Shuffle</label>
+               <description>Random shuffle the selection</description>
+       </channel-type>
+       <channel-type id="onlyplayone">
+               <item-type>Switch</item-type>
+               <label>Only Play One</label>
+               <description>Stop playback after playing one media entry from queue</description>
+       </channel-type>
+       <channel-type id="uri">
+               <item-type>String</item-type>
+               <label>URI</label>
+               <description>Now playing URI</description>
+       </channel-type>
+       <channel-type id="favoriteselect">
+               <item-type>String</item-type>
+               <label>Select Favorite</label>
+               <description>Select favorite to play</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy>
+       </channel-type>
+       <channel-type id="favorite">
+               <item-type>String</item-type>
+               <label>Favorite</label>
+               <description>Favorite name</description>
+       </channel-type>
+       <channel-type id="favoriteaction">
+               <item-type>String</item-type>
+               <label>Favorite Action</label>
+               <description>Favorite action</description>
+               <command>
+                       <options>
+                               <option value="SAVE">Save</option>
+                               <option value="DELETE">Delete</option>
+                       </options>
+               </command>
+               <autoUpdatePolicy>veto</autoUpdatePolicy>
+       </channel-type>
        <channel-type id="album">
                <item-type>String</item-type>
                <label>Album</label>
                <item-type>Number:Time</item-type>
                <label>Track Position</label>
                <description>Now playing track position</description>
-               <state readOnly="true" pattern="%d %unit%"/>
+               <state pattern="%d %unit%"/>
+       </channel-type>
+       <channel-type id="reltrackposition">
+               <item-type>Dimmer</item-type>
+               <label>Relative Track Position</label>
+               <description>Track position as percentage of track duration</description>
+               <category>MediaControl</category>
        </channel-type>
 
        <channel-type id="upnprenderer">
                <label>Renderer</label>
                <description>Select AV renderer</description>
        </channel-type>
-       <channel-type id="currentid">
-               <item-type>String</item-type>
-               <label>Current Media Id</label>
-               <description>Current id of media entry or container</description>
-       </channel-type>
        <channel-type id="browse">
                <item-type>String</item-type>
-               <label>Browse Selection</label>
-               <description>Browse selection for playing</description>
+               <label>Current Media Id</label>
+               <description>Current id of media entry or container, option list to browse hierarchy</description>
        </channel-type>
        <channel-type id="search">
                <item-type>String</item-type>
                        Examples: dc:title contains "song", dc:creator contains "SpringSteen", unp:class = "object.item.audioItem",
                        upnp:album contains "Born in"</description>
        </channel-type>
+       <channel-type id="playlistselect">
+               <item-type>String</item-type>
+               <label>Select Playlist</label>
+               <description>Playlist for selection</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy>
+       </channel-type>
+       <channel-type id="playlist">
+               <item-type>String</item-type>
+               <label>Playlist</label>
+               <description>Playlist name</description>
+       </channel-type>
+       <channel-type id="playlistaction">
+               <item-type>String</item-type>
+               <label>Playlist Action</label>
+               <description>Playlist action</description>
+               <command>
+                       <options>
+                               <option value="RESTORE">Restore</option>
+                               <option value="SAVE">Save</option>
+                               <option value="APPEND">Append</option>
+                               <option value="DELETE">Delete</option>
+                       </options>
+               </command>
+               <autoUpdatePolicy>veto</autoUpdatePolicy>
+       </channel-type>
 </thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandlerTest.java b/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpHandlerTest.java
new file mode 100644 (file)
index 0000000..81b432f
--- /dev/null
@@ -0,0 +1,156 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.handler;
+
+import static org.eclipse.jdt.annotation.Checks.requireNonNull;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlConfiguration;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.io.transport.upnp.UpnpIOService;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.binding.ThingHandlerCallback;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Base class for {@link UpnpServerHandlerTest} and {@link UpnpRendererHandlerTest}.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@SuppressWarnings({ "null" })
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class UpnpHandlerTest {
+
+    private final Logger logger = LoggerFactory.getLogger(UpnpHandlerTest.class);
+
+    private static final ScheduledExecutorService SCHEDULER = Executors.newScheduledThreadPool(1);
+
+    protected @Nullable UpnpHandler handler;
+
+    @Mock
+    protected @Nullable Thing thing;
+
+    @Mock
+    protected @Nullable UpnpIOService upnpIOService;
+
+    @Mock
+    protected @Nullable UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
+
+    @Mock
+    protected @Nullable UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
+
+    protected UpnpControlBindingConfiguration configuration = new UpnpControlBindingConfiguration();
+
+    @Mock
+    protected @Nullable Configuration config;
+
+    // Use temporary folder for favorites and playlists testing
+    @TempDir
+    public @Nullable Path tempFolder;
+
+    @Mock
+    @Nullable
+    protected ScheduledExecutorService scheduler;
+
+    @Mock
+    protected @Nullable ThingHandlerCallback callback;
+
+    public void setUp() {
+        // don't test for multi-threading, so avoid using extra threads
+        implementAsDirectExecutor(requireNonNull(scheduler));
+
+        String path = tempFolder.toString();
+        if (!(path.endsWith(File.separator) || path.endsWith("/"))) {
+            path = path + File.separator;
+        }
+        configuration.path = path;
+
+        // stub thing methods
+        when(thing.getConfiguration()).thenReturn(requireNonNull(config));
+        when(thing.getStatus()).thenReturn(ThingStatus.OFFLINE);
+
+        // stub upnpIOService methods for initialize
+        when(upnpIOService.isRegistered(any())).thenReturn(true);
+
+        Map<String, String> result = new HashMap<>();
+        result.put("ConnectionID", "0");
+        result.put("AVTransportID", "0");
+        result.put("RcsID", "0");
+        when(upnpIOService.invokeAction(any(), eq("ConnectionManager"), eq("GetCurrentConnectionInfo"), anyMap()))
+                .thenReturn(result);
+
+        // stub config for initialize
+        when(config.as(UpnpControlConfiguration.class)).thenReturn(new UpnpControlConfiguration());
+    }
+
+    protected void initHandler(UpnpHandler handler) {
+        handler.setCallback(callback);
+        handler.upnpScheduler = requireNonNull(scheduler);
+
+        // No timeouts for responses, as we don't actually communicate with a UPnP device
+        handler.config.responseTimeout = 0;
+
+        doReturn("12345").when(handler).getUDN();
+    }
+
+    /**
+     * Mock the {@link ScheduledExecutorService}, so all testing is done in the current thread. We do not test
+     * request/response with a real media server, so do not need the executor to avoid long running processes.
+     * As an exception, we will schedule one off futures with 500ms delay, as this is related to internal
+     * synchronization
+     * logic.
+     *
+     * @param executor
+     */
+    private void implementAsDirectExecutor(ScheduledExecutorService executor) {
+        doAnswer(invocation -> {
+            ((Runnable) invocation.getArguments()[0]).run();
+            return null;
+        }).when(executor).submit(any(Runnable.class));
+        doAnswer(invocation -> {
+            ((Runnable) invocation.getArguments()[0]).run();
+            return null;
+        }).when(executor).scheduleWithFixedDelay(any(Runnable.class), eq(0L), anyLong(), any(TimeUnit.class));
+        doAnswer(invocation -> {
+            return SCHEDULER.schedule((Runnable) invocation.getArguments()[0], 500, TimeUnit.MILLISECONDS);
+        }).when(executor).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class));
+    }
+
+    public void tearDown() {
+        logger.info("-----------------------------------------------------------------------------------");
+    }
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandlerTest.java b/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpRendererHandlerTest.java
new file mode 100644 (file)
index 0000000..4ea3735
--- /dev/null
@@ -0,0 +1,928 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.handler;
+
+import static org.eclipse.jdt.annotation.Checks.requireNonNull;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSinkReg;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlRendererConfiguration;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue;
+import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryRes;
+import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.NextPreviousType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.PlayPauseType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.SmartHomeUnits;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.CommandOption;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Unit tests for {@link UpnpRendererHandler}.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@SuppressWarnings({ "null", "unchecked" })
+@NonNullByDefault
+public class UpnpRendererHandlerTest extends UpnpHandlerTest {
+
+    private final Logger logger = LoggerFactory.getLogger(UpnpRendererHandlerTest.class);
+
+    private static final String THING_TYPE_UID = "upnpcontrol:upnprenderer";
+    private static final String THING_UID = THING_TYPE_UID + ":mockrenderer";
+
+    private static final String LAST_CHANGE_HEADER = "<Event xmlns=\"urn:schemas-upnp-org:metadata-1-0/AVT/\">"
+            + "<InstanceID val=\"0\">";
+    private static final String LAST_CHANGE_FOOTER = "</InstanceID></Event>";
+    private static final String AV_TRANSPORT_URI = "<AVTransportURI val=\"";
+    private static final String AV_TRANSPORT_URI_METADATA = "<AVTransportURIMetaData val=\"";
+    private static final String CURRENT_TRACK_URI = "<CurrentTrackURI val=\"";
+    private static final String CURRENT_TRACK_METADATA = "<CurrentTrackMetaData val=\"";
+    private static final String TRANSPORT_STATE = "<TransportState val=\"";
+    private static final String CLOSE = "\"/>";
+
+    protected @Nullable UpnpRendererHandler handler;
+
+    private @Nullable UpnpEntryQueue upnpEntryQueue;
+
+    private ChannelUID volumeChannelUID = new ChannelUID(THING_UID + ":" + VOLUME);
+    private Channel volumeChannel = ChannelBuilder.create(volumeChannelUID, "Dimmer").build();
+
+    private ChannelUID muteChannelUID = new ChannelUID(THING_UID + ":" + MUTE);
+    private Channel muteChannel = ChannelBuilder.create(muteChannelUID, "Switch").build();
+
+    private ChannelUID stopChannelUID = new ChannelUID(THING_UID + ":" + STOP);
+    private Channel stopChannel = ChannelBuilder.create(stopChannelUID, "Switch").build();
+
+    private ChannelUID controlChannelUID = new ChannelUID(THING_UID + ":" + CONTROL);
+    private Channel controlChannel = ChannelBuilder.create(controlChannelUID, "Player").build();
+
+    private ChannelUID repeatChannelUID = new ChannelUID(THING_UID + ":" + REPEAT);
+    private Channel repeatChannel = ChannelBuilder.create(repeatChannelUID, "Switch").build();
+
+    private ChannelUID shuffleChannelUID = new ChannelUID(THING_UID + ":" + SHUFFLE);
+    private Channel shuffleChannel = ChannelBuilder.create(shuffleChannelUID, "Switch").build();
+
+    private ChannelUID onlyPlayOneChannelUID = new ChannelUID(THING_UID + ":" + ONLY_PLAY_ONE);
+    private Channel onlyPlayOneChannel = ChannelBuilder.create(onlyPlayOneChannelUID, "Switch").build();
+
+    private ChannelUID uriChannelUID = new ChannelUID(THING_UID + ":" + URI);
+    private Channel uriChannel = ChannelBuilder.create(uriChannelUID, "String").build();
+
+    private ChannelUID favoriteSelectChannelUID = new ChannelUID(THING_UID + ":" + FAVORITE_SELECT);
+    private Channel favoriteSelectChannel = ChannelBuilder.create(favoriteSelectChannelUID, "String").build();
+
+    private ChannelUID favoriteChannelUID = new ChannelUID(THING_UID + ":" + FAVORITE);
+    private Channel favoriteChannel = ChannelBuilder.create(favoriteChannelUID, "String").build();
+
+    private ChannelUID favoriteActionChannelUID = new ChannelUID(THING_UID + ":" + FAVORITE_ACTION);
+    private Channel favoriteActionChannel = ChannelBuilder.create(favoriteActionChannelUID, "String").build();
+
+    private ChannelUID playlistSelectChannelUID = new ChannelUID(THING_UID + ":" + PLAYLIST_SELECT);
+    private Channel playlistSelectChannel = ChannelBuilder.create(playlistSelectChannelUID, "String").build();
+
+    private ChannelUID titleChannelUID = new ChannelUID(THING_UID + ":" + TITLE);
+    private Channel titleChannel = ChannelBuilder.create(titleChannelUID, "String").build();
+
+    private ChannelUID albumChannelUID = new ChannelUID(THING_UID + ":" + ALBUM);
+    private Channel albumChannel = ChannelBuilder.create(albumChannelUID, "String").build();
+
+    private ChannelUID albumArtChannelUID = new ChannelUID(THING_UID + ":" + ALBUM_ART);
+    private Channel albumArtChannel = ChannelBuilder.create(albumArtChannelUID, "Image").build();
+
+    private ChannelUID creatorChannelUID = new ChannelUID(THING_UID + ":" + CREATOR);
+    private Channel creatorChannel = ChannelBuilder.create(creatorChannelUID, "String").build();
+
+    private ChannelUID artistChannelUID = new ChannelUID(THING_UID + ":" + ARTIST);
+    private Channel artistChannel = ChannelBuilder.create(artistChannelUID, "String").build();
+
+    private ChannelUID publisherChannelUID = new ChannelUID(THING_UID + ":" + PUBLISHER);
+    private Channel publisherChannel = ChannelBuilder.create(publisherChannelUID, "String").build();
+
+    private ChannelUID genreChannelUID = new ChannelUID(THING_UID + ":" + GENRE);
+    private Channel genreChannel = ChannelBuilder.create(genreChannelUID, "String").build();
+
+    private ChannelUID trackNumberChannelUID = new ChannelUID(THING_UID + ":" + TRACK_NUMBER);
+    private Channel trackNumberChannel = ChannelBuilder.create(trackNumberChannelUID, "Number").build();
+
+    private ChannelUID trackDurationChannelUID = new ChannelUID(THING_UID + ":" + TRACK_DURATION);
+    private Channel trackDurationChannel = ChannelBuilder.create(trackDurationChannelUID, "Number:Time").build();
+
+    private ChannelUID trackPositionChannelUID = new ChannelUID(THING_UID + ":" + TRACK_POSITION);
+    private Channel trackPositionChannel = ChannelBuilder.create(trackPositionChannelUID, "Number:Time").build();
+
+    private ChannelUID relTrackPositionChannelUID = new ChannelUID(THING_UID + ":" + REL_TRACK_POSITION);
+    private Channel relTrackPositionChannel = ChannelBuilder.create(relTrackPositionChannelUID, "Dimmer").build();
+
+    @Mock
+    private @Nullable UpnpAudioSinkReg audioSinkReg;
+
+    @Override
+    @BeforeEach
+    public void setUp() {
+        super.setUp();
+
+        // stub thing methods
+        when(thing.getUID()).thenReturn(new ThingUID("upnpcontrol", "upnprenderer", "mockrenderer"));
+        when(thing.getLabel()).thenReturn("MockRenderer");
+        when(thing.getStatus()).thenReturn(ThingStatus.OFFLINE);
+
+        // stub channels
+        when(thing.getChannel(VOLUME)).thenReturn(volumeChannel);
+        when(thing.getChannel(MUTE)).thenReturn(muteChannel);
+        when(thing.getChannel(STOP)).thenReturn(stopChannel);
+        when(thing.getChannel(CONTROL)).thenReturn(controlChannel);
+        when(thing.getChannel(REPEAT)).thenReturn(repeatChannel);
+        when(thing.getChannel(SHUFFLE)).thenReturn(shuffleChannel);
+        when(thing.getChannel(ONLY_PLAY_ONE)).thenReturn(onlyPlayOneChannel);
+        when(thing.getChannel(URI)).thenReturn(uriChannel);
+        when(thing.getChannel(FAVORITE_SELECT)).thenReturn(favoriteSelectChannel);
+        when(thing.getChannel(FAVORITE)).thenReturn(favoriteChannel);
+        when(thing.getChannel(FAVORITE_ACTION)).thenReturn(favoriteActionChannel);
+        when(thing.getChannel(PLAYLIST_SELECT)).thenReturn(playlistSelectChannel);
+        when(thing.getChannel(TITLE)).thenReturn(titleChannel);
+        when(thing.getChannel(ALBUM)).thenReturn(albumChannel);
+        when(thing.getChannel(ALBUM_ART)).thenReturn(albumArtChannel);
+        when(thing.getChannel(CREATOR)).thenReturn(creatorChannel);
+        when(thing.getChannel(ARTIST)).thenReturn(artistChannel);
+        when(thing.getChannel(PUBLISHER)).thenReturn(publisherChannel);
+        when(thing.getChannel(GENRE)).thenReturn(genreChannel);
+        when(thing.getChannel(TRACK_NUMBER)).thenReturn(trackNumberChannel);
+        when(thing.getChannel(TRACK_DURATION)).thenReturn(trackDurationChannel);
+        when(thing.getChannel(TRACK_POSITION)).thenReturn(trackPositionChannel);
+        when(thing.getChannel(REL_TRACK_POSITION)).thenReturn(relTrackPositionChannel);
+
+        // stub config for initialize
+        when(config.as(UpnpControlRendererConfiguration.class)).thenReturn(new UpnpControlRendererConfiguration());
+
+        // create a media queue for playing
+        List<UpnpEntry> entries = createUpnpEntries();
+        upnpEntryQueue = new UpnpEntryQueue(entries, "54321");
+
+        handler = spy(new UpnpRendererHandler(requireNonNull(thing), requireNonNull(upnpIOService),
+                requireNonNull(audioSinkReg), requireNonNull(upnpStateDescriptionProvider),
+                requireNonNull(upnpCommandDescriptionProvider), configuration));
+
+        initHandler(requireNonNull(handler));
+
+        handler.initialize();
+
+        expectLastChangeOnStop(true);
+        expectLastChangeOnPlay(true);
+        expectLastChangeOnPause(true);
+    }
+
+    private List<UpnpEntry> createUpnpEntries() {
+        List<UpnpEntry> entries = new ArrayList<>();
+        UpnpEntry entry;
+        List<UpnpEntryRes> resList;
+        UpnpEntryRes res;
+        resList = new ArrayList<>();
+        res = new UpnpEntryRes("http-get:*:audio/mpeg:*", 8054458L, "10", "http://MediaServerContent_0/1/M0/");
+        res.setRes("http://MediaServerContent_0/1/M0/Test_0.mp3");
+        resList.add(res);
+        entry = new UpnpEntry("M0", "M0", "C11", "object.item.audioItem").withTitle("Music_00").withResList(resList)
+                .withAlbum("My Music 0").withCreator("Creator_0").withArtist("Artist_0").withGenre("Morning")
+                .withPublisher("myself 0").withAlbumArtUri("").withTrackNumber(1);
+        entries.add(entry);
+        resList = new ArrayList<>();
+        res = new UpnpEntryRes("http-get:*:audio/wav:*", 1156598L, "6", "http://MediaServerContent_0/1/M1/");
+        res.setRes("http://MediaServerContent_0/1/M1/Test_1.wav");
+        resList.add(res);
+        entry = new UpnpEntry("M1", "M1", "C11", "object.item.audioItem").withTitle("Music_01").withResList(resList)
+                .withAlbum("My Music 0").withCreator("Creator_1").withArtist("Artist_1").withGenre("Morning")
+                .withPublisher("myself 1").withAlbumArtUri("").withTrackNumber(2);
+        entries.add(entry);
+        resList = new ArrayList<>();
+        res = new UpnpEntryRes("http-get:*:audio/mpeg:*", 1156598L, "40", "http://MediaServerContent_0/1/M2/");
+        res.setRes("http://MediaServerContent_0/1/M2/Test_2.mp3");
+        resList.add(res);
+        entry = new UpnpEntry("M2", "M2", "C12", "object.item.audioItem").withTitle("Music_02").withResList(resList)
+                .withAlbum("My Music 2").withCreator("Creator_2").withArtist("Artist_2").withGenre("Evening")
+                .withPublisher("myself 2").withAlbumArtUri("").withTrackNumber(1);
+        entries.add(entry);
+        return entries;
+    }
+
+    @Override
+    @AfterEach
+    public void tearDown() {
+        handler.dispose();
+
+        super.tearDown();
+    }
+
+    @Test
+    public void testRegisterQueue() {
+        logger.info("testRegisterQueue");
+
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        checkInternalState(0, 1, true, false, true, false);
+        checkControlChannel(PlayPauseType.PAUSE);
+        checkSetURI(0, 1);
+        checkMetadataChannels(0);
+    }
+
+    @Test
+    public void testPlayQueue() {
+        logger.info("testPlayQueue");
+
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        // Play media
+        handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+        checkInternalState(0, 1, false, true, false, true);
+        checkControlChannel(PlayPauseType.PLAY);
+        checkSetURI(0, 1);
+        checkMetadataChannels(0);
+    }
+
+    @Test
+    public void testStop() {
+        logger.info("testStop");
+
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        // Play media
+        handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+        // Stop playback
+        handler.handleCommand(stopChannelUID, OnOffType.ON);
+
+        checkInternalState(0, 1, true, false, false, false);
+        checkControlChannel(PlayPauseType.PAUSE);
+        checkSetURI(0, 1);
+        checkMetadataChannels(0);
+    }
+
+    @Test
+    public void testPause() {
+        logger.info("testPause");
+
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        // Play media
+        handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+        // Pause media
+        handler.handleCommand(controlChannelUID, PlayPauseType.PAUSE);
+
+        checkControlChannel(PlayPauseType.PAUSE);
+
+        // Continue playing
+        handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+        checkControlChannel(PlayPauseType.PLAY);
+    }
+
+    @Test
+    public void testPauseNotSupported() {
+        logger.info("testPauseNotSupported");
+
+        // Some players don't support pause and just continue playing.
+        // Test if we properly switch back to playing state if no confirmation of pause received.
+
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        // Play media
+        handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+        // Pause media
+        // Do not receive a PAUSED_PLAYBACK response
+        expectLastChangeOnPause(false);
+        handler.handleCommand(controlChannelUID, PlayPauseType.PAUSE);
+
+        // Wait long enough for status to turn back to PLAYING.
+        // All timeouts in test are set to 1s.
+        try {
+            TimeUnit.SECONDS.sleep(1);
+        } catch (InterruptedException ignore) {
+        }
+
+        checkControlChannel(PlayPauseType.PLAY);
+    }
+
+    @Test
+    public void testRegisterQueueWhilePlaying() {
+        logger.info("testRegisterQueueWhilePlaying");
+
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 2);
+        List<UpnpEntry> startList = new ArrayList<UpnpEntry>();
+        startList.add(requireNonNull(upnpEntryQueue.get(2)));
+        UpnpEntryQueue startQueue = new UpnpEntryQueue(startList, "54321");
+        handler.registerQueue(requireNonNull(startQueue));
+
+        // Play media
+        handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+        // Register a new media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        checkInternalState(2, 0, false, true, true, true);
+        checkControlChannel(PlayPauseType.PLAY);
+        checkSetURI(null, 0);
+        checkMetadataChannels(2);
+    }
+
+    @Test
+    public void testNext() {
+        logger.info("testNext");
+
+        testNext(false, false);
+    }
+
+    @Test
+    public void testNextRepeat() {
+        logger.info("testNextRepeat");
+
+        testNext(false, true);
+    }
+
+    @Test
+    public void testNextWhilePlaying() {
+        logger.info("testNextWhilePlaying");
+
+        testNext(true, false);
+    }
+
+    @Test
+    public void testNextWhilePlayingRepeat() {
+        logger.info("testNextWhilePlayingRepeat");
+
+        testNext(true, true);
+    }
+
+    private void testNext(boolean play, boolean repeat) {
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        if (repeat) {
+            handler.handleCommand(repeatChannelUID, OnOffType.ON);
+        }
+
+        if (play) {
+            // Play media
+            handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+        }
+
+        // Next media
+        expectLastChangeOnSetAVTransportURI(true, 1);
+        handler.handleCommand(controlChannelUID, NextPreviousType.NEXT);
+
+        checkInternalState(1, 2, play ? false : true, play ? true : false, play ? false : true, play ? true : false);
+        checkControlChannel(play ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
+        checkSetURI(1, 2);
+        checkMetadataChannels(1);
+
+        // Next media
+        expectLastChangeOnSetAVTransportURI(true, 2);
+        handler.handleCommand(controlChannelUID, NextPreviousType.NEXT);
+
+        checkInternalState(2, repeat ? 0 : null, play ? false : true, play ? true : false, play ? false : true,
+                play ? true : false);
+        checkControlChannel(play ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
+        checkSetURI(2, repeat ? 0 : null);
+        checkMetadataChannels(2);
+
+        // Next media
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.handleCommand(controlChannelUID, NextPreviousType.NEXT);
+
+        checkInternalState(0, 1, (play && repeat) ? false : true, (play && repeat) ? true : false,
+                (play && repeat) ? false : true, (play && repeat) ? true : false);
+        checkControlChannel((play && repeat) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
+        checkSetURI(0, 1);
+        checkMetadataChannels(0);
+    }
+
+    @Test
+    public void testPrevious() {
+        logger.info("testPrevious");
+
+        testPrevious(false, false);
+    }
+
+    @Test
+    public void testPreviousRepeat() {
+        logger.info("testPreviousRepeat");
+
+        testPrevious(false, true);
+    }
+
+    @Test
+    public void testPreviousWhilePlaying() {
+        logger.info("testPreviousWhilePlaying");
+
+        testPrevious(true, false);
+    }
+
+    @Test
+    public void testPreviousWhilePlayingRepeat() {
+        logger.info("testPreviousWhilePlayingRepeat");
+
+        testPrevious(true, true);
+    }
+
+    public void testPrevious(boolean play, boolean repeat) {
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        if (repeat) {
+            handler.handleCommand(repeatChannelUID, OnOffType.ON);
+        }
+
+        if (play) {
+            // Play media
+            handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+        }
+
+        // Next media
+        expectLastChangeOnSetAVTransportURI(true, 1);
+        handler.handleCommand(controlChannelUID, NextPreviousType.NEXT);
+
+        // Previous media
+        expectLastChangeOnSetAVTransportURI(true, 2);
+        handler.handleCommand(controlChannelUID, NextPreviousType.PREVIOUS);
+
+        checkInternalState(0, 1, play ? false : true, play ? true : false, play ? false : true, play ? true : false);
+        checkControlChannel(play ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
+        checkSetURI(0, 1);
+        checkMetadataChannels(0);
+
+        // Previous media
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.handleCommand(controlChannelUID, NextPreviousType.PREVIOUS);
+
+        checkInternalState(repeat ? 2 : 0, repeat ? 0 : 1, (play && repeat) ? false : true,
+                (play && repeat) ? true : false, (play && repeat) ? false : true, (play && repeat) ? true : false);
+        checkControlChannel((play && repeat) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
+        checkSetURI(repeat ? 2 : 0, repeat ? 0 : 1);
+        checkMetadataChannels(repeat ? 2 : 0);
+    }
+
+    @Test
+    public void testAutoPlayNextInQueue() {
+        logger.info("testAutoPlayNextInQueue");
+
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        // Play media
+        handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+        // We expect GENA LastChange event with new metadata when the renderer starts to play next entry
+        expectLastChangeOnSetAVTransportURI(true, 1);
+
+        // At the end of the media, we will get GENA LastChange STOP event, renderer should move to next media and play
+        // Force this STOP event for test
+        String lastChange = LAST_CHANGE_HEADER + TRANSPORT_STATE + "STOPPED" + CLOSE + LAST_CHANGE_FOOTER;
+        handler.onValueReceived("LastChange", lastChange, "AVTransport");
+
+        checkInternalState(1, 2, false, true, false, true);
+        checkControlChannel(PlayPauseType.PLAY);
+        checkSetURI(1, 2);
+        checkMetadataChannels(1);
+    }
+
+    @Test
+    public void testAutoPlayNextInQueueGapless() {
+        logger.info("testAutoPlayNextInQueueGapless");
+
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        // Play media
+        handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+        // We expect GENA LastChange event with new metadata when the renderer starts to play next entry
+        expectLastChangeOnSetAVTransportURI(true, 1);
+
+        // At the end of the media, we will get GENA event with new URI and metadata
+        String lastChange = LAST_CHANGE_HEADER + AV_TRANSPORT_URI + upnpEntryQueue.get(1).getRes() + CLOSE
+                + AV_TRANSPORT_URI_METADATA + UpnpXMLParser.compileMetadataString(requireNonNull(upnpEntryQueue.get(0)))
+                + CLOSE + CURRENT_TRACK_URI + upnpEntryQueue.get(1).getRes() + CLOSE + CURRENT_TRACK_METADATA
+                + UpnpXMLParser.compileMetadataString(requireNonNull(upnpEntryQueue.get(1))) + CLOSE
+                + LAST_CHANGE_FOOTER;
+        handler.onValueReceived("LastChange", lastChange, "AVTransport");
+
+        checkInternalState(1, 2, false, true, false, true);
+        checkControlChannel(PlayPauseType.PLAY);
+        checkSetURI(null, 2);
+        checkMetadataChannels(1);
+    }
+
+    @Test
+    public void testOnlyPlayOne() {
+        logger.info("testOnlyPlayOne");
+
+        handler.handleCommand(onlyPlayOneChannelUID, OnOffType.ON);
+
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        // Play media
+        handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+        checkInternalState(0, 1, false, true, false, true);
+        checkSetURI(0, null);
+        checkMetadataChannels(0);
+
+        // We expect GENA LastChange event with new metadata when the renderer has finished playing
+        expectLastChangeOnSetAVTransportURI(true, 1);
+
+        // At the end of the media, we will get GENA LastChange STOP event, renderer should stop
+        // Force this STOP event for test
+        String lastChange = LAST_CHANGE_HEADER + TRANSPORT_STATE + "STOPPED" + CLOSE + LAST_CHANGE_FOOTER;
+        handler.onValueReceived("LastChange", lastChange, "AVTransport");
+
+        checkInternalState(1, 2, false, false, false, true);
+        checkControlChannel(PlayPauseType.PAUSE);
+        checkSetURI(1, null);
+        checkMetadataChannels(1);
+    }
+
+    @Test
+    public void testPlayUri() {
+        logger.info("testPlayUri");
+
+        expectLastChangeOnSetAVTransportURI(true, false, 0);
+        handler.handleCommand(uriChannelUID, StringType.valueOf(upnpEntryQueue.get(0).getRes()));
+
+        // Play media
+        handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+        checkInternalState(null, null, false, true, false, false);
+        checkControlChannel(PlayPauseType.PLAY);
+        checkSetURI(0, null, false);
+        checkMetadataChannels(0, true);
+    }
+
+    @Test
+    public void testPlayAction() {
+        logger.info("testPlayAction");
+
+        expectLastChangeOnSetAVTransportURI(true, false, 0);
+
+        // Methods called in sequence by audio sink
+        handler.setCurrentURI(upnpEntryQueue.get(0).getRes(), "");
+        handler.play();
+
+        checkInternalState(null, null, false, true, false, false);
+        checkControlChannel(PlayPauseType.PLAY);
+        checkSetURI(0, null, false);
+        checkMetadataChannels(0, true);
+    }
+
+    @Test
+    public void testPlayNotification() {
+        logger.info("testPlayNotification");
+
+        // Register a media queue
+        expectLastChangeOnSetAVTransportURI(true, 0);
+        handler.registerQueue(requireNonNull(upnpEntryQueue));
+
+        // Set volume
+        expectLastChangeOnSetVolume(true, 50);
+        handler.setVolume(new PercentType(50));
+
+        checkInternalState(0, 1, true, false, true, false);
+        checkSetURI(0, 1, true);
+        checkMetadataChannels(0, false);
+
+        // Play notification, at standard 10% volume above current volume level
+        expectLastChangeOnSetAVTransportURI(true, false, 2);
+        expectLastChangeOnGetPositionInfo(true, "00:00:00");
+        handler.playNotification(upnpEntryQueue.get(2).getRes());
+
+        checkInternalState(0, 1, true, false, true, false);
+        checkSetURI(2, null, false);
+        checkMetadataChannels(0, false);
+        verify(handler).setVolume(new PercentType(55));
+
+        // At the end of the notification, we will get GENA LastChange STOP event
+        // Force this STOP event for test
+        expectLastChangeOnSetAVTransportURI(true, false, 0);
+        String lastChange = LAST_CHANGE_HEADER + TRANSPORT_STATE + "STOPPED" + CLOSE + LAST_CHANGE_FOOTER;
+        handler.onValueReceived("LastChange", lastChange, "AVTransport");
+
+        checkInternalState(0, 1, true, false, true, false);
+        checkMetadataChannels(0, false);
+        verify(handler, times(2)).setVolume(new PercentType(50));
+
+        // Play media and move to position
+        handler.handleCommand(controlChannelUID, PlayPauseType.PLAY);
+
+        checkInternalState(0, 1, false, true, false, true); //
+        checkSetURI(0, 1, true);
+        checkMetadataChannels(0, false);
+
+        // Play notification again, while simulating the current playing media is at 10s position
+        // Play at volume level provided by audiSink action
+        expectLastChangeOnSetAVTransportURI(true, false, 2);
+        expectLastChangeOnGetPositionInfo(true, "00:00:10");
+        handler.setNotificationVolume(new PercentType(70));
+        handler.playNotification(upnpEntryQueue.get(2).getRes());
+
+        checkInternalState(0, 1, false, true, false, true);
+        checkSetURI(2, null, false);
+        checkMetadataChannels(0, false);
+        verify(handler).setVolume(new PercentType(70));
+
+        // Wait long enough for max notification duration to be reached.
+        // In the test, we have enforced 500ms delay through schedule mock.
+        expectLastChangeOnSetAVTransportURI(true, false, 0);
+        try {
+            TimeUnit.SECONDS.sleep(1);
+            logger.info("Test playing {}, stopped {}", handler.playing, handler.playerStopped);
+        } catch (InterruptedException ignore) {
+        }
+
+        checkInternalState(0, 1, false, true, false, true);
+        checkSetURI(0, null, false);
+        checkMetadataChannels(0, false);
+        verify(handler, times(3)).setVolume(new PercentType(50));
+        verify(callback, times(2)).stateUpdated(trackPositionChannelUID, new QuantityType<>(10, SmartHomeUnits.SECOND));
+    }
+
+    @Test
+    public void testFavorite() {
+        logger.info("testFavorite");
+
+        // Check already called in initialize
+        verify(handler).updateFavoritesList();
+
+        // First set URI
+        expectLastChangeOnSetAVTransportURI(true, false, 0);
+        handler.handleCommand(uriChannelUID, StringType.valueOf(upnpEntryQueue.get(0).getRes()));
+
+        // Save favorite
+        handler.handleCommand(favoriteChannelUID, StringType.valueOf("Test_Favorite"));
+        handler.handleCommand(favoriteActionChannelUID, StringType.valueOf("SAVE"));
+
+        // Check called after saving favorite
+        verify(handler, times(2)).updateFavoritesList();
+
+        // Check that FAVORITE_SELECT channel now has the favorite as a state option
+        ArgumentCaptor<List<CommandOption>> commandOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateCommandDescription(eq(thing.getChannel(FAVORITE_SELECT).getUID()),
+                commandOptionListCaptor.capture());
+        assertThat(commandOptionListCaptor.getValue().size(), is(1));
+        assertThat(commandOptionListCaptor.getValue().get(0).getCommand(), is("Test_Favorite"));
+        assertThat(commandOptionListCaptor.getValue().get(0).getLabel(), is("Test_Favorite"));
+
+        // Clear FAVORITE channel
+        handler.handleCommand(favoriteChannelUID, StringType.valueOf(""));
+
+        // Set another URI
+        expectLastChangeOnSetAVTransportURI(true, false, 2);
+        handler.handleCommand(uriChannelUID, StringType.valueOf(upnpEntryQueue.get(2).getRes()));
+
+        checkInternalState(null, null, false, true, false, false);
+        checkSetURI(2, null, false);
+        checkMetadataChannels(2, true);
+
+        // Restore favorite
+        expectLastChangeOnSetAVTransportURI(true, false, 0);
+        handler.handleCommand(favoriteSelectChannelUID, StringType.valueOf("Test_Favorite"));
+
+        checkInternalState(null, null, false, true, false, false);
+        checkControlChannel(PlayPauseType.PLAY);
+        checkSetURI(0, null, false);
+        checkMetadataChannels(0, true);
+
+        // Delete favorite
+        handler.handleCommand(favoriteSelectChannelUID, StringType.valueOf("Test_Favorite"));
+        handler.handleCommand(favoriteActionChannelUID, StringType.valueOf("DELETE"));
+
+        // Check called after deleting favorite
+        verify(handler, times(3)).updateFavoritesList();
+
+        // Check that FAVORITE_SELECT channel option list is empty again
+        commandOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateCommandDescription(eq(thing.getChannel(FAVORITE_SELECT).getUID()),
+                commandOptionListCaptor.capture());
+        assertThat(commandOptionListCaptor.getValue().size(), is(0));
+    }
+
+    private void expectLastChangeOnStop(boolean respond) {
+        String value = LAST_CHANGE_HEADER + TRANSPORT_STATE + "STOPPED" + CLOSE + LAST_CHANGE_FOOTER;
+        doAnswer(invocation -> {
+            if (respond) {
+                handler.onValueReceived("LastChange", value, "AVTransport");
+            }
+            return Collections.emptyMap();
+        }).when(upnpIOService).invokeAction(eq(handler), eq("AVTransport"), eq("Stop"), anyMap());
+    }
+
+    private void expectLastChangeOnPlay(boolean respond) {
+        String value = LAST_CHANGE_HEADER + TRANSPORT_STATE + "PLAYING" + CLOSE + LAST_CHANGE_FOOTER;
+        doAnswer(invocation -> {
+            if (respond) {
+                handler.onValueReceived("LastChange", value, "AVTransport");
+            }
+            return Collections.emptyMap();
+        }).when(upnpIOService).invokeAction(eq(handler), eq("AVTransport"), eq("Play"), anyMap());
+    }
+
+    private void expectLastChangeOnPause(boolean respond) {
+        String value = LAST_CHANGE_HEADER + TRANSPORT_STATE + "PAUSED_PLAYBACK" + CLOSE + LAST_CHANGE_FOOTER;
+        doAnswer(invocation -> {
+            if (respond) {
+                handler.onValueReceived("LastChange", value, "AVTransport");
+            }
+            return Collections.emptyMap();
+        }).when(upnpIOService).invokeAction(eq(handler), eq("AVTransport"), eq("Pause"), anyMap());
+    }
+
+    private void expectLastChangeOnSetVolume(boolean respond, long volume) {
+        Map<String, String> inputs = new HashMap<>();
+        inputs.put("InstanceID", "0");
+        inputs.put("Channel", UPNP_MASTER);
+        inputs.put("DesiredVolume", String.valueOf(volume));
+        doAnswer(invocation -> {
+            if (respond) {
+                handler.onValueReceived(UPNP_MASTER + "Volume", String.valueOf(volume), "RenderingControl");
+            }
+            return Collections.emptyMap();
+        }).when(upnpIOService).invokeAction(eq(handler), eq("RenderingControl"), eq("SetVolume"), eq(inputs));
+    }
+
+    private void expectLastChangeOnGetPositionInfo(boolean respond, String seekTarget) {
+        Map<String, String> inputs = new HashMap<>();
+        inputs.put("InstanceID", "0");
+        doAnswer(invocation -> {
+            if (respond) {
+                handler.onValueReceived("RelTime", seekTarget, "AVTransport");
+            }
+            return Collections.emptyMap();
+        }).when(upnpIOService).invokeAction(eq(handler), eq("AVTransport"), eq("GetPositionInfo"), eq(inputs));
+    }
+
+    private void expectLastChangeOnSetAVTransportURI(boolean respond, int mediaId) {
+        expectLastChangeOnSetAVTransportURI(respond, true, mediaId);
+    }
+
+    private void expectLastChangeOnSetAVTransportURI(boolean respond, boolean withMetadata, int mediaId) {
+        String uri = upnpEntryQueue.get(mediaId).getRes();
+        String metadata = UpnpXMLParser.compileMetadataString(requireNonNull(upnpEntryQueue.get(mediaId)));
+        Map<String, String> inputs = new HashMap<>();
+        inputs.put("InstanceID", "0");
+        inputs.put("CurrentURI", uri);
+        inputs.put("CurrentURIMetaData", withMetadata ? metadata : "");
+        String value = LAST_CHANGE_HEADER + AV_TRANSPORT_URI + uri + CLOSE + AV_TRANSPORT_URI_METADATA + metadata
+                + CLOSE + CURRENT_TRACK_URI + uri + CLOSE + CURRENT_TRACK_METADATA + metadata + CLOSE
+                + LAST_CHANGE_FOOTER;
+        doAnswer(invocation -> {
+            if (respond) {
+                handler.onValueReceived("LastChange", value, "AVTransport");
+            }
+            return Collections.emptyMap();
+        }).when(upnpIOService).invokeAction(eq(handler), eq("AVTransport"), eq("SetAVTransportURI"), eq(inputs));
+    }
+
+    private void checkInternalState(@Nullable Integer currentEntry, @Nullable Integer nextEntry, boolean playerStopped,
+            boolean playing, boolean registeredQueue, boolean playingQueue) {
+        if (currentEntry == null) {
+            assertNull(handler.currentEntry);
+        } else {
+            assertThat(handler.currentEntry, is(upnpEntryQueue.get(currentEntry)));
+        }
+        if (nextEntry == null) {
+            assertNull(handler.nextEntry);
+        } else {
+            assertThat(handler.nextEntry, is(upnpEntryQueue.get(nextEntry)));
+        }
+        assertThat(handler.playerStopped, is(playerStopped));
+        assertThat(handler.playing, is(playing));
+        assertThat(handler.registeredQueue, is(registeredQueue));
+        assertThat(handler.playingQueue, is(playingQueue));
+    }
+
+    private void checkControlChannel(Command command) {
+        ArgumentCaptor<PlayPauseType> captor = ArgumentCaptor.forClass(PlayPauseType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CONTROL).getUID()), captor.capture());
+        assertThat(captor.getValue(), is(command));
+    }
+
+    private void checkSetURI(@Nullable Integer current, @Nullable Integer next) {
+        checkSetURI(current, next, true);
+    }
+
+    private void checkSetURI(@Nullable Integer current, @Nullable Integer next, boolean withMetadata) {
+        ArgumentCaptor<String> uriCaptor = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<String> metadataCaptor = ArgumentCaptor.forClass(String.class);
+        if (current != null) {
+            verify(handler, atLeastOnce()).setCurrentURI(uriCaptor.capture(), metadataCaptor.capture());
+            assertThat(uriCaptor.getValue(), is(upnpEntryQueue.get(current).getRes()));
+            if (withMetadata) {
+                assertThat(metadataCaptor.getValue(),
+                        is(UpnpXMLParser.compileMetadataString(requireNonNull(upnpEntryQueue.get(current)))));
+            }
+        }
+        if (next != null) {
+            verify(handler, atLeastOnce()).setNextURI(uriCaptor.capture(), metadataCaptor.capture());
+            assertThat(uriCaptor.getValue(), is(upnpEntryQueue.get(next).getRes()));
+            if (withMetadata) {
+                assertThat(metadataCaptor.getValue(),
+                        is(UpnpXMLParser.compileMetadataString(requireNonNull(upnpEntryQueue.get(next)))));
+            }
+        }
+    }
+
+    private void checkMetadataChannels(int mediaId) {
+        checkMetadataChannels(mediaId, false);
+    }
+
+    private void checkMetadataChannels(int mediaId, boolean cleared) {
+        ArgumentCaptor<State> stateCaptor = ArgumentCaptor.forClass(State.class);
+
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(URI).getUID()), stateCaptor.capture());
+        assertThat(stateCaptor.getValue(), is(StringType.valueOf(upnpEntryQueue.get(mediaId).getRes())));
+
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(TITLE).getUID()), stateCaptor.capture());
+        assertThat(stateCaptor.getValue(),
+                is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getTitle())));
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(ALBUM).getUID()), stateCaptor.capture());
+        assertThat(stateCaptor.getValue(),
+                is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getAlbum())));
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CREATOR).getUID()), stateCaptor.capture());
+        assertThat(stateCaptor.getValue(),
+                is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getCreator())));
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(ARTIST).getUID()), stateCaptor.capture());
+        assertThat(stateCaptor.getValue(),
+                is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getArtist())));
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(PUBLISHER).getUID()), stateCaptor.capture());
+        assertThat(stateCaptor.getValue(),
+                is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getPublisher())));
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(GENRE).getUID()), stateCaptor.capture());
+        assertThat(stateCaptor.getValue(),
+                is(cleared ? UnDefType.UNDEF : StringType.valueOf(upnpEntryQueue.get(mediaId).getGenre())));
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(TRACK_NUMBER).getUID()),
+                stateCaptor.capture());
+        assertThat(stateCaptor.getValue(),
+                is(cleared ? UnDefType.UNDEF : new DecimalType(upnpEntryQueue.get(mediaId).getOriginalTrackNumber())));
+        is(new DecimalType(upnpEntryQueue.get(mediaId).getOriginalTrackNumber()));
+    }
+}
diff --git a/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandlerTest.java b/bundles/org.openhab.binding.upnpcontrol/src/test/java/org/openhab/binding/upnpcontrol/internal/handler/UpnpServerHandlerTest.java
new file mode 100644 (file)
index 0000000..ef7a3f1
--- /dev/null
@@ -0,0 +1,877 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.upnpcontrol.internal.handler;
+
+import static org.eclipse.jdt.annotation.Checks.requireNonNull;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.openhab.binding.upnpcontrol.internal.config.UpnpControlServerConfiguration;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.types.CommandOption;
+import org.openhab.core.types.StateOption;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Unit tests for {@link UpnpServerHandler}.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@SuppressWarnings({ "null", "unchecked" })
+@NonNullByDefault
+public class UpnpServerHandlerTest extends UpnpHandlerTest {
+
+    private final Logger logger = LoggerFactory.getLogger(UpnpServerHandlerTest.class);
+
+    private static final String THING_TYPE_UID = "upnpcontrol:upnpserver";
+    private static final String THING_UID = THING_TYPE_UID + ":mockserver";
+
+    private static final String RESPONSE_HEADER = "<DIDL-Lite xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" "
+            + "xmlns:dc=\"http://purl.org/dc/elements/1.1/\" "
+            + "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\">";
+    private static final String RESPONSE_FOOTER = "</DIDL-Lite>";
+
+    private static final String BASE_CONTAINER = RESPONSE_HEADER
+            + "<container id=\"C1\" searchable=\"0\" parentID=\"0\" restricted=\"1\" childCount=\"2\">"
+            + "<dc:title>All Audio Items</dc:title><upnp:class>object.container</upnp:class>"
+            + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></container>"
+            + "<container id=\"C2\" searchable=\"0\" parentID=\"0\" restricted=\"1\" childCount=\"0\">"
+            + "<dc:title>All Image Items</dc:title><upnp:class>object.container</upnp:class>"
+            + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></container>" + RESPONSE_FOOTER;
+
+    private static final String SINGLE_CONTAINER = RESPONSE_HEADER
+            + "<container id=\"C11\" searchable=\"0\" parentID=\"C1\" restricted=\"1\" childCount=\"2\">"
+            + "<dc:title>Morning Music</dc:title><upnp:class>object.container</upnp:class>"
+            + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></container>" + RESPONSE_FOOTER;
+
+    private static final String DOUBLE_CONTAINER = RESPONSE_HEADER
+            + "<container id=\"C11\" searchable=\"0\" parentID=\"C1\" restricted=\"1\" childCount=\"2\">"
+            + "<dc:title>Morning Music</dc:title><upnp:class>object.container</upnp:class>"
+            + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></container>"
+            + "<container id=\"C12\" searchable=\"0\" parentID=\"C1\" restricted=\"1\" childCount=\"1\">"
+            + "<dc:title>Evening Music</dc:title><upnp:class>object.container</upnp:class>"
+            + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></container>" + RESPONSE_FOOTER;
+
+    private static final String DOUBLE_MEDIA = RESPONSE_HEADER + "<item id=\"M1\" parentID=\"C11\" restricted=\"1\">"
+            + "<dc:title>Music_01</dc:title><upnp:class>object.item.audioItem</upnp:class>"
+            + "<dc:creator>Creator_1</dc:creator>"
+            + "<res protocolInfo=\"http-get:*:audio/mpeg:*\" size=\"8054458\" importUri=\"http://MediaServerContent_0/1/M1/\">http://MediaServerContent_0/1/M1/Test_1.mp3</res>"
+            + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></item>"
+            + "<item id=\"M2\" parentID=\"C11\" restricted=\"1\">"
+            + "<dc:title>Music_02</dc:title><upnp:class>object.item.audioItem</upnp:class>"
+            + "<dc:creator>Creator_2</dc:creator>"
+            + "<res protocolInfo=\"http-get:*:audio/wav:*\" size=\"1156598\" importUri=\"http://MediaServerContent_0/3/M2/\">http://MediaServerContent_0/3/M2/Test_2.wav</res>"
+            + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></item>" + RESPONSE_FOOTER;
+
+    private static final String EXTRA_MEDIA = RESPONSE_HEADER + "<item id=\"M3\" parentID=\"C12\" restricted=\"1\">"
+            + "<dc:title>Extra_01</dc:title><upnp:class>object.item.audioItem</upnp:class>"
+            + "<dc:creator>Creator_3</dc:creator>"
+            + "<res protocolInfo=\"http-get:*:audio/mpeg:*\" size=\"8054458\" importUri=\"http://MediaServerContent_0/1/M3/\">http://MediaServerContent_0/1/M3/Test_3.mp3</res>"
+            + "<upnp:writeStatus>UNKNOWN</upnp:writeStatus></item>" + RESPONSE_FOOTER;
+
+    protected @Nullable UpnpServerHandler handler;
+
+    private ChannelUID rendererChannelUID = new ChannelUID(THING_UID + ":" + UPNPRENDERER);
+    private Channel rendererChannel = ChannelBuilder.create(rendererChannelUID, "String").build();
+
+    private ChannelUID browseChannelUID = new ChannelUID(THING_UID + ":" + BROWSE);
+    private Channel browseChannel = ChannelBuilder.create(browseChannelUID, "String").build();
+
+    private ChannelUID currentTitleChannelUID = new ChannelUID(THING_UID + ":" + CURRENTTITLE);
+    private Channel currentTitleChannel = ChannelBuilder.create(currentTitleChannelUID, "String").build();
+
+    private ChannelUID searchChannelUID = new ChannelUID(THING_UID + ":" + SEARCH);
+    private Channel searchChannel = ChannelBuilder.create(searchChannelUID, "String").build();
+
+    private ChannelUID playlistSelectChannelUID = new ChannelUID(THING_UID + ":" + PLAYLIST_SELECT);
+    private Channel playlistSelectChannel = ChannelBuilder.create(playlistSelectChannelUID, "String").build();
+
+    private ChannelUID playlistChannelUID = new ChannelUID(THING_UID + ":" + PLAYLIST);
+    private Channel playlistChannel = ChannelBuilder.create(playlistChannelUID, "String").build();
+
+    private ChannelUID playlistActionChannelUID = new ChannelUID(THING_UID + ":" + PLAYLIST_ACTION);
+    private Channel playlistActionChannel = ChannelBuilder.create(playlistActionChannelUID, "String").build();
+
+    private ConcurrentMap<String, UpnpRendererHandler> upnpRenderers = new ConcurrentHashMap<>();
+
+    @Mock
+    private @Nullable UpnpRendererHandler rendererHandler;
+    @Mock
+    private @Nullable Thing rendererThing;
+
+    @Override
+    @BeforeEach
+    public void setUp() {
+        super.setUp();
+
+        // stub thing methods
+        when(thing.getUID()).thenReturn(new ThingUID("upnpcontrol", "upnpserver", "mockserver"));
+        when(thing.getLabel()).thenReturn("MockServer");
+        when(thing.getStatus()).thenReturn(ThingStatus.OFFLINE);
+
+        // stub upnpIOService methods for initialize
+        Map<String, String> result = new HashMap<>();
+        result.put("Result", BASE_CONTAINER);
+        when(upnpIOService.invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap())).thenReturn(result);
+
+        // stub rendererHandler, so that only one protocol is supported and results should be filtered when filter true
+        when(rendererHandler.getSink()).thenReturn(Arrays.asList("http-get:*:audio/mpeg:*"));
+        when(rendererHandler.getThing()).thenReturn(requireNonNull(rendererThing));
+        when(rendererThing.getUID()).thenReturn(new ThingUID("upnpcontrol", "upnprenderer", "mockrenderer"));
+        when(rendererThing.getLabel()).thenReturn("MockRenderer");
+        upnpRenderers.put(rendererThing.getUID().toString(), requireNonNull(rendererHandler));
+
+        // stub channels
+        when(thing.getChannel(UPNPRENDERER)).thenReturn(rendererChannel);
+        when(thing.getChannel(BROWSE)).thenReturn(browseChannel);
+        when(thing.getChannel(CURRENTTITLE)).thenReturn(currentTitleChannel);
+        when(thing.getChannel(SEARCH)).thenReturn(searchChannel);
+        when(thing.getChannel(PLAYLIST_SELECT)).thenReturn(playlistSelectChannel);
+        when(thing.getChannel(PLAYLIST)).thenReturn(playlistChannel);
+        when(thing.getChannel(PLAYLIST_ACTION)).thenReturn(playlistActionChannel);
+
+        // stub config for initialize
+        when(config.as(UpnpControlServerConfiguration.class)).thenReturn(new UpnpControlServerConfiguration());
+
+        handler = spy(new UpnpServerHandler(requireNonNull(thing), requireNonNull(upnpIOService),
+                requireNonNull(upnpRenderers), requireNonNull(upnpStateDescriptionProvider),
+                requireNonNull(upnpCommandDescriptionProvider), configuration));
+
+        initHandler(requireNonNull(handler));
+
+        handler.initialize();
+    }
+
+    @Override
+    @AfterEach
+    public void tearDown() {
+        handler.dispose();
+
+        super.tearDown();
+    }
+
+    @Test
+    public void testBase() {
+        logger.info("testBase");
+
+        handler.config.filter = false;
+        handler.config.browseDown = false;
+        handler.config.searchFromRoot = false;
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is(UpnpServerHandler.DIRECTORY_ROOT));
+
+        // Check BROWSE
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("0")));
+
+        // Check CURRENTTITLE
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("")));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(2));
+        assertThat(handler.entries.get(0).getId(), is("C1"));
+        assertThat(handler.entries.get(0).getTitle(), is("All Audio Items"));
+        assertThat(handler.entries.get(1).getId(), is("C2"));
+        assertThat(handler.entries.get(1).getTitle(), is("All Image Items"));
+
+        // Check that BROWSE channel gets the correct command options, no UP should be added
+        ArgumentCaptor<List<StateOption>> commandOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+                commandOptionListCaptor.capture());
+        assertThat(commandOptionListCaptor.getValue().size(), is(2));
+        assertThat(commandOptionListCaptor.getValue().get(0).getValue(), is("C1"));
+        assertThat(commandOptionListCaptor.getValue().get(0).getLabel(), is("All Audio Items"));
+        assertThat(commandOptionListCaptor.getValue().get(1).getValue(), is("C2"));
+        assertThat(commandOptionListCaptor.getValue().get(1).getLabel(), is("All Image Items"));
+
+        // Check media queue serving
+        verify(rendererHandler, times(0)).registerQueue(any());
+    }
+
+    @Test
+    public void testSetBrowse() {
+        logger.info("testSetBrowse");
+
+        handler.config.filter = false;
+        handler.config.browseDown = false;
+        handler.config.searchFromRoot = false;
+
+        Map<String, String> result = new HashMap<>();
+        result.put("Result", DOUBLE_MEDIA);
+        doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+        handler.handleCommand(browseChannelUID, StringType.valueOf("C11"));
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is("C11"));
+
+        // Check BROWSE
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("C11")));
+
+        // Check CURRENTTITLE
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("")));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(2));
+        assertThat(handler.entries.get(0).getId(), is("M1"));
+        assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+        assertThat(handler.entries.get(1).getId(), is("M2"));
+        assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+
+        // Check that BROWSE channel gets the correct state options
+        ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+                stateOptionListCaptor.capture());
+        assertThat(stateOptionListCaptor.getValue().size(), is(3));
+        assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+        assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("M2"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Music_02"));
+
+        // Check media queue serving
+        verify(rendererHandler, times(0)).registerQueue(any());
+    }
+
+    @Test
+    public void testSetBrowseRendererFilter() {
+        logger.info("testSetBrowseRendererFilter");
+
+        handler.config.filter = true;
+        handler.config.browseDown = false;
+        handler.config.searchFromRoot = false;
+
+        handler.handleCommand(rendererChannelUID, StringType.valueOf(rendererThing.getUID().toString()));
+
+        Map<String, String> result = new HashMap<>();
+        result.put("Result", DOUBLE_MEDIA);
+        doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+        handler.handleCommand(browseChannelUID, StringType.valueOf("C11"));
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is("C11"));
+
+        // Check BROWSE
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("C11")));
+
+        // Check CURRENTTITLE
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("")));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(1));
+        assertThat(handler.entries.get(0).getId(), is("M1"));
+        assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+
+        // Check that BROWSE channel gets the correct state options
+        ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+                stateOptionListCaptor.capture());
+        assertThat(stateOptionListCaptor.getValue().size(), is(2));
+        assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+        assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+
+        // Check media queue serving
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(UPNPRENDERER).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf(rendererThing.getUID().toString())));
+
+        // Check media queue serving
+        verify(rendererHandler).registerQueue(any());
+    }
+
+    @Test
+    public void testBrowseContainers() {
+        logger.info("testBrowseContainers");
+
+        handler.config.filter = false;
+        handler.config.browseDown = false;
+        handler.config.searchFromRoot = false;
+
+        Map<String, String> result = new HashMap<>();
+        result.put("Result", DOUBLE_CONTAINER);
+        doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+        handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is("C1"));
+
+        // Check BROWSE
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("C1")));
+
+        // Check CURRENTTITLE
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("All Audio Items")));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(2));
+        assertThat(handler.entries.get(0).getId(), is("C11"));
+        assertThat(handler.entries.get(0).getTitle(), is("Morning Music"));
+        assertThat(handler.entries.get(1).getId(), is("C12"));
+        assertThat(handler.entries.get(1).getTitle(), is("Evening Music"));
+
+        // Check that BROWSE channel gets the correct state options
+        ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+                stateOptionListCaptor.capture());
+        assertThat(stateOptionListCaptor.getValue().size(), is(3));
+        assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("C11"));
+        assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Morning Music"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("C12"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Evening Music"));
+
+        // Check media queue serving
+        verify(rendererHandler, times(0)).registerQueue(any());
+    }
+
+    @Test
+    public void testBrowseOneContainerNoBrowseDown() {
+        logger.info("testBrowseOneContainerNoBrowseDown");
+
+        handler.config.filter = false;
+        handler.config.browseDown = false;
+        handler.config.searchFromRoot = false;
+
+        Map<String, String> resultContainer = new HashMap<>();
+        resultContainer.put("Result", SINGLE_CONTAINER);
+        Map<String, String> resultMedia = new HashMap<>();
+        resultMedia.put("Result", DOUBLE_MEDIA);
+        doReturn(resultContainer).doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"),
+                eq("Browse"), anyMap());
+
+        handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is("C1"));
+
+        // Check BROWSE
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("C1")));
+
+        // Check CURRENTTITLE
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("All Audio Items")));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(1));
+        assertThat(handler.entries.get(0).getId(), is("C11"));
+        assertThat(handler.entries.get(0).getTitle(), is("Morning Music"));
+
+        // Check that BROWSE channel gets the correct state options
+        ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+                stateOptionListCaptor.capture());
+        assertThat(stateOptionListCaptor.getValue().size(), is(2));
+        assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("C11"));
+        assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Morning Music"));
+
+        // Check that a no media queue is being served as there is no renderer selected
+        verify(rendererHandler, times(0)).registerQueue(any());
+    }
+
+    @Test
+    public void testBrowseOneContainerBrowseDown() {
+        logger.info("testBrowseOneContainerBrowseDown");
+
+        handler.config.filter = false;
+        handler.config.browseDown = true;
+        handler.config.searchFromRoot = false;
+
+        Map<String, String> resultContainer = new HashMap<>();
+        resultContainer.put("Result", SINGLE_CONTAINER);
+        Map<String, String> resultMedia = new HashMap<>();
+        resultMedia.put("Result", DOUBLE_MEDIA);
+        doReturn(resultContainer).doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"),
+                eq("Browse"), anyMap());
+
+        handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is("C11"));
+
+        // Check BROWSE
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("C11")));
+
+        // Check CURRENTTITLE
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("Morning Music")));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(2));
+        assertThat(handler.entries.get(0).getId(), is("M1"));
+        assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+        assertThat(handler.entries.get(1).getId(), is("M2"));
+        assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+
+        // Check that BROWSE channel gets the correct state options
+        ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+                stateOptionListCaptor.capture());
+        assertThat(stateOptionListCaptor.getValue().size(), is(3));
+        assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+        assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("M2"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Music_02"));
+
+        // Check media queue serving
+        verify(rendererHandler, times(0)).registerQueue(any());
+    }
+
+    @Test
+    public void testSearchOneContainerNotFromRootNoBrowseDown() {
+        logger.info("testSearchOneContainerNotFromRootNoBrowseDown");
+
+        handler.config.filter = false;
+        handler.config.browseDown = false;
+        handler.config.searchFromRoot = false;
+
+        // First navigate away from root
+        Map<String, String> result = new HashMap<>();
+        result.put("Result", DOUBLE_CONTAINER);
+        doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+        handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+        Map<String, String> resultContainer = new HashMap<>();
+        resultContainer.put("Result", SINGLE_CONTAINER);
+        Map<String, String> resultMedia = new HashMap<>();
+        resultMedia.put("Result", DOUBLE_MEDIA);
+        doReturn(resultContainer).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"),
+                anyMap());
+        doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+        String searchString = "dc:title contains \"Morning\" and upnp:class derivedfrom \"object.container\"";
+        handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is("C1"));
+
+        // Check BROWSE
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("C1")));
+
+        // Check CURRENTTITLE
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("All Audio Items")));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(1));
+        assertThat(handler.entries.get(0).getId(), is("C11"));
+        assertThat(handler.entries.get(0).getTitle(), is("Morning Music"));
+
+        // Check that BROWSE channel gets the correct state options
+        ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+                stateOptionListCaptor.capture());
+        assertThat(stateOptionListCaptor.getValue().size(), is(2));
+        assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("C11"));
+        assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Morning Music"));
+
+        // Check that a no media queue is being served as there is no renderer selected
+        verify(rendererHandler, times(0)).registerQueue(any());
+    }
+
+    @Test
+    public void testSearchOneContainerNotFromRootBrowseDown() {
+        logger.info("testSearchOneContainerNotFromRootBrowseDown");
+
+        handler.config.filter = false;
+        handler.config.browseDown = true;
+        handler.config.searchFromRoot = false;
+
+        // First navigate away from root
+        Map<String, String> result = new HashMap<>();
+        result.put("Result", DOUBLE_CONTAINER);
+        doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+        handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+        Map<String, String> resultContainer = new HashMap<>();
+        resultContainer.put("Result", SINGLE_CONTAINER);
+        Map<String, String> resultMedia = new HashMap<>();
+        resultMedia.put("Result", DOUBLE_MEDIA);
+        doReturn(resultContainer).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"),
+                anyMap());
+        doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+        String searchString = "dc:title contains \"Morning\" and upnp:class derivedfrom \"object.container\"";
+        handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is("C11"));
+
+        // Check BROWSE
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("C11")));
+
+        // Check CURRENTTITLE
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("Morning Music")));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(2));
+        assertThat(handler.entries.get(0).getId(), is("M1"));
+        assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+        assertThat(handler.entries.get(1).getId(), is("M2"));
+        assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+
+        // Check that BROWSE channel gets the correct state options
+        ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+                stateOptionListCaptor.capture());
+        assertThat(stateOptionListCaptor.getValue().size(), is(3));
+        assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+        assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("M2"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Music_02"));
+
+        // Check that a no media queue is being served as there is no renderer selected
+        verify(rendererHandler, times(0)).registerQueue(any());
+    }
+
+    @Test
+    public void testSearchOneContainerFromRootNoBrowseDown() {
+        logger.info("testSearchOneContainerFromRootNoBrowseDown");
+
+        handler.config.filter = false;
+        handler.config.browseDown = false;
+        handler.config.searchFromRoot = true;
+
+        // First navigate away from root
+        Map<String, String> result = new HashMap<>();
+        result.put("Result", DOUBLE_CONTAINER);
+        doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+        handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+        Map<String, String> resultContainer = new HashMap<>();
+        resultContainer.put("Result", SINGLE_CONTAINER);
+        Map<String, String> resultMedia = new HashMap<>();
+        resultMedia.put("Result", DOUBLE_MEDIA);
+        doReturn(resultContainer).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"),
+                anyMap());
+        doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+        String searchString = "dc:title contains \"Morning\" and upnp:class derivedfrom \"object.container\"";
+        handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is("0"));
+
+        // Check BROWSE
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("0")));
+
+        // Check CURRENTTITLE
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("")));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(1));
+        assertThat(handler.entries.get(0).getId(), is("C11"));
+        assertThat(handler.entries.get(0).getTitle(), is("Morning Music"));
+
+        // Check that BROWSE channel gets the correct state options
+        ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+                stateOptionListCaptor.capture());
+        assertThat(stateOptionListCaptor.getValue().size(), is(2));
+        assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("C11"));
+        assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Morning Music"));
+
+        // Check that a no media queue is being served as there is no renderer selected
+        verify(rendererHandler, times(0)).registerQueue(any());
+    }
+
+    @Test
+    public void testSearchOneContainerFromRootBrowseDown() {
+        logger.info("testSearchOneContainerFromRootBrowseDown");
+
+        handler.config.filter = false;
+        handler.config.browseDown = true;
+        handler.config.searchFromRoot = true;
+
+        // First navigate away from root
+        Map<String, String> result = new HashMap<>();
+        result.put("Result", DOUBLE_CONTAINER);
+        doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+        handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+        Map<String, String> resultContainer = new HashMap<>();
+        resultContainer.put("Result", SINGLE_CONTAINER);
+        Map<String, String> resultMedia = new HashMap<>();
+        resultMedia.put("Result", DOUBLE_MEDIA);
+        doReturn(resultContainer).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"),
+                anyMap());
+        doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+
+        String searchString = "dc:title contains \"Morning\" and upnp:class derivedfrom \"object.container\"";
+        handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is("C11"));
+
+        // Check BROWSE
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("C11")));
+
+        // Check CURRENTTITLE
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("Morning Music")));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(2));
+        assertThat(handler.entries.get(0).getId(), is("M1"));
+        assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+        assertThat(handler.entries.get(1).getId(), is("M2"));
+        assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+
+        // Check that BROWSE channel gets the correct state options
+        ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+                stateOptionListCaptor.capture());
+        assertThat(stateOptionListCaptor.getValue().size(), is(3));
+        assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+        assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("M2"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Music_02"));
+
+        // Check that a no media queue is being served as there is no renderer selected
+        verify(rendererHandler, times(0)).registerQueue(any());
+    }
+
+    @Test
+    public void testSearchMediaFromRootBrowseDownFilter() {
+        logger.info("testSearchMediaFromRootBrowseDownFilter");
+
+        handler.config.filter = true;
+        handler.config.browseDown = true;
+        handler.config.searchFromRoot = true;
+
+        // First navigate away from root
+        Map<String, String> result = new HashMap<>();
+        result.put("Result", DOUBLE_CONTAINER);
+        doReturn(result).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Browse"), anyMap());
+        handler.handleCommand(browseChannelUID, StringType.valueOf("C1"));
+
+        Map<String, String> resultMedia = new HashMap<>();
+        resultMedia.put("Result", DOUBLE_MEDIA);
+        doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"), anyMap());
+
+        String searchString = "dc:title contains \"Music\"";
+        handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is("0"));
+
+        // Check BROWSE
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(BROWSE).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("0")));
+
+        // Check CURRENTTITLE
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(CURRENTTITLE).getUID()),
+                stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("")));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(2));
+        assertThat(handler.entries.get(0).getId(), is("M1"));
+        assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+        assertThat(handler.entries.get(1).getId(), is("M2"));
+        assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+
+        // Check that BROWSE channel gets the correct state options
+        ArgumentCaptor<List<StateOption>> stateOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateStateDescription(eq(thing.getChannel(BROWSE).getUID()),
+                stateOptionListCaptor.capture());
+        assertThat(stateOptionListCaptor.getValue().size(), is(3));
+        assertThat(stateOptionListCaptor.getValue().get(0).getValue(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(0).getLabel(), is(".."));
+        assertThat(stateOptionListCaptor.getValue().get(1).getValue(), is("M1"));
+        assertThat(stateOptionListCaptor.getValue().get(1).getLabel(), is("Music_01"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getValue(), is("M2"));
+        assertThat(stateOptionListCaptor.getValue().get(2).getLabel(), is("Music_02"));
+
+        // Check that a no media queue is being served as there is no renderer selected
+        verify(rendererHandler, times(0)).registerQueue(any());
+    }
+
+    @Test
+    public void testPlaylist() {
+        logger.info("testPlaylist");
+
+        handler.config.filter = false;
+        handler.config.browseDown = false;
+        handler.config.searchFromRoot = true;
+
+        // Check already called in initialize
+        verify(handler).playlistsListChanged();
+
+        // First search for media
+        Map<String, String> resultMedia = new HashMap<>();
+        resultMedia.put("Result", DOUBLE_MEDIA);
+        doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"), anyMap());
+        String searchString = "dc:title contains \"Music\"";
+        handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+        // Save playlist
+        handler.handleCommand(playlistChannelUID, StringType.valueOf("Test_Playlist"));
+        handler.handleCommand(playlistActionChannelUID, StringType.valueOf("SAVE"));
+
+        // Check called after saving playlist
+        verify(handler, times(2)).playlistsListChanged();
+
+        // Check that PLAYLIST_SELECT channel now has the playlist as a state option
+        ArgumentCaptor<List<CommandOption>> commandOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateCommandDescription(eq(thing.getChannel(PLAYLIST_SELECT).getUID()),
+                commandOptionListCaptor.capture());
+        assertThat(commandOptionListCaptor.getValue().size(), is(1));
+        assertThat(commandOptionListCaptor.getValue().get(0).getCommand(), is("Test_Playlist"));
+        assertThat(commandOptionListCaptor.getValue().get(0).getLabel(), is("Test_Playlist"));
+
+        // Clear PLAYLIST channel
+        handler.handleCommand(playlistChannelUID, StringType.valueOf(""));
+
+        // Search for some extra media
+        resultMedia = new HashMap<>();
+        resultMedia.put("Result", EXTRA_MEDIA);
+        doReturn(resultMedia).when(upnpIOService).invokeAction(any(), eq("ContentDirectory"), eq("Search"), anyMap());
+        searchString = "dc:title contains \"Extra\"";
+        handler.handleCommand(searchChannelUID, StringType.valueOf(searchString));
+
+        // Append to playlist
+        handler.handleCommand(playlistSelectChannelUID, StringType.valueOf("Test_Playlist"));
+        handler.handleCommand(playlistActionChannelUID, StringType.valueOf("APPEND"));
+
+        // Check called after appending to playlist
+        verify(handler, times(3)).playlistsListChanged();
+
+        // Check that PLAYLIST channel received "Test_Playlist"
+        ArgumentCaptor<StringType> stringCaptor = ArgumentCaptor.forClass(StringType.class);
+        verify(callback, atLeastOnce()).stateUpdated(eq(thing.getChannel(PLAYLIST).getUID()), stringCaptor.capture());
+        assertThat(stringCaptor.getValue(), is(StringType.valueOf("Test_Playlist")));
+
+        // Clear PLAYLIST channel
+        handler.handleCommand(playlistChannelUID, StringType.valueOf(""));
+
+        // Restore playlist
+        handler.handleCommand(playlistSelectChannelUID, StringType.valueOf("Test_Playlist"));
+        handler.handleCommand(playlistActionChannelUID, StringType.valueOf("RESTORE"));
+
+        // Check currentEntry
+        assertThat(handler.currentEntry.getId(), is("C11"));
+
+        // Check entries
+        assertThat(handler.entries.size(), is(3));
+        assertThat(handler.entries.get(0).getId(), is("M1"));
+        assertThat(handler.entries.get(0).getTitle(), is("Music_01"));
+        assertThat(handler.entries.get(1).getId(), is("M2"));
+        assertThat(handler.entries.get(1).getTitle(), is("Music_02"));
+        assertThat(handler.entries.get(2).getId(), is("M3"));
+        assertThat(handler.entries.get(2).getTitle(), is("Extra_01"));
+
+        // Delete playlist
+        handler.handleCommand(playlistSelectChannelUID, StringType.valueOf("Test_Playlist"));
+        handler.handleCommand(playlistActionChannelUID, StringType.valueOf("DELETE"));
+
+        // Check called after deleting playlist
+        verify(handler, times(4)).playlistsListChanged();
+
+        // Check that PLAYLIST_SELECT channel is empty again
+        commandOptionListCaptor = ArgumentCaptor.forClass(List.class);
+        verify(handler, atLeastOnce()).updateCommandDescription(eq(thing.getChannel(PLAYLIST_SELECT).getUID()),
+                commandOptionListCaptor.capture());
+        assertThat(commandOptionListCaptor.getValue().size(), is(0));
+
+        // select a renderer, so we expect the "current" playlist to be created
+        handler.handleCommand(rendererChannelUID, StringType.valueOf(rendererThing.getUID().toString()));
+
+        // Check called after selecting renderer
+        verify(handler, times(5)).playlistsListChanged();
+
+        // Check that PLAYLIST_SELECT channel received "current" playlist
+        verify(handler, atLeastOnce()).updateCommandDescription(eq(thing.getChannel(PLAYLIST_SELECT).getUID()),
+                commandOptionListCaptor.capture());
+        assertThat(commandOptionListCaptor.getValue().size(), is(1));
+        assertThat(commandOptionListCaptor.getValue().get(0).getCommand(), is("current"));
+        assertThat(commandOptionListCaptor.getValue().get(0).getLabel(), is("current"));
+    }
+}