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.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;
61 * The {@link UpnpServerHandler} is responsible for handling commands sent to the UPnP Server. It implements UPnP
62 * ContentDirectory service actions.
64 * @author Mark Herwege - Initial contribution
65 * @author Karel Goderis - Based on UPnP logic in Sonos binding
68 public class UpnpServerHandler extends UpnpHandler {
70 private final Logger logger = LoggerFactory.getLogger(UpnpServerHandler.class);
73 static final String CONTENT_DIRECTORY = "ContentDirectory";
74 static final String DIRECTORY_ROOT = "0";
75 static final String UP = "..";
77 ConcurrentMap<String, UpnpRendererHandler> upnpRenderers;
78 private volatile @Nullable UpnpRendererHandler currentRendererHandler;
79 private volatile List<StateOption> rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
81 private volatile List<CommandOption> playlistCommandOptionList = List.of();
83 private @NonNullByDefault({}) ChannelUID rendererChannelUID;
84 private @NonNullByDefault({}) ChannelUID currentSelectionChannelUID;
85 private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID;
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
91 private static final UpnpEntry ROOT_ENTRY = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
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<>();
99 private volatile String playlistName = "";
101 protected @NonNullByDefault({}) UpnpControlServerConfiguration config;
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;
111 // put root as highest level in parent map
112 parentMap.put(ROOT_ENTRY.getId(), ROOT_ENTRY);
116 public void initialize() {
118 config = getConfigAs(UpnpControlServerConfiguration.class);
120 logger.debug("Initializing handler for media server device {}", thing.getLabel());
122 Channel rendererChannel = thing.getChannel(UPNPRENDERER);
123 if (rendererChannel != null) {
124 rendererChannelUID = rendererChannel.getUID();
126 String msg = String.format("@text/offline.channel-undefined [ \"%s\" ]", UPNPRENDERER);
127 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
130 Channel selectionChannel = thing.getChannel(BROWSE);
131 if (selectionChannel != null) {
132 currentSelectionChannelUID = selectionChannel.getUID();
134 String msg = String.format("@text/offline.channel-undefined [ \"%s\" ]", BROWSE);
135 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
138 Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT);
139 if (playlistSelectChannel != null) {
140 playlistSelectChannelUID = playlistSelectChannel.getUID();
142 String msg = String.format("@text/offline.channel-undefined [ \"%s\" ]", PLAYLIST_SELECT);
143 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
151 public void dispose() {
152 logger.debug("Disposing handler for media server device {}", thing.getLabel());
154 CompletableFuture<Boolean> browsingFuture = isBrowsing;
155 if (browsingFuture != null) {
156 browsingFuture.complete(false);
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);
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);
180 updateStateDescription(rendererChannelUID, rendererStateOptionList);
182 browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
183 playlistsListChanged();
184 updateStatus(ThingStatus.ONLINE);
187 if (!upnpSubscribed) {
194 * Method that does a UPnP browse on a content directory. Results will be retrieved in the {@link #onValueReceived}
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
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;
209 if (browsing != null) {
210 // wait for maximum 2.5s until browsing is finished
211 browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
213 } catch (InterruptedException | ExecutionException | TimeoutException e) {
214 logger.debug("Exception, previous server query on {} interrupted or timed out, trying new browse anyway",
219 isBrowsing = new CompletableFuture<Boolean>();
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);
229 invokeAction(CONTENT_DIRECTORY, "Browse", inputs);
231 logger.debug("Cannot browse, cancelled querying server {}", thing.getLabel());
236 * Method that does a UPnP search on a content directory. Results will be retrieved in the {@link #onValueReceived}
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
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;
255 if (browsing != null) {
256 // wait for maximum 2.5s until browsing is finished
257 browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
259 } catch (InterruptedException | ExecutionException | TimeoutException e) {
260 logger.debug("Exception, previous server query on {} interrupted or timed out, trying new search anyway",
265 isBrowsing = new CompletableFuture<Boolean>();
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);
275 invokeAction(CONTENT_DIRECTORY, "Search", inputs);
277 logger.debug("Cannot search, cancelled querying server {}", thing.getLabel());
281 protected void updateServerState(ChannelUID channelUID, State state) {
282 updateState(channelUID, state);
286 public void handleCommand(ChannelUID channelUID, Command command) {
287 logger.debug("Handle command {} for channel {} on server {}", command, channelUID, thing.getLabel());
289 switch (channelUID.getId()) {
291 handleCommandUpnpRenderer(channelUID, command);
294 handleCommandCurrentTitle(channelUID, command);
297 handleCommandBrowse(channelUID, command);
300 handleCommandSearch(command);
302 case PLAYLIST_SELECT:
303 handleCommandPlaylistSelect(channelUID, command);
306 handleCommandPlaylist(channelUID, command);
308 case PLAYLIST_ACTION:
309 handleCommandPlaylistAction(command);
315 // Pass these on to the media renderer thing if one is selected
316 handleCommandInRenderer(channelUID, command);
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;
328 // only refresh title list if filtering by renderer capabilities
329 browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
335 if ((renderer != null) && !renderer.equals(previousRenderer)) {
336 if (previousRenderer != null) {
337 previousRenderer.unsetServerHandler();
339 renderer.setServerHandler(this);
342 if ((channel = thing.getChannel(VOLUME)) != null) {
343 handleCommand(channel.getUID(), RefreshType.REFRESH);
345 if ((channel = thing.getChannel(MUTE)) != null) {
346 handleCommand(channel.getUID(), RefreshType.REFRESH);
348 if ((channel = thing.getChannel(CONTROL)) != null) {
349 handleCommand(channel.getUID(), RefreshType.REFRESH);
353 if ((renderer = currentRendererHandler) != null) {
354 updateState(channelUID, StringType.valueOf(renderer.getThing().getUID().toString()));
356 updateState(channelUID, UnDefType.UNDEF);
360 private void handleCommandCurrentTitle(ChannelUID channelUID, Command command) {
361 if (command instanceof RefreshType) {
362 updateState(channelUID, StringType.valueOf(currentEntry.getTitle()));
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)) {
373 browseTarget = currentEntry.getParentId();
374 if (browseTarget.isEmpty()) {
375 // No parent found, so make it the root directory
376 browseTarget = DIRECTORY_ROOT;
380 UpnpEntry entry = parentMap.get(browseTarget);
382 currentEntry = entry;
384 final String target = browseTarget;
385 synchronized (entries) {
386 Optional<UpnpEntry> current = entries.stream().filter(e -> target.equals(e.getId()))
388 if (current.isPresent()) {
389 currentEntry = current.get();
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,
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);
404 } else if (command instanceof RefreshType) {
405 browseTarget = currentEntry.getId();
406 updateState(channelUID, StringType.valueOf(browseTarget));
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();
418 searchContainer = currentEntry.getParentId();
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;
424 UpnpEntry entry = parentMap.get(searchContainer);
426 currentEntry = entry;
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");
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);
440 private void handleCommandPlaylistSelect(ChannelUID channelUID, Command command) {
441 if (command instanceof StringType) {
442 playlistName = command.toString();
443 updateState(PLAYLIST, StringType.valueOf(playlistName));
447 private void handleCommandPlaylist(ChannelUID channelUID, Command command) {
448 if (command instanceof StringType) {
449 playlistName = command.toString();
451 updateState(channelUID, StringType.valueOf(playlistName));
454 private void handleCommandPlaylistAction(Command command) {
455 if (command instanceof StringType) {
456 switch (command.toString()) {
458 handleCommandPlaylistRestore();
461 handleCommandPlaylistSave(false);
464 handleCommandPlaylistSave(true);
467 handleCommandPlaylistDelete();
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;
478 if (browsing != null) {
479 // wait for maximum 2.5s until browsing is finished
480 browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
482 } catch (InterruptedException | ExecutionException | TimeoutException e) {
484 "Exception, previous server on {} query interrupted or timed out, restoring playlist anyway",
488 UpnpEntryQueue queue = new UpnpEntryQueue();
489 queue.restoreQueue(playlistName, config.udn, bindingConfig.path);
490 updateTitleSelection(queue.getEntryList());
493 UpnpEntry current = queue.get(0);
494 if (current != null) {
495 parentId = current.getParentId();
496 UpnpEntry entry = parentMap.get(parentId);
498 currentEntry = entry;
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");
504 parentId = DIRECTORY_ROOT;
505 currentEntry = ROOT_ENTRY;
508 logger.debug("Restoring playlist to node {} on server {}", parentId, thing.getLabel());
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);
519 UpnpEntryQueue queue = new UpnpEntryQueue(mediaQueue, config.udn);
520 queue.persistQueue(playlistName, append, bindingConfig.path);
521 UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
525 private void handleCommandPlaylistDelete() {
526 if (!playlistName.isEmpty()) {
527 UpnpControlUtil.deletePlaylist(playlistName, bindingConfig.path);
528 UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
529 updateState(PLAYLIST, UnDefType.UNDEF);
533 private void handleCommandInRenderer(ChannelUID channelUID, Command command) {
534 String channelId = channelUID.getId();
535 UpnpRendererHandler handler = currentRendererHandler;
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);
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.
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 org.openhab.binding.upnpcontrol.internal.UpnpControlHandlerFactory
565 * UpnpControlHandlerFactory} class when removing a renderer handler.
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);
575 synchronized (rendererStateOptionList) {
576 rendererStateOptionList.removeIf(stateOption -> (stateOption.getValue().equals(key)));
578 updateStateDescription(rendererChannelUID, rendererStateOptionList);
579 logger.debug("Renderer option {} removed from {}", key, thing.getLabel());
583 public void playlistsListChanged() {
584 playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p)))
585 .collect(Collectors.toList());
586 updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList);
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;
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());
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());
610 // Keep the entries in a map so we can find the parent and container for the current selection to go
612 if (value.isContainer()) {
613 parentMap.put(value.getId(), value);
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()));
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.
632 * @param includeContainers
635 private List<UpnpEntry> filterEntries(List<UpnpEntry> resultList, boolean includeContainers) {
636 logger.debug("Server {}, raw result list {}", thing.getLabel(), resultList);
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());
645 logger.debug("Server {}, filtered result list {}", thing.getLabel(), list);
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,
653 if (variable == null) {
658 onValueReceivedResult(value);
660 case "NumberReturned":
665 super.onValueReceived(variable, value, service);
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
677 if (browsing != null) {
678 browsing.complete(true); // Clear previous browse flag before starting new browse
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(),
685 browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
687 updateTitleSelection(removeDuplicates(list));
690 updateTitleSelection(new ArrayList<UpnpEntry>());
693 if (browsing != null) {
694 browsing.complete(true); // We have received browse or search results, so can launch new browse or
700 protected void updateProtocolInfo(String value) {
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.
708 * @return filtered list
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)) {
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);
731 if (mediaQueue.isEmpty()) {
732 logger.debug("Nothing to serve from server {} to renderer {}", thing.getLabel(),
733 handler.getThing().getLabel());
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());
740 // always keep a copy of current list that is being served
741 queue.persistQueue(bindingConfig.path);
742 UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
745 logger.warn("Cannot serve media from server {}, no renderer selected", thing.getLabel());