From 40e0b845bdbad5e59fba1174108c6989d1a885ad Mon Sep 17 00:00:00 2001 From: druciak Date: Thu, 21 Nov 2024 17:20:02 +0100 Subject: [PATCH] [Onkyo] Set "CurrentURIMetaData" for "SetAVTransportURI" action (#17755) (#17770) * [Onkyo] Set "CurrentURIMetaData" for "SetAVTransportURI" action (#17755) Signed-off-by: Krzysztof Goworek Signed-off-by: Ciprian Pascu --- .../internal/handler/OnkyoAudioSink.java | 8 +- .../internal/handler/OnkyoUpnpHandler.java | 11 +- .../handler/URIMetaDataProcessor.java | 118 ++++++++++++++++++ .../handler/URIMetaDataProcessorTest.java | 93 ++++++++++++++ 4 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/URIMetaDataProcessor.java create mode 100644 bundles/org.openhab.binding.onkyo/src/test/java/org/openhab/binding/onkyo/internal/handler/URIMetaDataProcessorTest.java diff --git a/bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/OnkyoAudioSink.java b/bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/OnkyoAudioSink.java index 5c06a8f4eda..ce4742a970d 100644 --- a/bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/OnkyoAudioSink.java +++ b/bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/OnkyoAudioSink.java @@ -45,9 +45,9 @@ public class OnkyoAudioSink extends AudioSinkAsync { private static final Set SUPPORTED_FORMATS = Set.of(AudioFormat.WAV, AudioFormat.MP3); private static final Set> SUPPORTED_STREAMS = Set.of(AudioStream.class); - private OnkyoHandler handler; - private AudioHTTPServer audioHTTPServer; - private @Nullable String callbackUrl; + private final OnkyoHandler handler; + private final AudioHTTPServer audioHTTPServer; + private final @Nullable String callbackUrl; public OnkyoAudioSink(OnkyoHandler handler, AudioHTTPServer audioHTTPServer, @Nullable String callbackUrl) { this.handler = handler; @@ -106,7 +106,7 @@ public class OnkyoAudioSink extends AudioSinkAsync { tryClose(audioStream); return; } - handler.playMedia(url); + handler.playMedia(url, audioStream); } private void tryClose(@Nullable InputStream is) { diff --git a/bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/OnkyoUpnpHandler.java b/bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/OnkyoUpnpHandler.java index ee15fb3a1dc..3d15ea8bc96 100644 --- a/bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/OnkyoUpnpHandler.java +++ b/bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/OnkyoUpnpHandler.java @@ -16,6 +16,7 @@ import java.util.HashMap; import java.util.Map; import org.openhab.binding.onkyo.internal.OnkyoBindingConstants; +import org.openhab.core.audio.AudioStream; import org.openhab.core.io.transport.upnp.UpnpIOParticipant; import org.openhab.core.io.transport.upnp.UpnpIOService; import org.openhab.core.library.types.StringType; @@ -35,7 +36,9 @@ public abstract class OnkyoUpnpHandler extends BaseThingHandler implements UpnpI private final Logger logger = LoggerFactory.getLogger(OnkyoUpnpHandler.class); - private UpnpIOService service; + private final UpnpIOService service; + + private final URIMetaDataProcessor uriMetaDataProcessor = new URIMetaDataProcessor(); public OnkyoUpnpHandler(Thing thing, UpnpIOService upnpIOService) { super(thing); @@ -45,7 +48,7 @@ public abstract class OnkyoUpnpHandler extends BaseThingHandler implements UpnpI protected void handlePlayUri(Command command) { if (command instanceof StringType) { try { - playMedia(command.toString()); + playMedia(command.toString(), null); } catch (IllegalStateException e) { logger.warn("Cannot play URI ({})", e.getMessage()); @@ -53,7 +56,7 @@ public abstract class OnkyoUpnpHandler extends BaseThingHandler implements UpnpI } } - public void playMedia(String url) { + public void playMedia(String url, AudioStream audioStream) { stop(); removeAllTracksFromQueue(); @@ -61,7 +64,7 @@ public abstract class OnkyoUpnpHandler extends BaseThingHandler implements UpnpI url = "x-file-cifs:" + url; } - setCurrentURI(url, ""); + setCurrentURI(url, uriMetaDataProcessor.generate(url, audioStream)); play(); } diff --git a/bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/URIMetaDataProcessor.java b/bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/URIMetaDataProcessor.java new file mode 100644 index 00000000000..0df1a1cbb20 --- /dev/null +++ b/bundles/org.openhab.binding.onkyo/src/main/java/org/openhab/binding/onkyo/internal/handler/URIMetaDataProcessor.java @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.onkyo.internal.handler; + +import static org.jupnp.model.XMLUtil.appendNewElement; + +import java.io.StringWriter; + +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import org.jupnp.transport.impl.PooledXmlProcessor; +import org.openhab.core.audio.AudioFormat; +import org.openhab.core.audio.AudioStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +/** + * Utility class for building metadata XML for UPnP "SetAVTransportURI" action. + * + * @author Krzysztof Goworek - Initial contribution + */ +public class URIMetaDataProcessor extends PooledXmlProcessor { + private static final String XML_NAMESPACE_URI = "http://www.w3.org/2000/xmlns/"; + private static final String DIDL_NAMESPACE_URI = "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"; + private static final String UPNP_NAMESPACE_URI = "urn:schemas-upnp-org:metadata-1-0/upnp/"; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + /** + * Generates metadata XML for given audio stream. + * + * @param url media stream URL + * @param audioStream audio format specification + * @return generated XML document as {@code String} + */ + public String generate(String url, AudioStream audioStream) { + if (audioStream != null) { + try { + Document document = this.newDocument(); + Element rootElement = document.createElementNS(DIDL_NAMESPACE_URI, "DIDL-Lite"); + document.appendChild(rootElement); + rootElement.setAttributeNS(XML_NAMESPACE_URI, "xmlns:upnp", UPNP_NAMESPACE_URI); + + Element itemElement = appendNewElement(document, rootElement, "item"); + setAttributeIfNotNull(itemElement, "id", audioStream.getId()); + + appendNewElement(document, itemElement, "upnp:class", "object.item.audioItem", UPNP_NAMESPACE_URI); + Element resourceElement = appendNewElement(document, itemElement, "res", url); + setFormatAttributes(resourceElement, audioStream.getFormat()); + + return documentToString(document); + } catch (Exception e) { + logger.warn("Unable to build metadata for {}: {}", url, e.getMessage()); + } + } + + return ""; + } + + private void setFormatAttributes(Element resourceElement, AudioFormat format) { + setAttributeIfNotNull(resourceElement, "nrAudioChannels", format.getChannels()); + setAttributeIfNotNull(resourceElement, "sampleFrequency", format.getFrequency()); + setAttributeIfNotNull(resourceElement, "bitrate", format.getBitRate()); + setAttributeIfNotNull(resourceElement, "protocolInfo", getProtocolInfo(format)); + } + + private String getProtocolInfo(AudioFormat format) { + String[] protocolInfo = { "http-get", "*", getFormatMimeType(format), "*" }; + return String.join(":", protocolInfo); + } + + private String getFormatMimeType(AudioFormat format) { + if (AudioFormat.MP3.isCompatible(format)) { + return "audio/mpeg"; + } else if (AudioFormat.WAV.isCompatible(format)) { + return "audio/wav"; + } else if (AudioFormat.OGG.isCompatible(format)) { + return "audio/ogg"; + } else if (AudioFormat.AAC.isCompatible(format)) { + return "audio/aac"; + } else if (AudioFormat.PCM_SIGNED.isCompatible(format)) { + return "audio/pcm"; + } + throw new IllegalArgumentException("Invalid audio format given: " + format); + } + + private String documentToString(Document document) throws TransformerException { + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + Transformer transformer = transformerFactory.newTransformer(); + StringWriter out = new StringWriter(); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + transformer.transform(new DOMSource(document), new StreamResult(out)); + return out.toString(); + } + + private static void setAttributeIfNotNull(Element element, String name, Object value) { + if (value != null) { + element.setAttribute(name, value.toString()); + } + } +} diff --git a/bundles/org.openhab.binding.onkyo/src/test/java/org/openhab/binding/onkyo/internal/handler/URIMetaDataProcessorTest.java b/bundles/org.openhab.binding.onkyo/src/test/java/org/openhab/binding/onkyo/internal/handler/URIMetaDataProcessorTest.java new file mode 100644 index 00000000000..3435e91a93e --- /dev/null +++ b/bundles/org.openhab.binding.onkyo/src/test/java/org/openhab/binding/onkyo/internal/handler/URIMetaDataProcessorTest.java @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.onkyo.internal.handler; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.Test; +import org.openhab.core.audio.AudioFormat; +import org.openhab.core.audio.AudioStream; + +/** + * @author Krzysztof Goworek - Initial contribution + */ +class URIMetaDataProcessorTest { + private static final String MP3_STREAM_URL = "http://audio.server.local/audio.mp3"; + private static final String AAC_STREAM_URL = "http://audio.server.local/audio.aac"; + + @Test + void generateShouldReturnEmptyStringWhenStreamIsNull() { + String result = new URIMetaDataProcessor().generate(MP3_STREAM_URL, null); + + assertEquals("", result); + } + + @Test + void generateShouldReturnEmptyStringWhenExceptionOccurs() { + AudioStream audioStream = new TestAudioStream(AudioFormat.MP3) { + @Override + public @Nullable String getId() { + throw new UnsupportedOperationException(); + } + }; + + String result = new URIMetaDataProcessor().generate(MP3_STREAM_URL, audioStream); + + assertEquals("", result); + } + + @Test + void generateShouldReturnXMLWithGivenURLAndProtocolInfo() { + String result = new URIMetaDataProcessor().generate(MP3_STREAM_URL, new TestAudioStream(AudioFormat.MP3)); + + assertTrue(result.matches("")); + assertTrue(result.contains(">" + MP3_STREAM_URL + "")); + assertTrue(result.contains("protocolInfo=\"http-get:*:audio/mpeg:*\"")); + } + + @Test + void generateShouldReturnXMLWithAllFormatAttributes() { + AudioFormat audioFormat = new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_AAC, true, 8, 192000, + 48000L, 2); + + String result = new URIMetaDataProcessor().generate(AAC_STREAM_URL, new TestAudioStream(audioFormat)); + + assertTrue(result.contains("nrAudioChannels=\"2\"")); + assertTrue(result.contains("sampleFrequency=\"48000\"")); + assertTrue(result.contains("bitrate=\"192000\"")); + assertTrue(result.contains("protocolInfo=\"http-get:*:audio/aac:*\"")); + } + + @NonNullByDefault + private static class TestAudioStream extends AudioStream { + private final AudioFormat audioFormat; + + private TestAudioStream(AudioFormat audioFormat) { + this.audioFormat = audioFormat; + } + + @Override + public AudioFormat getFormat() { + return audioFormat; + } + + @Override + public int read() throws IOException { + throw new IOException("Unsupported operation"); + } + } +}