]> git.basschouten.com Git - openhab-addons.git/blob
d8d84f381da795c76a3a607cc2c064b99ff1ead1
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.upnpcontrol.internal.handler;
14
15 import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
16
17 import java.util.ArrayList;
18 import java.util.Collections;
19 import java.util.HashMap;
20 import java.util.HashSet;
21 import java.util.List;
22 import java.util.Map;
23 import java.util.Optional;
24 import java.util.Set;
25 import java.util.concurrent.CompletableFuture;
26 import java.util.concurrent.ConcurrentHashMap;
27 import java.util.concurrent.ConcurrentMap;
28 import java.util.concurrent.ExecutionException;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.TimeoutException;
31 import java.util.stream.Collectors;
32
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.binding.upnpcontrol.internal.UpnpControlHandlerFactory;
36 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
37 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
38 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
39 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlServerConfiguration;
40 import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
41 import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue;
42 import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
43 import org.openhab.binding.upnpcontrol.internal.util.UpnpProtocolMatcher;
44 import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser;
45 import org.openhab.core.io.transport.upnp.UpnpIOService;
46 import org.openhab.core.library.types.StringType;
47 import org.openhab.core.thing.Channel;
48 import org.openhab.core.thing.ChannelUID;
49 import org.openhab.core.thing.Thing;
50 import org.openhab.core.thing.ThingStatus;
51 import org.openhab.core.thing.ThingStatusDetail;
52 import org.openhab.core.types.Command;
53 import org.openhab.core.types.CommandOption;
54 import org.openhab.core.types.RefreshType;
55 import org.openhab.core.types.State;
56 import org.openhab.core.types.StateOption;
57 import org.openhab.core.types.UnDefType;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
60
61 /**
62  * The {@link UpnpServerHandler} is responsible for handling commands sent to the UPnP Server. It implements UPnP
63  * ContentDirectory service actions.
64  *
65  * @author Mark Herwege - Initial contribution
66  * @author Karel Goderis - Based on UPnP logic in Sonos binding
67  */
68 @NonNullByDefault
69 public class UpnpServerHandler extends UpnpHandler {
70
71     private final Logger logger = LoggerFactory.getLogger(UpnpServerHandler.class);
72
73     // UPnP constants
74     static final String CONTENT_DIRECTORY = "ContentDirectory";
75     static final String DIRECTORY_ROOT = "0";
76     static final String UP = "..";
77
78     ConcurrentMap<String, UpnpRendererHandler> upnpRenderers;
79     private volatile @Nullable UpnpRendererHandler currentRendererHandler;
80     private volatile List<StateOption> rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
81
82     private volatile List<CommandOption> playlistCommandOptionList = List.of();
83
84     private @NonNullByDefault({}) ChannelUID rendererChannelUID;
85     private @NonNullByDefault({}) ChannelUID currentSelectionChannelUID;
86     private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID;
87
88     private volatile @Nullable CompletableFuture<Boolean> isBrowsing;
89     private volatile boolean browseUp = false; // used to avoid automatically going down a level if only one container
90                                                // entry found when going up in the hierarchy
91
92     private static final UpnpEntry ROOT_ENTRY = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
93             "object.container");
94     volatile UpnpEntry currentEntry = ROOT_ENTRY;
95     // current entry list in selection
96     List<UpnpEntry> entries = Collections.synchronizedList(new ArrayList<>());
97     // store parents in hierarchy separately to be able to move up in directory structure
98     private ConcurrentMap<String, UpnpEntry> parentMap = new ConcurrentHashMap<>();
99
100     private volatile String playlistName = "";
101
102     protected @NonNullByDefault({}) UpnpControlServerConfiguration config;
103
104     public UpnpServerHandler(Thing thing, UpnpIOService upnpIOService,
105             ConcurrentMap<String, UpnpRendererHandler> upnpRenderers,
106             UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
107             UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider,
108             UpnpControlBindingConfiguration configuration) {
109         super(thing, upnpIOService, configuration, upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
110         this.upnpRenderers = upnpRenderers;
111
112         // put root as highest level in parent map
113         parentMap.put(ROOT_ENTRY.getId(), ROOT_ENTRY);
114     }
115
116     @Override
117     public void initialize() {
118         super.initialize();
119         config = getConfigAs(UpnpControlServerConfiguration.class);
120
121         logger.debug("Initializing handler for media server device {}", thing.getLabel());
122
123         Channel rendererChannel = thing.getChannel(UPNPRENDERER);
124         if (rendererChannel != null) {
125             rendererChannelUID = rendererChannel.getUID();
126         } else {
127             String msg = String.format("@text/offline.channel-undefined [ \"%s\" ]", UPNPRENDERER);
128             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
129             return;
130         }
131         Channel selectionChannel = thing.getChannel(BROWSE);
132         if (selectionChannel != null) {
133             currentSelectionChannelUID = selectionChannel.getUID();
134         } else {
135             String msg = String.format("@text/offline.channel-undefined [ \"%s\" ]", BROWSE);
136             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
137             return;
138         }
139         Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT);
140         if (playlistSelectChannel != null) {
141             playlistSelectChannelUID = playlistSelectChannel.getUID();
142         } else {
143             String msg = String.format("@text/offline.channel-undefined [ \"%s\" ]", PLAYLIST_SELECT);
144             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
145             return;
146         }
147
148         initDevice();
149     }
150
151     @Override
152     public void dispose() {
153         logger.debug("Disposing handler for media server device {}", thing.getLabel());
154
155         CompletableFuture<Boolean> browsingFuture = isBrowsing;
156         if (browsingFuture != null) {
157             browsingFuture.complete(false);
158             isBrowsing = null;
159         }
160
161         super.dispose();
162     }
163
164     @Override
165     protected void initJob() {
166         synchronized (jobLock) {
167             if (!upnpIOService.isRegistered(this)) {
168                 String msg = String.format("@text/offline.device-not-registered [ \"%s\" ]", getUDN());
169                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
170                 return;
171             }
172
173             if (!ThingStatus.ONLINE.equals(thing.getStatus())) {
174                 rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
175                 synchronized (rendererStateOptionList) {
176                     upnpRenderers.forEach((key, value) -> {
177                         StateOption stateOption = new StateOption(key, value.getThing().getLabel());
178                         rendererStateOptionList.add(stateOption);
179                     });
180                 }
181                 updateStateDescription(rendererChannelUID, rendererStateOptionList);
182                 getProtocolInfo();
183                 browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
184                 playlistsListChanged();
185                 updateStatus(ThingStatus.ONLINE);
186             }
187
188             if (!upnpSubscribed) {
189                 addSubscriptions();
190             }
191         }
192     }
193
194     /**
195      * Method that does a UPnP browse on a content directory. Results will be retrieved in the {@link onValueReceived}
196      * method.
197      *
198      * @param objectID content directory object
199      * @param browseFlag BrowseMetaData or BrowseDirectChildren
200      * @param filter properties to be returned
201      * @param startingIndex starting index of objects to return
202      * @param requestedCount number of objects to return, 0 for all
203      * @param sortCriteria sort criteria, example: +dc:title
204      */
205     protected void browse(String objectID, String browseFlag, String filter, String startingIndex,
206             String requestedCount, String sortCriteria) {
207         CompletableFuture<Boolean> browsing = isBrowsing;
208         boolean browsed = true;
209         try {
210             if (browsing != null) {
211                 // wait for maximum 2.5s until browsing is finished
212                 browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
213             }
214         } catch (InterruptedException | ExecutionException | TimeoutException e) {
215             logger.debug("Exception, previous server query on {} interrupted or timed out, trying new browse anyway",
216                     thing.getLabel());
217         }
218
219         if (browsed) {
220             isBrowsing = new CompletableFuture<Boolean>();
221
222             Map<String, String> inputs = new HashMap<>();
223             inputs.put("ObjectID", objectID);
224             inputs.put("BrowseFlag", browseFlag);
225             inputs.put("Filter", filter);
226             inputs.put("StartingIndex", startingIndex);
227             inputs.put("RequestedCount", requestedCount);
228             inputs.put("SortCriteria", sortCriteria);
229
230             invokeAction(CONTENT_DIRECTORY, "Browse", inputs);
231         } else {
232             logger.debug("Cannot browse, cancelled querying server {}", thing.getLabel());
233         }
234     }
235
236     /**
237      * Method that does a UPnP search on a content directory. Results will be retrieved in the {@link onValueReceived}
238      * method.
239      *
240      * @param containerID content directory container
241      * @param searchCriteria search criteria, examples:
242      *            dc:title contains "song"
243      *            dc:creator contains "Springsteen"
244      *            upnp:class = "object.item.audioItem"
245      *            upnp:album contains "Born in"
246      * @param filter properties to be returned
247      * @param startingIndex starting index of objects to return
248      * @param requestedCount number of objects to return, 0 for all
249      * @param sortCriteria sort criteria, example: +dc:title
250      */
251     protected void search(String containerID, String searchCriteria, String filter, String startingIndex,
252             String requestedCount, String sortCriteria) {
253         CompletableFuture<Boolean> browsing = isBrowsing;
254         boolean browsed = true;
255         try {
256             if (browsing != null) {
257                 // wait for maximum 2.5s until browsing is finished
258                 browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
259             }
260         } catch (InterruptedException | ExecutionException | TimeoutException e) {
261             logger.debug("Exception, previous server query on {} interrupted or timed out, trying new search anyway",
262                     thing.getLabel());
263         }
264
265         if (browsed) {
266             isBrowsing = new CompletableFuture<Boolean>();
267
268             Map<String, String> inputs = new HashMap<>();
269             inputs.put("ContainerID", containerID);
270             inputs.put("SearchCriteria", searchCriteria);
271             inputs.put("Filter", filter);
272             inputs.put("StartingIndex", startingIndex);
273             inputs.put("RequestedCount", requestedCount);
274             inputs.put("SortCriteria", sortCriteria);
275
276             invokeAction(CONTENT_DIRECTORY, "Search", inputs);
277         } else {
278             logger.debug("Cannot search, cancelled querying server {}", thing.getLabel());
279         }
280     }
281
282     protected void updateServerState(ChannelUID channelUID, State state) {
283         updateState(channelUID, state);
284     }
285
286     @Override
287     public void handleCommand(ChannelUID channelUID, Command command) {
288         logger.debug("Handle command {} for channel {} on server {}", command, channelUID, thing.getLabel());
289
290         switch (channelUID.getId()) {
291             case UPNPRENDERER:
292                 handleCommandUpnpRenderer(channelUID, command);
293                 break;
294             case CURRENTTITLE:
295                 handleCommandCurrentTitle(channelUID, command);
296                 break;
297             case BROWSE:
298                 handleCommandBrowse(channelUID, command);
299                 break;
300             case SEARCH:
301                 handleCommandSearch(command);
302                 break;
303             case PLAYLIST_SELECT:
304                 handleCommandPlaylistSelect(channelUID, command);
305                 break;
306             case PLAYLIST:
307                 handleCommandPlaylist(channelUID, command);
308                 break;
309             case PLAYLIST_ACTION:
310                 handleCommandPlaylistAction(command);
311                 break;
312             case VOLUME:
313             case MUTE:
314             case CONTROL:
315             case STOP:
316                 // Pass these on to the media renderer thing if one is selected
317                 handleCommandInRenderer(channelUID, command);
318                 break;
319         }
320     }
321
322     private void handleCommandUpnpRenderer(ChannelUID channelUID, Command command) {
323         UpnpRendererHandler renderer = null;
324         UpnpRendererHandler previousRenderer = currentRendererHandler;
325         if (command instanceof StringType) {
326             renderer = (upnpRenderers.get(((StringType) command).toString()));
327             currentRendererHandler = renderer;
328             if (config.filter) {
329                 // only refresh title list if filtering by renderer capabilities
330                 browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
331             } else {
332                 serveMedia();
333             }
334         }
335
336         if ((renderer != null) && !renderer.equals(previousRenderer)) {
337             if (previousRenderer != null) {
338                 previousRenderer.unsetServerHandler();
339             }
340             renderer.setServerHandler(this);
341
342             Channel channel;
343             if ((channel = thing.getChannel(VOLUME)) != null) {
344                 handleCommand(channel.getUID(), RefreshType.REFRESH);
345             }
346             if ((channel = thing.getChannel(MUTE)) != null) {
347                 handleCommand(channel.getUID(), RefreshType.REFRESH);
348             }
349             if ((channel = thing.getChannel(CONTROL)) != null) {
350                 handleCommand(channel.getUID(), RefreshType.REFRESH);
351             }
352         }
353
354         if ((renderer = currentRendererHandler) != null) {
355             updateState(channelUID, StringType.valueOf(renderer.getThing().getUID().toString()));
356         } else {
357             updateState(channelUID, UnDefType.UNDEF);
358         }
359     }
360
361     private void handleCommandCurrentTitle(ChannelUID channelUID, Command command) {
362         if (command instanceof RefreshType) {
363             updateState(channelUID, StringType.valueOf(currentEntry.getTitle()));
364         }
365     }
366
367     private void handleCommandBrowse(ChannelUID channelUID, Command command) {
368         String browseTarget = "";
369         if (command instanceof StringType) {
370             browseTarget = command.toString();
371             if (!browseTarget.isEmpty()) {
372                 if (UP.equals(browseTarget)) {
373                     // Move up in tree
374                     browseTarget = currentEntry.getParentId();
375                     if (browseTarget.isEmpty()) {
376                         // No parent found, so make it the root directory
377                         browseTarget = DIRECTORY_ROOT;
378                     }
379                     browseUp = true;
380                 }
381                 UpnpEntry entry = parentMap.get(browseTarget);
382                 if (entry != null) {
383                     currentEntry = entry;
384                 } else {
385                     final String target = browseTarget;
386                     synchronized (entries) {
387                         Optional<UpnpEntry> current = entries.stream().filter(e -> target.equals(e.getId()))
388                                 .findFirst();
389                         if (current.isPresent()) {
390                             currentEntry = current.get();
391                         } else {
392                             // The real entry is not in the parentMap or options list yet, so construct a default one
393                             currentEntry = new UpnpEntry(browseTarget, browseTarget, DIRECTORY_ROOT,
394                                     "object.container");
395                         }
396                     }
397                 }
398
399                 logger.debug("Browse target {}", browseTarget);
400                 logger.debug("Navigating to node {} on server {}", currentEntry.getId(), thing.getLabel());
401                 updateState(channelUID, StringType.valueOf(browseTarget));
402                 updateState(CURRENTTITLE, StringType.valueOf(currentEntry.getTitle()));
403                 browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
404             }
405         } else if (command instanceof RefreshType) {
406             browseTarget = currentEntry.getId();
407             updateState(channelUID, StringType.valueOf(browseTarget));
408         }
409     }
410
411     private void handleCommandSearch(Command command) {
412         if (command instanceof StringType) {
413             String criteria = command.toString();
414             if (!criteria.isEmpty()) {
415                 String searchContainer = "";
416                 if (currentEntry.isContainer()) {
417                     searchContainer = currentEntry.getId();
418                 } else {
419                     searchContainer = currentEntry.getParentId();
420                 }
421                 if (config.searchFromRoot || searchContainer.isEmpty()) {
422                     // Config option search from root or no parent found, so make it the root directory
423                     searchContainer = DIRECTORY_ROOT;
424                 }
425                 UpnpEntry entry = parentMap.get(searchContainer);
426                 if (entry != null) {
427                     currentEntry = entry;
428                 } else {
429                     // The real entry is not in the parentMap yet, so construct a default one
430                     currentEntry = new UpnpEntry(searchContainer, searchContainer, DIRECTORY_ROOT, "object.container");
431                 }
432
433                 logger.debug("Navigating to node {} on server {}", searchContainer, thing.getLabel());
434                 updateState(BROWSE, StringType.valueOf(currentEntry.getId()));
435                 logger.debug("Search container {} for {}", searchContainer, criteria);
436                 search(searchContainer, criteria, "*", "0", "0", config.sortCriteria);
437             }
438         }
439     }
440
441     private void handleCommandPlaylistSelect(ChannelUID channelUID, Command command) {
442         if (command instanceof StringType) {
443             playlistName = command.toString();
444             updateState(PLAYLIST, StringType.valueOf(playlistName));
445         }
446     }
447
448     private void handleCommandPlaylist(ChannelUID channelUID, Command command) {
449         if (command instanceof StringType) {
450             playlistName = command.toString();
451         }
452         updateState(channelUID, StringType.valueOf(playlistName));
453     }
454
455     private void handleCommandPlaylistAction(Command command) {
456         if (command instanceof StringType) {
457             switch (command.toString()) {
458                 case RESTORE:
459                     handleCommandPlaylistRestore();
460                     break;
461                 case SAVE:
462                     handleCommandPlaylistSave(false);
463                     break;
464                 case APPEND:
465                     handleCommandPlaylistSave(true);
466                     break;
467                 case DELETE:
468                     handleCommandPlaylistDelete();
469                     break;
470             }
471         }
472     }
473
474     private void handleCommandPlaylistRestore() {
475         if (!playlistName.isEmpty()) {
476             // Don't immediately restore a playlist if a browse or search is still underway, or it could get overwritten
477             CompletableFuture<Boolean> browsing = isBrowsing;
478             try {
479                 if (browsing != null) {
480                     // wait for maximum 2.5s until browsing is finished
481                     browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
482                 }
483             } catch (InterruptedException | ExecutionException | TimeoutException e) {
484                 logger.debug(
485                         "Exception, previous server on {} query interrupted or timed out, restoring playlist anyway",
486                         thing.getLabel());
487             }
488
489             UpnpEntryQueue queue = new UpnpEntryQueue();
490             queue.restoreQueue(playlistName, config.udn, bindingConfig.path);
491             updateTitleSelection(queue.getEntryList());
492
493             String parentId;
494             UpnpEntry current = queue.get(0);
495             if (current != null) {
496                 parentId = current.getParentId();
497                 UpnpEntry entry = parentMap.get(parentId);
498                 if (entry != null) {
499                     currentEntry = entry;
500                 } else {
501                     // The real entry is not in the parentMap yet, so construct a default one
502                     currentEntry = new UpnpEntry(parentId, parentId, DIRECTORY_ROOT, "object.container");
503                 }
504             } else {
505                 parentId = DIRECTORY_ROOT;
506                 currentEntry = ROOT_ENTRY;
507             }
508
509             logger.debug("Restoring playlist to node {} on server {}", parentId, thing.getLabel());
510         }
511     }
512
513     private void handleCommandPlaylistSave(boolean append) {
514         if (!playlistName.isEmpty()) {
515             List<UpnpEntry> mediaQueue = new ArrayList<>();
516             mediaQueue.addAll(entries);
517             if (mediaQueue.isEmpty() && !currentEntry.isContainer()) {
518                 mediaQueue.add(currentEntry);
519             }
520             UpnpEntryQueue queue = new UpnpEntryQueue(mediaQueue, config.udn);
521             queue.persistQueue(playlistName, append, bindingConfig.path);
522             UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
523         }
524     }
525
526     private void handleCommandPlaylistDelete() {
527         if (!playlistName.isEmpty()) {
528             UpnpControlUtil.deletePlaylist(playlistName, bindingConfig.path);
529             UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
530             updateState(PLAYLIST, UnDefType.UNDEF);
531         }
532     }
533
534     private void handleCommandInRenderer(ChannelUID channelUID, Command command) {
535         String channelId = channelUID.getId();
536         UpnpRendererHandler handler = currentRendererHandler;
537         Channel channel;
538         if ((handler != null) && (channel = handler.getThing().getChannel(channelId)) != null) {
539             handler.handleCommand(channel.getUID(), command);
540         } else if (!STOP.equals(channelId)) {
541             updateState(channelId, UnDefType.UNDEF);
542         }
543     }
544
545     /**
546      * Add a renderer to the renderer channel state option list.
547      * This method is called from the {@link UpnpControlHandlerFactory} class when creating a renderer handler.
548      *
549      * @param key
550      */
551     public void addRendererOption(String key) {
552         synchronized (rendererStateOptionList) {
553             UpnpRendererHandler handler = upnpRenderers.get(key);
554             if (handler != null) {
555                 rendererStateOptionList.add(new StateOption(key, handler.getThing().getLabel()));
556             }
557         }
558         updateStateDescription(rendererChannelUID, rendererStateOptionList);
559         logger.debug("Renderer option {} added to {}", key, thing.getLabel());
560     }
561
562     /**
563      * Remove a renderer from the renderer channel state option list.
564      * This method is called from the {@link UpnpControlHandlerFactory} class when removing a renderer handler.
565      *
566      * @param key
567      */
568     public void removeRendererOption(String key) {
569         UpnpRendererHandler handler = currentRendererHandler;
570         if ((handler != null) && (handler.getThing().getUID().toString().equals(key))) {
571             currentRendererHandler = null;
572             updateState(rendererChannelUID, UnDefType.UNDEF);
573         }
574         synchronized (rendererStateOptionList) {
575             rendererStateOptionList.removeIf(stateOption -> (stateOption.getValue().equals(key)));
576         }
577         updateStateDescription(rendererChannelUID, rendererStateOptionList);
578         logger.debug("Renderer option {} removed from {}", key, thing.getLabel());
579     }
580
581     @Override
582     public void playlistsListChanged() {
583         playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p)))
584                 .collect(Collectors.toList());
585         updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList);
586     }
587
588     private void updateTitleSelection(List<UpnpEntry> titleList) {
589         // Optionally, filter only items that can be played on the renderer
590         logger.debug("Filtering content on server {}: {}", thing.getLabel(), config.filter);
591         List<UpnpEntry> resultList = config.filter ? filterEntries(titleList, true) : titleList;
592
593         List<StateOption> stateOptionList = new ArrayList<>();
594         // Add a directory up selector if not in the directory root
595         if ((!resultList.isEmpty() && !(DIRECTORY_ROOT.equals(resultList.get(0).getParentId())))
596                 || (resultList.isEmpty() && !DIRECTORY_ROOT.equals(currentEntry.getId()))) {
597             StateOption stateOption = new StateOption(UP, UP);
598             stateOptionList.add(stateOption);
599             logger.debug("UP added to selection list on server {}", thing.getLabel());
600         }
601
602         synchronized (entries) {
603             entries.clear(); // always only keep the current selection in the entry map to keep memory usage down
604             resultList.forEach((value) -> {
605                 StateOption stateOption = new StateOption(value.getId(), value.getTitle());
606                 stateOptionList.add(stateOption);
607                 logger.trace("{} added to selection list on server {}", value.getId(), thing.getLabel());
608
609                 // Keep the entries in a map so we can find the parent and container for the current selection to go
610                 // back up
611                 if (value.isContainer()) {
612                     parentMap.put(value.getId(), value);
613                 }
614                 entries.add(value);
615             });
616         }
617
618         logger.debug("{} entries added to selection list on server {}", stateOptionList.size(), thing.getLabel());
619         updateStateDescription(currentSelectionChannelUID, stateOptionList);
620         updateState(BROWSE, StringType.valueOf(currentEntry.getId()));
621         updateState(CURRENTTITLE, StringType.valueOf(currentEntry.getTitle()));
622
623         serveMedia();
624     }
625
626     /**
627      * Filter a list of media and only keep the media that are playable on the currently selected renderer. Return all
628      * if no renderer is selected.
629      *
630      * @param resultList
631      * @param includeContainers
632      * @return
633      */
634     private List<UpnpEntry> filterEntries(List<UpnpEntry> resultList, boolean includeContainers) {
635         logger.debug("Server {}, raw result list {}", thing.getLabel(), resultList);
636
637         UpnpRendererHandler handler = currentRendererHandler;
638         List<String> sink = (handler != null) ? handler.getSink() : null;
639         List<UpnpEntry> list = resultList.stream()
640                 .filter(entry -> ((includeContainers && entry.isContainer()) || (sink == null) && !entry.isContainer())
641                         || ((sink != null) && UpnpProtocolMatcher.testProtocolList(entry.getProtocolList(), sink)))
642                 .collect(Collectors.toList());
643
644         logger.debug("Server {}, filtered result list {}", thing.getLabel(), list);
645         return list;
646     }
647
648     @Override
649     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
650         logger.debug("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(), variable,
651                 value, service);
652         if (variable == null) {
653             return;
654         }
655         switch (variable) {
656             case "Result":
657                 onValueReceivedResult(value);
658                 break;
659             case "NumberReturned":
660             case "TotalMatches":
661             case "UpdateID":
662                 break;
663             default:
664                 super.onValueReceived(variable, value, service);
665                 break;
666         }
667     }
668
669     private void onValueReceivedResult(@Nullable String value) {
670         CompletableFuture<Boolean> browsing = isBrowsing;
671         if (!((value == null) || (value.isEmpty()))) {
672             List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
673             if (config.browseDown && (list.size() == 1) && list.get(0).isContainer() && !browseUp) {
674                 // We only received one container entry, so we immediately browse to the next level if config.browsedown
675                 // = true
676                 if (browsing != null) {
677                     browsing.complete(true); // Clear previous browse flag before starting new browse
678                 }
679                 currentEntry = list.get(0);
680                 String browseTarget = currentEntry.getId();
681                 parentMap.put(browseTarget, currentEntry);
682                 logger.debug("Server {}, browsing down one level to the unique container result {}", thing.getLabel(),
683                         browseTarget);
684                 browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
685             } else {
686                 updateTitleSelection(removeDuplicates(list));
687             }
688         } else {
689             updateTitleSelection(new ArrayList<UpnpEntry>());
690         }
691         browseUp = false;
692         if (browsing != null) {
693             browsing.complete(true); // We have received browse or search results, so can launch new browse or
694                                      // search
695         }
696     }
697
698     @Override
699     protected void updateProtocolInfo(String value) {
700     }
701
702     /**
703      * Remove double entries by checking the refId if it exists as Id in the list and only keeping the original entry if
704      * available. If the original entry is not in the list, only keep one referring entry.
705      *
706      * @param list
707      * @return filtered list
708      */
709     private List<UpnpEntry> removeDuplicates(List<UpnpEntry> list) {
710         List<UpnpEntry> newList = new ArrayList<>();
711         Set<String> refIdSet = new HashSet<>();
712         list.forEach(entry -> {
713             String refId = entry.getRefId();
714             if (refId.isEmpty() || !refIdSet.contains(refId)) {
715                 newList.add(entry);
716                 refIdSet.add(refId);
717             }
718         });
719         return newList;
720     }
721
722     private void serveMedia() {
723         UpnpRendererHandler handler = currentRendererHandler;
724         if (handler != null) {
725             List<UpnpEntry> mediaQueue = new ArrayList<>();
726             mediaQueue.addAll(filterEntries(entries, false));
727             if (mediaQueue.isEmpty() && !currentEntry.isContainer()) {
728                 mediaQueue.add(currentEntry);
729             }
730             if (mediaQueue.isEmpty()) {
731                 logger.debug("Nothing to serve from server {} to renderer {}", thing.getLabel(),
732                         handler.getThing().getLabel());
733             } else {
734                 UpnpEntryQueue queue = new UpnpEntryQueue(mediaQueue, getUDN());
735                 handler.registerQueue(queue);
736                 logger.debug("Serving media queue {} from server {} to renderer {}", mediaQueue, thing.getLabel(),
737                         handler.getThing().getLabel());
738
739                 // always keep a copy of current list that is being served
740                 queue.persistQueue(bindingConfig.path);
741                 UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
742             }
743         } else {
744             logger.warn("Cannot serve media from server {}, no renderer selected", thing.getLabel());
745         }
746     }
747 }