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.util;
15 import java.io.IOException;
16 import java.io.StringReader;
17 import java.text.MessageFormat;
18 import java.util.ArrayList;
19 import java.util.Collections;
20 import java.util.HashMap;
21 import java.util.List;
24 import javax.xml.parsers.ParserConfigurationException;
25 import javax.xml.parsers.SAXParser;
26 import javax.xml.parsers.SAXParserFactory;
28 import org.apache.commons.lang3.StringEscapeUtils;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
32 import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryRes;
33 import org.slf4j.Logger;
34 import org.slf4j.LoggerFactory;
35 import org.xml.sax.Attributes;
36 import org.xml.sax.InputSource;
37 import org.xml.sax.SAXException;
38 import org.xml.sax.helpers.DefaultHandler;
42 * @author Mark Herwege - Initial contribution
43 * @author Karel Goderis - Based on UPnP logic in Sonos binding
46 public class UpnpXMLParser {
48 private static final Logger LOGGER = LoggerFactory.getLogger(UpnpXMLParser.class);
50 private static final String METADATA_PATTERN = "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" "
51 + "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" "
52 + "xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">"
53 + "<item id=\"{0}\" parentID=\"{1}\" restricted=\"true\"><dc:title>{2}</dc:title>"
54 + "<upnp:class>{3}</upnp:class><upnp:album>{4}</upnp:album>"
55 + "<upnp:albumArtURI>{5}</upnp:albumArtURI><dc:creator>{6}</dc:creator>"
56 + "<upnp:artist>{7}</upnp:artist><dc:publisher>{8}</dc:publisher>"
57 + "<upnp:genre>{9}</upnp:genre><upnp:originalTrackNumber>{10}</upnp:originalTrackNumber>"
58 + "</item></DIDL-Lite>";
60 private enum Element {
73 public static Map<String, @Nullable String> getRenderingControlFromXML(String xml) {
75 LOGGER.debug("Could not parse Rendering Control from empty xml");
76 return Collections.emptyMap();
78 RenderingControlEventHandler handler = new RenderingControlEventHandler();
80 SAXParserFactory factory = SAXParserFactory.newInstance();
81 SAXParser saxParser = factory.newSAXParser();
82 factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
83 saxParser.getXMLReader().setFeature("http://xml.org/sax/features/external-general-entities", false);
84 factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
85 saxParser.parse(new InputSource(new StringReader(xml)), handler);
86 } catch (IOException e) {
87 // This should never happen - we're not performing I/O!
88 LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
89 } catch (SAXException | ParserConfigurationException s) {
90 LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
92 return handler.getChanges();
95 private static class RenderingControlEventHandler extends DefaultHandler {
97 private final Map<String, @Nullable String> changes = new HashMap<>();
99 RenderingControlEventHandler() {
100 // shouldn't be used outside of this package.
104 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
105 @Nullable Attributes attributes) throws SAXException {
113 String channel = attributes == null ? null : attributes.getValue("channel");
114 String val = attributes == null ? null : attributes.getValue("val");
115 if (channel != null && val != null) {
116 changes.put(channel + qName, val);
120 if ((attributes != null) && (attributes.getValue("val") != null)) {
121 changes.put(qName, attributes.getValue("val"));
127 public Map<String, @Nullable String> getChanges() {
132 public static Map<String, String> getAVTransportFromXML(String xml) {
134 LOGGER.debug("Could not parse AV Transport from empty xml");
135 return Collections.emptyMap();
137 AVTransportEventHandler handler = new AVTransportEventHandler();
139 SAXParserFactory factory = SAXParserFactory.newInstance();
140 SAXParser saxParser = factory.newSAXParser();
141 factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
142 saxParser.getXMLReader().setFeature("http://xml.org/sax/features/external-general-entities", false);
143 factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
144 saxParser.parse(new InputSource(new StringReader(xml)), handler);
145 } catch (IOException e) {
146 // This should never happen - we're not performing I/O!
147 LOGGER.error("Could not parse AV Transport from string '{}'", xml, e);
148 } catch (SAXException | ParserConfigurationException s) {
149 LOGGER.debug("Could not parse AV Transport from string '{}'", xml, s);
151 return handler.getChanges();
154 private static class AVTransportEventHandler extends DefaultHandler {
156 private final Map<String, String> changes = new HashMap<String, String>();
158 AVTransportEventHandler() {
159 // shouldn't be used outside of this package.
163 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
164 @Nullable Attributes attributes) throws SAXException {
166 * The events are all of the form <qName val="value"/> so we can get all
167 * the info we need from here.
169 if ((qName != null) && (attributes != null) && (attributes.getValue("val") != null)) {
170 changes.put(qName, attributes.getValue("val"));
174 public Map<String, String> getChanges() {
179 public static List<UpnpEntry> getEntriesFromXML(String xml) {
181 LOGGER.debug("Could not parse Entries from empty xml");
182 return Collections.emptyList();
184 EntryHandler handler = new EntryHandler();
186 SAXParserFactory factory = SAXParserFactory.newInstance();
187 SAXParser saxParser = factory.newSAXParser();
188 factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
189 saxParser.getXMLReader().setFeature("http://xml.org/sax/features/external-general-entities", false);
190 factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
191 saxParser.parse(new InputSource(new StringReader(xml)), handler);
192 } catch (IOException e) {
193 // This should never happen - we're not performing I/O!
194 LOGGER.error("Could not parse Entries from string '{}'", xml, e);
195 } catch (SAXException | ParserConfigurationException s) {
196 LOGGER.debug("Could not parse Entries from string '{}'", xml, s);
198 return handler.getEntries();
201 private static class EntryHandler extends DefaultHandler {
203 // Maintain a set of elements it is not useful to complain about.
204 // This list will be initialized on the first failure case.
205 private static List<String> ignore = new ArrayList<String>();
207 private String id = "";
208 private String refId = "";
209 private String parentId = "";
210 private StringBuilder upnpClass = new StringBuilder();
211 private List<UpnpEntryRes> resList = new ArrayList<>();
212 private StringBuilder res = new StringBuilder();
213 private StringBuilder title = new StringBuilder();
214 private StringBuilder album = new StringBuilder();
215 private StringBuilder albumArtUri = new StringBuilder();
216 private StringBuilder creator = new StringBuilder();
217 private StringBuilder artist = new StringBuilder();
218 private List<String> artistList = new ArrayList<>();
219 private StringBuilder publisher = new StringBuilder();
220 private StringBuilder genre = new StringBuilder();
221 private StringBuilder trackNumber = new StringBuilder();
222 private @Nullable Element element = null;
224 private List<UpnpEntry> entries = new ArrayList<>();
227 // shouldn't be used outside of this package.
231 public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
232 @Nullable Attributes attributes) throws SAXException {
240 if (attributes != null) {
241 if (attributes.getValue("id") != null) {
242 id = attributes.getValue("id");
244 if (attributes.getValue("refID") != null) {
245 refId = attributes.getValue("refID");
247 if (attributes.getValue("parentID") != null) {
248 parentId = attributes.getValue("parentID");
253 if (attributes != null) {
254 String protocolInfo = attributes.getValue("protocolInfo");
257 size = Long.parseLong(attributes.getValue("size"));
258 } catch (NumberFormatException e) {
261 String duration = attributes.getValue("duration");
262 String importUri = attributes.getValue("importUri");
263 resList.add(0, new UpnpEntryRes(protocolInfo, size, duration, importUri));
264 element = Element.RES;
268 element = Element.TITLE;
271 element = Element.CLASS;
274 element = Element.CREATOR;
277 element = Element.ARTIST;
280 element = Element.PUBLISHER;
283 element = Element.GENRE;
286 element = Element.ALBUM;
288 case "upnp:albumArtURI":
289 element = Element.ALBUM_ART_URI;
291 case "upnp:originalTrackNumber":
292 element = Element.TRACK_NUMBER;
295 if (ignore.isEmpty()) {
297 ignore.add("DIDL-Lite");
299 ignore.add("ordinal");
300 ignore.add("description");
301 ignore.add("writeStatus");
302 ignore.add("storageUsed");
303 ignore.add("supported");
304 ignore.add("pushSource");
306 ignore.add("playlist");
308 ignore.add("rating");
309 ignore.add("userrating");
310 ignore.add("episodeSeason");
311 ignore.add("childCountContainer");
312 ignore.add("modificationTime");
313 ignore.add("containerContent");
315 if (!ignore.contains(localName)) {
316 LOGGER.debug("Did not recognise element named {}", localName);
323 public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
324 Element el = element;
325 if (el == null || ch == null) {
330 title.append(ch, start, length);
333 upnpClass.append(ch, start, length);
336 res.append(ch, start, length);
339 album.append(ch, start, length);
342 albumArtUri.append(ch, start, length);
345 creator.append(ch, start, length);
348 artist.append(ch, start, length);
351 publisher.append(ch, start, length);
354 genre.append(ch, start, length);
357 trackNumber.append(ch, start, length);
363 public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
364 throws SAXException {
365 if ("container".equals(qName) || "item".equals(qName)) {
368 Integer trackNumberVal;
370 trackNumberVal = Integer.parseInt(trackNumber.toString());
371 } catch (NumberFormatException e) {
372 trackNumberVal = null;
375 entries.add(new UpnpEntry(id, refId, parentId, upnpClass.toString()).withTitle(title.toString())
376 .withAlbum(album.toString()).withAlbumArtUri(albumArtUri.toString())
377 .withCreator(creator.toString())
378 .withArtist(!artistList.isEmpty() ? artistList.get(0) : artist.toString())
379 .withPublisher(publisher.toString()).withGenre(genre.toString()).withTrackNumber(trackNumberVal)
380 .withResList(resList));
382 title = new StringBuilder();
383 upnpClass = new StringBuilder();
384 resList = new ArrayList<>();
385 album = new StringBuilder();
386 albumArtUri = new StringBuilder();
387 creator = new StringBuilder();
388 artistList = new ArrayList<>();
389 publisher = new StringBuilder();
390 genre = new StringBuilder();
391 trackNumber = new StringBuilder();
392 } else if ("res".equals(qName)) {
393 resList.get(0).setRes(res.toString());
394 res = new StringBuilder();
395 } else if ("upnp:artist".equals(qName)) {
396 artistList.add(artist.toString());
397 artist = new StringBuilder();
401 public List<UpnpEntry> getEntries() {
406 public static String compileMetadataString(UpnpEntry entry) {
407 String id = entry.getId();
408 String parentId = entry.getParentId();
409 String title = StringEscapeUtils.escapeXml(entry.getTitle());
410 String upnpClass = entry.getUpnpClass();
411 String album = StringEscapeUtils.escapeXml(entry.getAlbum());
412 String albumArtUri = entry.getAlbumArtUri();
413 String creator = StringEscapeUtils.escapeXml(entry.getCreator());
414 String artist = StringEscapeUtils.escapeXml(entry.getArtist());
415 String publisher = StringEscapeUtils.escapeXml(entry.getPublisher());
416 String genre = StringEscapeUtils.escapeXml(entry.getGenre());
417 Integer trackNumber = entry.getOriginalTrackNumber();
419 final MessageFormat messageFormat = new MessageFormat(METADATA_PATTERN);
420 String metadata = messageFormat.format(new Object[] { id, parentId, title, upnpClass, album, albumArtUri,
421 creator, artist, publisher, genre, trackNumber });