2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.upnpcontrol.internal.handler;
15 import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
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;
23 import java.util.Optional;
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;
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;
62 * The {@link UpnpServerHandler} is responsible for handling commands sent to the UPnP Server. It implements UPnP
63 * ContentDirectory service actions.
65 * @author Mark Herwege - Initial contribution
66 * @author Karel Goderis - Based on UPnP logic in Sonos binding
69 public class UpnpServerHandler extends UpnpHandler {
71 private final Logger logger = LoggerFactory.getLogger(UpnpServerHandler.class);
74 static final String CONTENT_DIRECTORY = "ContentDirectory";
75 static final String DIRECTORY_ROOT = "0";
76 static final String UP = "..";
78 ConcurrentMap<String, UpnpRendererHandler> upnpRenderers;
79 private volatile @Nullable UpnpRendererHandler currentRendererHandler;
80 private volatile List<StateOption> rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
82 private volatile List<CommandOption> playlistCommandOptionList = List.of();
84 private @NonNullByDefault({}) ChannelUID rendererChannelUID;
85 private @NonNullByDefault({}) ChannelUID currentSelectionChannelUID;
86 private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID;
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
92 private static final UpnpEntry ROOT_ENTRY = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
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<>();
100 private volatile String playlistName = "";
102 protected @NonNullByDefault({}) UpnpControlServerConfiguration config;
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;
112 // put root as highest level in parent map
113 parentMap.put(ROOT_ENTRY.getId(), ROOT_ENTRY);
117 public void initialize() {
119 config = getConfigAs(UpnpControlServerConfiguration.class);
121 logger.debug("Initializing handler for media server device {}", thing.getLabel());
123 Channel rendererChannel = thing.getChannel(UPNPRENDERER);
124 if (rendererChannel != null) {
125 rendererChannelUID = rendererChannel.getUID();
127 String msg = String.format("@text/offline.channel-undefined [ \"%s\" ]", UPNPRENDERER);
128 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
131 Channel selectionChannel = thing.getChannel(BROWSE);
132 if (selectionChannel != null) {
133 currentSelectionChannelUID = selectionChannel.getUID();
135 String msg = String.format("@text/offline.channel-undefined [ \"%s\" ]", BROWSE);
136 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
139 Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT);
140 if (playlistSelectChannel != null) {
141 playlistSelectChannelUID = playlistSelectChannel.getUID();
143 String msg = String.format("@text/offline.channel-undefined [ \"%s\" ]", PLAYLIST_SELECT);
144 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
152 public void dispose() {
153 logger.debug("Disposing handler for media server device {}", thing.getLabel());
155 CompletableFuture<Boolean> browsingFuture = isBrowsing;
156 if (browsingFuture != null) {
157 browsingFuture.complete(false);
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);
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);
181 updateStateDescription(rendererChannelUID, rendererStateOptionList);
183 browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
184 playlistsListChanged();
185 updateStatus(ThingStatus.ONLINE);
188 if (!upnpSubscribed) {
195 * Method that does a UPnP browse on a content directory. Results will be retrieved in the {@link onValueReceived}
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
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;
210 if (browsing != null) {
211 // wait for maximum 2.5s until browsing is finished
212 browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
214 } catch (InterruptedException | ExecutionException | TimeoutException e) {
215 logger.debug("Exception, previous server query on {} interrupted or timed out, trying new browse anyway",
220 isBrowsing = new CompletableFuture<Boolean>();
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);
230 invokeAction(CONTENT_DIRECTORY, "Browse", inputs);
232 logger.debug("Cannot browse, cancelled querying server {}", thing.getLabel());
237 * Method that does a UPnP search on a content directory. Results will be retrieved in the {@link onValueReceived}
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
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;
256 if (browsing != null) {
257 // wait for maximum 2.5s until browsing is finished
258 browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
260 } catch (InterruptedException | ExecutionException | TimeoutException e) {
261 logger.debug("Exception, previous server query on {} interrupted or timed out, trying new search anyway",
266 isBrowsing = new CompletableFuture<Boolean>();
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);
276 invokeAction(CONTENT_DIRECTORY, "Search", inputs);
278 logger.debug("Cannot search, cancelled querying server {}", thing.getLabel());
282 protected void updateServerState(ChannelUID channelUID, State state) {
283 updateState(channelUID, state);
287 public void handleCommand(ChannelUID channelUID, Command command) {
288 logger.debug("Handle command {} for channel {} on server {}", command, channelUID, thing.getLabel());
290 switch (channelUID.getId()) {
292 handleCommandUpnpRenderer(channelUID, command);
295 handleCommandCurrentTitle(channelUID, command);
298 handleCommandBrowse(channelUID, command);
301 handleCommandSearch(command);
303 case PLAYLIST_SELECT:
304 handleCommandPlaylistSelect(channelUID, command);
307 handleCommandPlaylist(channelUID, command);
309 case PLAYLIST_ACTION:
310 handleCommandPlaylistAction(command);
316 // Pass these on to the media renderer thing if one is selected
317 handleCommandInRenderer(channelUID, command);
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;
329 // only refresh title list if filtering by renderer capabilities
330 browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
336 if ((renderer != null) && !renderer.equals(previousRenderer)) {
337 if (previousRenderer != null) {
338 previousRenderer.unsetServerHandler();
340 renderer.setServerHandler(this);
343 if ((channel = thing.getChannel(VOLUME)) != null) {
344 handleCommand(channel.getUID(), RefreshType.REFRESH);
346 if ((channel = thing.getChannel(MUTE)) != null) {
347 handleCommand(channel.getUID(), RefreshType.REFRESH);
349 if ((channel = thing.getChannel(CONTROL)) != null) {
350 handleCommand(channel.getUID(), RefreshType.REFRESH);
354 if ((renderer = currentRendererHandler) != null) {
355 updateState(channelUID, StringType.valueOf(renderer.getThing().getUID().toString()));
357 updateState(channelUID, UnDefType.UNDEF);
361 private void handleCommandCurrentTitle(ChannelUID channelUID, Command command) {
362 if (command instanceof RefreshType) {
363 updateState(channelUID, StringType.valueOf(currentEntry.getTitle()));
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)) {
374 browseTarget = currentEntry.getParentId();
375 if (browseTarget.isEmpty()) {
376 // No parent found, so make it the root directory
377 browseTarget = DIRECTORY_ROOT;
381 UpnpEntry entry = parentMap.get(browseTarget);
383 currentEntry = entry;
385 final String target = browseTarget;
386 synchronized (entries) {
387 Optional<UpnpEntry> current = entries.stream().filter(e -> target.equals(e.getId()))
389 if (current.isPresent()) {
390 currentEntry = current.get();
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,
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);
405 } else if (command instanceof RefreshType) {
406 browseTarget = currentEntry.getId();
407 updateState(channelUID, StringType.valueOf(browseTarget));
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();
419 searchContainer = currentEntry.getParentId();
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;
425 UpnpEntry entry = parentMap.get(searchContainer);
427 currentEntry = entry;
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");
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);
441 private void handleCommandPlaylistSelect(ChannelUID channelUID, Command command) {
442 if (command instanceof StringType) {
443 playlistName = command.toString();
444 updateState(PLAYLIST, StringType.valueOf(playlistName));
448 private void handleCommandPlaylist(ChannelUID channelUID, Command command) {
449 if (command instanceof StringType) {
450 playlistName = command.toString();
452 updateState(channelUID, StringType.valueOf(playlistName));
455 private void handleCommandPlaylistAction(Command command) {
456 if (command instanceof StringType) {
457 switch (command.toString()) {
459 handleCommandPlaylistRestore();
462 handleCommandPlaylistSave(false);
465 handleCommandPlaylistSave(true);
468 handleCommandPlaylistDelete();
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;
479 if (browsing != null) {
480 // wait for maximum 2.5s until browsing is finished
481 browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
483 } catch (InterruptedException | ExecutionException | TimeoutException e) {
485 "Exception, previous server on {} query interrupted or timed out, restoring playlist anyway",
489 UpnpEntryQueue queue = new UpnpEntryQueue();
490 queue.restoreQueue(playlistName, config.udn, bindingConfig.path);
491 updateTitleSelection(queue.getEntryList());
494 UpnpEntry current = queue.get(0);
495 if (current != null) {
496 parentId = current.getParentId();
497 UpnpEntry entry = parentMap.get(parentId);
499 currentEntry = entry;
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");
505 parentId = DIRECTORY_ROOT;
506 currentEntry = ROOT_ENTRY;
509 logger.debug("Restoring playlist to node {} on server {}", parentId, thing.getLabel());
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);
520 UpnpEntryQueue queue = new UpnpEntryQueue(mediaQueue, config.udn);
521 queue.persistQueue(playlistName, append, bindingConfig.path);
522 UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
526 private void handleCommandPlaylistDelete() {
527 if (!playlistName.isEmpty()) {
528 UpnpControlUtil.deletePlaylist(playlistName, bindingConfig.path);
529 UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
530 updateState(PLAYLIST, UnDefType.UNDEF);
534 private void handleCommandInRenderer(ChannelUID channelUID, Command command) {
535 String channelId = channelUID.getId();
536 UpnpRendererHandler handler = currentRendererHandler;
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);
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.
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()));
558 updateStateDescription(rendererChannelUID, rendererStateOptionList);
559 logger.debug("Renderer option {} added to {}", key, thing.getLabel());
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.
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);
574 synchronized (rendererStateOptionList) {
575 rendererStateOptionList.removeIf(stateOption -> (stateOption.getValue().equals(key)));
577 updateStateDescription(rendererChannelUID, rendererStateOptionList);
578 logger.debug("Renderer option {} removed from {}", key, thing.getLabel());
582 public void playlistsListChanged() {
583 playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p)))
584 .collect(Collectors.toList());
585 updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList);
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;
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());
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());
609 // Keep the entries in a map so we can find the parent and container for the current selection to go
611 if (value.isContainer()) {
612 parentMap.put(value.getId(), value);
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()));
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.
631 * @param includeContainers
634 private List<UpnpEntry> filterEntries(List<UpnpEntry> resultList, boolean includeContainers) {
635 logger.debug("Server {}, raw result list {}", thing.getLabel(), resultList);
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());
644 logger.debug("Server {}, filtered result list {}", thing.getLabel(), list);
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,
652 if (variable == null) {
657 onValueReceivedResult(value);
659 case "NumberReturned":
664 super.onValueReceived(variable, value, service);
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
676 if (browsing != null) {
677 browsing.complete(true); // Clear previous browse flag before starting new browse
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(),
684 browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
686 updateTitleSelection(removeDuplicates(list));
689 updateTitleSelection(new ArrayList<UpnpEntry>());
692 if (browsing != null) {
693 browsing.complete(true); // We have received browse or search results, so can launch new browse or
699 protected void updateProtocolInfo(String value) {
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.
707 * @return filtered list
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)) {
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);
730 if (mediaQueue.isEmpty()) {
731 logger.debug("Nothing to serve from server {} to renderer {}", thing.getLabel(),
732 handler.getThing().getLabel());
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());
739 // always keep a copy of current list that is being served
740 queue.persistQueue(bindingConfig.path);
741 UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
744 logger.warn("Cannot serve media from server {}, no renderer selected", thing.getLabel());