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