]> git.basschouten.com Git - openhab-addons.git/blob
85d3b8e7c159a06543d5600bbdacea0ca32659c6
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.sonos.internal;
14
15 import java.io.IOException;
16 import java.io.StringReader;
17 import java.net.URL;
18 import java.text.MessageFormat;
19 import java.util.ArrayList;
20 import java.util.HashMap;
21 import java.util.HashSet;
22 import java.util.List;
23 import java.util.Map;
24 import java.util.Set;
25 import java.util.regex.Matcher;
26 import java.util.regex.Pattern;
27
28 import org.apache.commons.lang.StringEscapeUtils;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.slf4j.Logger;
32 import org.slf4j.LoggerFactory;
33 import org.xml.sax.Attributes;
34 import org.xml.sax.InputSource;
35 import org.xml.sax.SAXException;
36 import org.xml.sax.XMLReader;
37 import org.xml.sax.helpers.DefaultHandler;
38 import org.xml.sax.helpers.XMLReaderFactory;
39
40 /**
41  * The {@link SonosXMLParser} is a class of helper functions
42  * to parse XML data returned by the Zone Players
43  *
44  * @author Karel Goderis - Initial contribution
45  */
46 @NonNullByDefault
47 public class SonosXMLParser {
48
49     static final Logger LOGGER = LoggerFactory.getLogger(SonosXMLParser.class);
50
51     private static final MessageFormat METADATA_FORMAT = new MessageFormat(
52             "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" "
53                     + "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" "
54                     + "xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\" "
55                     + "xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">"
56                     + "<item id=\"{0}\" parentID=\"{1}\" restricted=\"true\">" + "<dc:title>{2}</dc:title>"
57                     + "<upnp:class>{3}</upnp:class>"
58                     + "<desc id=\"cdudn\" nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">" + "{4}</desc>"
59                     + "</item></DIDL-Lite>");
60
61     private enum Element {
62         TITLE,
63         CLASS,
64         ALBUM,
65         ALBUM_ART_URI,
66         CREATOR,
67         RES,
68         TRACK_NUMBER,
69         RESMD,
70         DESC
71     }
72
73     private enum CurrentElement {
74         item,
75         res,
76         streamContent,
77         albumArtURI,
78         title,
79         upnpClass,
80         creator,
81         album,
82         albumArtist,
83         desc
84     }
85
86     /**
87      * @param xml
88      * @return a list of alarms from the given xml string.
89      * @throws IOException
90      * @throws SAXException
91      */
92     public static List<SonosAlarm> getAlarmsFromStringResult(String xml) {
93         AlarmHandler handler = new AlarmHandler();
94         try {
95             XMLReader reader = XMLReaderFactory.createXMLReader();
96             reader.setContentHandler(handler);
97             reader.parse(new InputSource(new StringReader(xml)));
98         } catch (IOException e) {
99             LOGGER.error("Could not parse Alarms from string '{}'", xml);
100         } catch (SAXException s) {
101             LOGGER.error("Could not parse Alarms from string '{}'", xml);
102         }
103         return handler.getAlarms();
104     }
105
106     /**
107      * @param xml
108      * @return a list of Entries from the given xml string.
109      * @throws IOException
110      * @throws SAXException
111      */
112     public static List<SonosEntry> getEntriesFromString(String xml) {
113         EntryHandler handler = new EntryHandler();
114         try {
115             XMLReader reader = XMLReaderFactory.createXMLReader();
116             reader.setContentHandler(handler);
117             reader.parse(new InputSource(new StringReader(xml)));
118         } catch (IOException e) {
119             LOGGER.error("Could not parse Entries from string '{}'", xml);
120         } catch (SAXException s) {
121             LOGGER.error("Could not parse Entries from string '{}'", xml);
122         }
123
124         return handler.getArtists();
125     }
126
127     /**
128      * Returns the meta data which is needed to play Pandora
129      * (and others?) favorites
130      *
131      * @param xml
132      * @return The value of the desc xml tag
133      * @throws SAXException
134      */
135     public static @Nullable SonosResourceMetaData getResourceMetaData(String xml) throws SAXException {
136         XMLReader reader = XMLReaderFactory.createXMLReader();
137         ResourceMetaDataHandler handler = new ResourceMetaDataHandler();
138         reader.setContentHandler(handler);
139         try {
140             reader.parse(new InputSource(new StringReader(xml)));
141         } catch (IOException e) {
142             LOGGER.error("Could not parse Resource MetaData from String '{}'", xml);
143         } catch (SAXException s) {
144             LOGGER.error("Could not parse Resource MetaData from string '{}'", xml);
145         }
146         return handler.getMetaData();
147     }
148
149     /**
150      * @param controller
151      * @param xml
152      * @return zone group from the given xml
153      * @throws IOException
154      * @throws SAXException
155      */
156     public static List<SonosZoneGroup> getZoneGroupFromXML(String xml) {
157         ZoneGroupHandler handler = new ZoneGroupHandler();
158         try {
159             XMLReader reader = XMLReaderFactory.createXMLReader();
160             reader.setContentHandler(handler);
161             reader.parse(new InputSource(new StringReader(xml)));
162         } catch (IOException e) {
163             // This should never happen - we're not performing I/O!
164             LOGGER.error("Could not parse ZoneGroup from string '{}'", xml);
165         } catch (SAXException s) {
166             LOGGER.error("Could not parse ZoneGroup from string '{}'", xml);
167         }
168
169         return handler.getGroups();
170     }
171
172     public static List<String> getRadioTimeFromXML(String xml) {
173         OpmlHandler handler = new OpmlHandler();
174         try {
175             XMLReader reader = XMLReaderFactory.createXMLReader();
176             reader.setContentHandler(handler);
177             reader.parse(new InputSource(new StringReader(xml)));
178         } catch (IOException e) {
179             // This should never happen - we're not performing I/O!
180             LOGGER.error("Could not parse RadioTime from string '{}'", xml);
181         } catch (SAXException s) {
182             LOGGER.error("Could not parse RadioTime from string '{}'", xml);
183         }
184
185         return handler.getTextFields();
186     }
187
188     public static Map<String, String> getRenderingControlFromXML(String xml) {
189         RenderingControlEventHandler handler = new RenderingControlEventHandler();
190         try {
191             XMLReader reader = XMLReaderFactory.createXMLReader();
192             reader.setContentHandler(handler);
193             reader.parse(new InputSource(new StringReader(xml)));
194         } catch (IOException e) {
195             // This should never happen - we're not performing I/O!
196             LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
197         } catch (SAXException s) {
198             LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
199         }
200         return handler.getChanges();
201     }
202
203     public static Map<String, String> getAVTransportFromXML(String xml) {
204         AVTransportEventHandler handler = new AVTransportEventHandler();
205         try {
206             XMLReader reader = XMLReaderFactory.createXMLReader();
207             reader.setContentHandler(handler);
208             reader.parse(new InputSource(new StringReader(xml)));
209         } catch (IOException e) {
210             // This should never happen - we're not performing I/O!
211             LOGGER.error("Could not parse AV Transport from string '{}'", xml);
212         } catch (SAXException s) {
213             LOGGER.error("Could not parse AV Transport from string '{}'", xml);
214         }
215         return handler.getChanges();
216     }
217
218     public static SonosMetaData getMetaDataFromXML(String xml) {
219         MetaDataHandler handler = new MetaDataHandler();
220         try {
221             XMLReader reader = XMLReaderFactory.createXMLReader();
222             reader.setContentHandler(handler);
223             reader.parse(new InputSource(new StringReader(xml)));
224         } catch (IOException e) {
225             // This should never happen - we're not performing I/O!
226             LOGGER.error("Could not parse MetaData from string '{}'", xml);
227         } catch (SAXException s) {
228             LOGGER.error("Could not parse MetaData from string '{}'", xml);
229         }
230
231         return handler.getMetaData();
232     }
233
234     public static List<SonosMusicService> getMusicServicesFromXML(String xml) {
235         MusicServiceHandler handler = new MusicServiceHandler();
236         try {
237             XMLReader reader = XMLReaderFactory.createXMLReader();
238             reader.setContentHandler(handler);
239             reader.parse(new InputSource(new StringReader(xml)));
240         } catch (IOException e) {
241             // This should never happen - we're not performing I/O!
242             LOGGER.error("Could not parse music services from string '{}'", xml);
243         } catch (SAXException s) {
244             LOGGER.error("Could not parse music services from string '{}'", xml);
245         }
246         return handler.getServices();
247     }
248
249     private static class EntryHandler extends DefaultHandler {
250
251         // Maintain a set of elements about which it is unuseful to complain about.
252         // This list will be initialized on the first failure case
253         private static @Nullable List<String> ignore;
254
255         private String id = "";
256         private String parentId = "";
257         private StringBuilder upnpClass = new StringBuilder();
258         private StringBuilder res = new StringBuilder();
259         private StringBuilder title = new StringBuilder();
260         private StringBuilder album = new StringBuilder();
261         private StringBuilder albumArtUri = new StringBuilder();
262         private StringBuilder creator = new StringBuilder();
263         private StringBuilder trackNumber = new StringBuilder();
264         private StringBuilder desc = new StringBuilder();
265         private @Nullable Element element;
266
267         private List<SonosEntry> artists = new ArrayList<>();
268
269         EntryHandler() {
270             // shouldn't be used outside of this package.
271         }
272
273         @Override
274         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
275                 @Nullable Attributes attributes) throws SAXException {
276             String name = qName == null ? "" : qName;
277             switch (name) {
278                 case "container":
279                 case "item":
280                     if (attributes != null) {
281                         id = attributes.getValue("id");
282                         parentId = attributes.getValue("parentID");
283                     }
284                     break;
285                 case "res":
286                     element = Element.RES;
287                     break;
288                 case "dc:title":
289                     element = Element.TITLE;
290                     break;
291                 case "upnp:class":
292                     element = Element.CLASS;
293                     break;
294                 case "dc:creator":
295                     element = Element.CREATOR;
296                     break;
297                 case "upnp:album":
298                     element = Element.ALBUM;
299                     break;
300                 case "upnp:albumArtURI":
301                     element = Element.ALBUM_ART_URI;
302                     break;
303                 case "upnp:originalTrackNumber":
304                     element = Element.TRACK_NUMBER;
305                     break;
306                 case "r:resMD":
307                     element = Element.RESMD;
308                     break;
309                 default:
310                     List<String> curIgnore = ignore;
311                     if (curIgnore == null) {
312                         curIgnore = new ArrayList<>();
313                         curIgnore.add("DIDL-Lite");
314                         curIgnore.add("type");
315                         curIgnore.add("ordinal");
316                         curIgnore.add("description");
317                         ignore = curIgnore;
318                     }
319
320                     if (!curIgnore.contains(localName)) {
321                         LOGGER.debug("Did not recognise element named {}", localName);
322                     }
323                     element = null;
324                     break;
325             }
326         }
327
328         @Override
329         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
330             Element elt = element;
331             if (elt == null || ch == null) {
332                 return;
333             }
334             switch (elt) {
335                 case TITLE:
336                     title.append(ch, start, length);
337                     break;
338                 case CLASS:
339                     upnpClass.append(ch, start, length);
340                     break;
341                 case RES:
342                     res.append(ch, start, length);
343                     break;
344                 case ALBUM:
345                     album.append(ch, start, length);
346                     break;
347                 case ALBUM_ART_URI:
348                     albumArtUri.append(ch, start, length);
349                     break;
350                 case CREATOR:
351                     creator.append(ch, start, length);
352                     break;
353                 case TRACK_NUMBER:
354                     trackNumber.append(ch, start, length);
355                     break;
356                 case RESMD:
357                     desc.append(ch, start, length);
358                     break;
359                 case DESC:
360                     break;
361             }
362         }
363
364         @Override
365         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
366                 throws SAXException {
367             if (("container".equals(qName) || "item".equals(qName))) {
368                 element = null;
369
370                 int trackNumberVal = 0;
371                 try {
372                     trackNumberVal = Integer.parseInt(trackNumber.toString());
373                 } catch (Exception e) {
374                 }
375
376                 SonosResourceMetaData md = null;
377
378                 // The resource description is needed for playing favorites on pandora
379                 if (!desc.toString().isEmpty()) {
380                     try {
381                         md = getResourceMetaData(desc.toString());
382                     } catch (SAXException ignore) {
383                         LOGGER.debug("Failed to parse embeded", ignore);
384                     }
385                 }
386
387                 artists.add(new SonosEntry(id, title.toString(), parentId, album.toString(), albumArtUri.toString(),
388                         creator.toString(), upnpClass.toString(), res.toString(), trackNumberVal, md));
389                 title = new StringBuilder();
390                 upnpClass = new StringBuilder();
391                 res = new StringBuilder();
392                 album = new StringBuilder();
393                 albumArtUri = new StringBuilder();
394                 creator = new StringBuilder();
395                 trackNumber = new StringBuilder();
396                 desc = new StringBuilder();
397             }
398         }
399
400         public List<SonosEntry> getArtists() {
401             return artists;
402         }
403     }
404
405     private static class ResourceMetaDataHandler extends DefaultHandler {
406
407         private String id = "";
408         private String parentId = "";
409         private StringBuilder title = new StringBuilder();
410         private StringBuilder upnpClass = new StringBuilder();
411         private StringBuilder desc = new StringBuilder();
412         private @Nullable Element element;
413         private @Nullable SonosResourceMetaData metaData;
414
415         ResourceMetaDataHandler() {
416             // shouldn't be used outside of this package.
417         }
418
419         @Override
420         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
421                 @Nullable Attributes attributes) throws SAXException {
422             String name = qName == null ? "" : qName;
423             switch (name) {
424                 case "container":
425                 case "item":
426                     if (attributes != null) {
427                         id = attributes.getValue("id");
428                         parentId = attributes.getValue("parentID");
429                     }
430                     break;
431                 case "desc":
432                     element = Element.DESC;
433                     break;
434                 case "upnp:class":
435                     element = Element.CLASS;
436                     break;
437                 case "dc:title":
438                     element = Element.TITLE;
439                     break;
440                 default:
441                     element = null;
442                     break;
443             }
444         }
445
446         @Override
447         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
448             Element elt = element;
449             if (elt == null || ch == null) {
450                 return;
451             }
452             switch (elt) {
453                 case TITLE:
454                     title.append(ch, start, length);
455                     break;
456                 case CLASS:
457                     upnpClass.append(ch, start, length);
458                     break;
459                 case DESC:
460                     desc.append(ch, start, length);
461                     break;
462                 default:
463                     break;
464             }
465         }
466
467         @Override
468         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
469                 throws SAXException {
470             if ("DIDL-Lite".equals(qName)) {
471                 metaData = new SonosResourceMetaData(id, parentId, title.toString(), upnpClass.toString(),
472                         desc.toString());
473                 element = null;
474                 desc = new StringBuilder();
475                 upnpClass = new StringBuilder();
476                 title = new StringBuilder();
477             }
478         }
479
480         public @Nullable SonosResourceMetaData getMetaData() {
481             return metaData;
482         }
483     }
484
485     private static class AlarmHandler extends DefaultHandler {
486
487         private @Nullable String id;
488         private String startTime = "";
489         private String duration = "";
490         private String recurrence = "";
491         private @Nullable String enabled;
492         private String roomUUID = "";
493         private String programURI = "";
494         private String programMetaData = "";
495         private String playMode = "";
496         private @Nullable String volume;
497         private @Nullable String includeLinkedZones;
498
499         private List<SonosAlarm> alarms = new ArrayList<>();
500
501         AlarmHandler() {
502             // shouldn't be used outside of this package.
503         }
504
505         @Override
506         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
507                 @Nullable Attributes attributes) throws SAXException {
508             if ("Alarm".equals(qName) && attributes != null) {
509                 id = attributes.getValue("ID");
510                 duration = attributes.getValue("Duration");
511                 recurrence = attributes.getValue("Recurrence");
512                 startTime = attributes.getValue("StartTime");
513                 enabled = attributes.getValue("Enabled");
514                 roomUUID = attributes.getValue("RoomUUID");
515                 programURI = attributes.getValue("ProgramURI");
516                 programMetaData = attributes.getValue("ProgramMetaData");
517                 playMode = attributes.getValue("PlayMode");
518                 volume = attributes.getValue("Volume");
519                 includeLinkedZones = attributes.getValue("IncludeLinkedZones");
520             }
521         }
522
523         @Override
524         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
525                 throws SAXException {
526             if ("Alarm".equals(qName)) {
527                 int finalID = 0;
528                 int finalVolume = 0;
529                 boolean finalEnabled = !"0".equals(enabled);
530                 boolean finalIncludeLinkedZones = !"0".equals(includeLinkedZones);
531
532                 try {
533                     String id = this.id;
534                     if (id == null) {
535                         throw new NumberFormatException();
536                     }
537                     finalID = Integer.parseInt(id);
538                     String volume = this.volume;
539                     if (volume == null) {
540                         throw new NumberFormatException();
541                     }
542                     finalVolume = Integer.parseInt(volume);
543                 } catch (NumberFormatException e) {
544                     LOGGER.debug("Error parsing Integer");
545                 }
546
547                 alarms.add(new SonosAlarm(finalID, startTime, duration, recurrence, finalEnabled, roomUUID, programURI,
548                         programMetaData, playMode, finalVolume, finalIncludeLinkedZones));
549             }
550         }
551
552         public List<SonosAlarm> getAlarms() {
553             return alarms;
554         }
555     }
556
557     private static class ZoneGroupHandler extends DefaultHandler {
558
559         private final List<SonosZoneGroup> groups = new ArrayList<>();
560         private final List<String> currentGroupPlayers = new ArrayList<>();
561         private final List<String> currentGroupPlayerZones = new ArrayList<>();
562         private String coordinator = "";
563         private String groupId = "";
564
565         @Override
566         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
567                 @Nullable Attributes attributes) throws SAXException {
568             if ("ZoneGroup".equals(qName) && attributes != null) {
569                 groupId = attributes.getValue("ID");
570                 coordinator = attributes.getValue("Coordinator");
571             } else if ("ZoneGroupMember".equals(qName) && attributes != null) {
572                 currentGroupPlayers.add(attributes.getValue("UUID"));
573                 String zoneName = attributes.getValue("ZoneName");
574                 if (zoneName != null) {
575                     currentGroupPlayerZones.add(zoneName);
576                 }
577                 String htInfoSet = attributes.getValue("HTSatChanMapSet");
578                 if (htInfoSet != null) {
579                     currentGroupPlayers.addAll(getAllHomeTheaterMembers(htInfoSet));
580                 }
581             }
582         }
583
584         @Override
585         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
586                 throws SAXException {
587             if ("ZoneGroup".equals(qName)) {
588                 groups.add(new SonosZoneGroup(groupId, coordinator, currentGroupPlayers, currentGroupPlayerZones));
589                 currentGroupPlayers.clear();
590                 currentGroupPlayerZones.clear();
591             }
592         }
593
594         public List<SonosZoneGroup> getGroups() {
595             return groups;
596         }
597
598         private Set<String> getAllHomeTheaterMembers(String homeTheaterDescription) {
599             Set<String> homeTheaterMembers = new HashSet<>();
600             Matcher matcher = Pattern.compile("(RINCON_\\w+)").matcher(homeTheaterDescription);
601             while (matcher.find()) {
602                 String member = matcher.group();
603                 homeTheaterMembers.add(member);
604             }
605             return homeTheaterMembers;
606         }
607     }
608
609     private static class OpmlHandler extends DefaultHandler {
610
611         // <opml version="1">
612         // <head>
613         // <status>200</status>
614         //
615         // </head>
616         // <body>
617         // <outline type="text" text="Q-Music 103.3" guide_id="s2398" key="station"
618         // image="http://radiotime-logos.s3.amazonaws.com/s87683q.png" preset_id="s2398"/>
619         // <outline type="text" text="Bjorn Verhoeven" guide_id="p257265" seconds_remaining="2230" duration="7200"
620         // key="show"/>
621         // <outline type="text" text="Top 40-Pop"/>
622         // <outline type="text" text="37m remaining"/>
623         // <outline type="object" text="NowPlaying">
624         // <nowplaying>
625         // <logo>http://radiotime-logos.s3.amazonaws.com/s87683.png</logo>
626         // <twitter_id />
627         // </nowplaying>
628         // </outline>
629         // </body>
630         // </opml>
631
632         private final List<String> textFields = new ArrayList<>();
633         private @Nullable String textField;
634         private @Nullable String type;
635         // private String logo;
636
637         @Override
638         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
639                 @Nullable Attributes attributes) throws SAXException {
640             if ("outline".equals(qName)) {
641                 type = attributes == null ? null : attributes.getValue("type");
642                 if ("text".equals(type)) {
643                     textField = attributes == null ? null : attributes.getValue("text");
644                 } else {
645                     textField = null;
646                 }
647             }
648         }
649
650         @Override
651         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
652                 throws SAXException {
653             if ("outline".equals(qName)) {
654                 String field = textField;
655                 if (field != null) {
656                     textFields.add(field);
657                 }
658             }
659         }
660
661         public List<String> getTextFields() {
662             return textFields;
663         }
664     }
665
666     private static class AVTransportEventHandler extends DefaultHandler {
667
668         /*
669          * <Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/">
670          * <InstanceID val="0">
671          * <TransportState val="PLAYING"/>
672          * <CurrentPlayMode val="NORMAL"/>
673          * <CurrentPlayMode val="0"/>
674          * <NumberOfTracks val="29"/>
675          * <CurrentTrack val="12"/>
676          * <CurrentSection val="0"/>
677          * <CurrentTrackURI val=
678          * "x-file-cifs://192.168.1.1/Storage4/Sonos%20Music/Queens%20Of%20The%20Stone%20Age/Lullabies%20To%20Paralyze/Queens%20Of%20The%20Stone%20Age%20-%20Lullabies%20To%20Paralyze%20-%2012%20-%20Broken%20Box.wma"
679          * />
680          * <CurrentTrackDuration val="0:03:02"/>
681          * <CurrentTrackMetaData val=
682          * "&lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;-1&quot; parentID=&quot;-1&quot; restricted=&quot;true&quot;&gt;&lt;res protocolInfo=&quot;x-file-cifs:*:audio/x-ms-wma:*&quot; duration=&quot;0:03:02&quot;&gt;x-file-cifs://192.168.1.1/Storage4/Sonos%20Music/Queens%20Of%20The%20Stone%20Age/Lullabies%20To%20Paralyze/Queens%20Of%20The%20Stone%20Age%20-%20Lullabies%20To%20Paralyze%20-%2012%20-%20Broken%20Box.wma&lt;/res&gt;&lt;r:streamContent&gt;&lt;/r:streamContent&gt;&lt;dc:title&gt;Broken Box&lt;/dc:title&gt;&lt;upnp:class&gt;object.item.audioItem.musicTrack&lt;/upnp:class&gt;&lt;dc:creator&gt;Queens Of The Stone Age&lt;/dc:creator&gt;&lt;upnp:album&gt;Lullabies To Paralyze&lt;/upnp:album&gt;&lt;r:albumArtist&gt;Queens Of The Stone Age&lt;/r:albumArtist&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;"
683          * /><r:NextTrackURI val=
684          * "x-file-cifs://192.168.1.1/Storage4/Sonos%20Music/Queens%20Of%20The%20Stone%20Age/Lullabies%20To%20Paralyze/Queens%20Of%20The%20Stone%20Age%20-%20Lullabies%20To%20Paralyze%20-%2013%20-%20&apos;&apos;You%20Got%20A%20Killer%20Scene%20There,%20Man...&apos;&apos;.wma"
685          * /><r:NextTrackMetaData val=
686          * "&lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;-1&quot; parentID=&quot;-1&quot; restricted=&quot;true&quot;&gt;&lt;res protocolInfo=&quot;x-file-cifs:*:audio/x-ms-wma:*&quot; duration=&quot;0:04:56&quot;&gt;x-file-cifs://192.168.1.1/Storage4/Sonos%20Music/Queens%20Of%20The%20Stone%20Age/Lullabies%20To%20Paralyze/Queens%20Of%20The%20Stone%20Age%20-%20Lullabies%20To%20Paralyze%20-%2013%20-%20&amp;apos;&amp;apos;You%20Got%20A%20Killer%20Scene%20There,%20Man...&amp;apos;&amp;apos;.wma&lt;/res&gt;&lt;dc:title&gt;&amp;apos;&amp;apos;You Got A Killer Scene There, Man...&amp;apos;&amp;apos;&lt;/dc:title&gt;&lt;upnp:class&gt;object.item.audioItem.musicTrack&lt;/upnp:class&gt;&lt;dc:creator&gt;Queens Of The Stone Age&lt;/dc:creator&gt;&lt;upnp:album&gt;Lullabies To Paralyze&lt;/upnp:album&gt;&lt;r:albumArtist&gt;Queens Of The Stone Age&lt;/r:albumArtist&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;"
687          * /><r:EnqueuedTransportURI
688          * val="x-rincon-playlist:RINCON_000E582126EE01400#A:ALBUMARTIST/Queens%20Of%20The%20Stone%20Age"/><r:
689          * EnqueuedTransportURIMetaData val=
690          * "&lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;A:ALBUMARTIST/Queens%20Of%20The%20Stone%20Age&quot; parentID=&quot;A:ALBUMARTIST&quot; restricted=&quot;true&quot;&gt;&lt;dc:title&gt;Queens Of The Stone Age&lt;/dc:title&gt;&lt;upnp:class&gt;object.container&lt;/upnp:class&gt;&lt;desc id=&quot;cdudn&quot; nameSpace=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot;&gt;RINCON_AssociatedZPUDN&lt;/desc&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;"
691          * />
692          * <PlaybackStorageMedium val="NETWORK"/>
693          * <AVTransportURI val="x-rincon-queue:RINCON_000E5812BC1801400#0"/>
694          * <AVTransportURIMetaData val=""/>
695          * <CurrentTransportActions val="Play, Stop, Pause, Seek, Next, Previous"/>
696          * <TransportStatus val="OK"/>
697          * <r:SleepTimerGeneration val="0"/>
698          * <r:AlarmRunning val="0"/>
699          * <r:SnoozeRunning val="0"/>
700          * <r:RestartPending val="0"/>
701          * <TransportPlaySpeed val="NOT_IMPLEMENTED"/>
702          * <CurrentMediaDuration val="NOT_IMPLEMENTED"/>
703          * <RecordStorageMedium val="NOT_IMPLEMENTED"/>
704          * <PossiblePlaybackStorageMedia val="NONE, NETWORK"/>
705          * <PossibleRecordStorageMedia val="NOT_IMPLEMENTED"/>
706          * <RecordMediumWriteStatus val="NOT_IMPLEMENTED"/>
707          * <CurrentRecordQualityMode val="NOT_IMPLEMENTED"/>
708          * <PossibleRecordQualityModes val="NOT_IMPLEMENTED"/>
709          * <NextAVTransportURI val="NOT_IMPLEMENTED"/>
710          * <NextAVTransportURIMetaData val="NOT_IMPLEMENTED"/>
711          * </InstanceID>
712          * </Event>
713          */
714
715         private final Map<String, String> changes = new HashMap<>();
716
717         @Override
718         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
719                 @Nullable Attributes attributes) throws SAXException {
720             /*
721              * The events are all of the form <qName val="value"/> so we can get all
722              * the info we need from here.
723              */
724             if (localName == null) {
725                 // this means that localName isn't defined in EventType, which is expected for some elements
726                 LOGGER.info("{} is not defined in EventType. ", localName);
727             } else {
728                 String val = attributes == null ? null : attributes.getValue("val");
729                 if (val != null) {
730                     changes.put(localName, val);
731                 }
732             }
733         }
734
735         public Map<String, String> getChanges() {
736             return changes;
737         }
738     }
739
740     private static class MetaDataHandler extends DefaultHandler {
741
742         private @Nullable CurrentElement currentElement;
743
744         private String id = "-1";
745         private String parentId = "-1";
746         private StringBuilder resource = new StringBuilder();
747         private StringBuilder streamContent = new StringBuilder();
748         private StringBuilder albumArtUri = new StringBuilder();
749         private StringBuilder title = new StringBuilder();
750         private StringBuilder upnpClass = new StringBuilder();
751         private StringBuilder creator = new StringBuilder();
752         private StringBuilder album = new StringBuilder();
753         private StringBuilder albumArtist = new StringBuilder();
754
755         @Override
756         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
757                 @Nullable Attributes attributes) throws SAXException {
758             String name = localName == null ? "" : localName;
759             switch (name) {
760                 case "item":
761                     currentElement = CurrentElement.item;
762                     if (attributes != null) {
763                         id = attributes.getValue("id");
764                         parentId = attributes.getValue("parentID");
765                     }
766                     break;
767                 case "res":
768                     currentElement = CurrentElement.res;
769                     break;
770                 case "streamContent":
771                     currentElement = CurrentElement.streamContent;
772                     break;
773                 case "albumArtURI":
774                     currentElement = CurrentElement.albumArtURI;
775                     break;
776                 case "title":
777                     currentElement = CurrentElement.title;
778                     break;
779                 case "class":
780                     currentElement = CurrentElement.upnpClass;
781                     break;
782                 case "creator":
783                     currentElement = CurrentElement.creator;
784                     break;
785                 case "album":
786                     currentElement = CurrentElement.album;
787                     break;
788                 case "albumArtist":
789                     currentElement = CurrentElement.albumArtist;
790                     break;
791                 default:
792                     // unknown element
793                     currentElement = null;
794                     break;
795             }
796         }
797
798         @Override
799         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
800             CurrentElement elt = currentElement;
801             if (elt == null || ch == null) {
802                 return;
803             }
804             switch (elt) {
805                 case item:
806                     break;
807                 case res:
808                     resource.append(ch, start, length);
809                     break;
810                 case streamContent:
811                     streamContent.append(ch, start, length);
812                     break;
813                 case albumArtURI:
814                     albumArtUri.append(ch, start, length);
815                     break;
816                 case title:
817                     title.append(ch, start, length);
818                     break;
819                 case upnpClass:
820                     upnpClass.append(ch, start, length);
821                     break;
822                 case creator:
823                     creator.append(ch, start, length);
824                     break;
825                 case album:
826                     album.append(ch, start, length);
827                     break;
828                 case albumArtist:
829                     albumArtist.append(ch, start, length);
830                     break;
831                 case desc:
832                     break;
833             }
834         }
835
836         public SonosMetaData getMetaData() {
837             return new SonosMetaData(id, parentId, resource.toString(), streamContent.toString(),
838                     albumArtUri.toString(), title.toString(), upnpClass.toString(), creator.toString(),
839                     album.toString(), albumArtist.toString());
840         }
841     }
842
843     private static class RenderingControlEventHandler extends DefaultHandler {
844
845         private final Map<String, String> changes = new HashMap<>();
846
847         private boolean getPresetName = false;
848         private @Nullable String presetName;
849
850         @Override
851         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
852                 @Nullable Attributes attributes) throws SAXException {
853             if (qName == null) {
854                 return;
855             }
856             String channel;
857             String val;
858             switch (qName) {
859                 case "Volume":
860                 case "Mute":
861                 case "Loudness":
862                     channel = attributes == null ? null : attributes.getValue("channel");
863                     val = attributes == null ? null : attributes.getValue("val");
864                     if (channel != null && val != null) {
865                         changes.put(qName + channel, val);
866                     }
867                     break;
868                 case "Bass":
869                 case "Treble":
870                 case "OutputFixed":
871                     val = attributes == null ? null : attributes.getValue("val");
872                     if (val != null) {
873                         changes.put(qName, val);
874                     }
875                     break;
876                 case "PresetNameList":
877                     getPresetName = true;
878                     break;
879                 default:
880                     break;
881             }
882         }
883
884         @Override
885         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
886             if (getPresetName && ch != null) {
887                 presetName = new String(ch, start, length);
888             }
889         }
890
891         @Override
892         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
893                 throws SAXException {
894             if (getPresetName) {
895                 getPresetName = false;
896                 String preset = presetName;
897                 if (qName != null && preset != null) {
898                     changes.put(qName, preset);
899                 }
900             }
901         }
902
903         public Map<String, String> getChanges() {
904             return changes;
905         }
906     }
907
908     private static class MusicServiceHandler extends DefaultHandler {
909
910         private final List<SonosMusicService> services = new ArrayList<>();
911
912         @Override
913         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
914                 @Nullable Attributes attributes) throws SAXException {
915             // All services are of the form <services Id="value" Name="value">...</Service>
916             if ("Service".equals(qName) && attributes != null && attributes.getValue("Id") != null
917                     && attributes.getValue("Name") != null) {
918                 services.add(new SonosMusicService(attributes.getValue("Id"), attributes.getValue("Name")));
919             }
920         }
921
922         public List<SonosMusicService> getServices() {
923             return services;
924         }
925     }
926
927     public static @Nullable String getRoomName(String descriptorXML) {
928         RoomNameHandler roomNameHandler = new RoomNameHandler();
929         try {
930             XMLReader reader = XMLReaderFactory.createXMLReader();
931             reader.setContentHandler(roomNameHandler);
932             URL url = new URL(descriptorXML);
933             reader.parse(new InputSource(url.openStream()));
934         } catch (IOException | SAXException e) {
935             LOGGER.error("Could not parse Sonos room name from string '{}'", descriptorXML);
936         }
937         return roomNameHandler.getRoomName();
938     }
939
940     private static class RoomNameHandler extends DefaultHandler {
941
942         private @Nullable String roomName;
943         private boolean roomNameTag;
944
945         @Override
946         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
947                 @Nullable Attributes attributes) throws SAXException {
948             if ("roomName".equalsIgnoreCase(localName)) {
949                 roomNameTag = true;
950             }
951         }
952
953         @Override
954         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
955             if (roomNameTag && ch != null) {
956                 roomName = new String(ch, start, length);
957                 roomNameTag = false;
958             }
959         }
960
961         public @Nullable String getRoomName() {
962             return roomName;
963         }
964     }
965
966     public static @Nullable String parseModelDescription(URL descriptorURL) {
967         ModelNameHandler modelNameHandler = new ModelNameHandler();
968         try {
969             XMLReader reader = XMLReaderFactory.createXMLReader();
970             reader.setContentHandler(modelNameHandler);
971             URL url = new URL(descriptorURL.toString());
972             reader.parse(new InputSource(url.openStream()));
973         } catch (IOException | SAXException e) {
974             LOGGER.error("Could not parse Sonos model name from string '{}'", descriptorURL.toString());
975         }
976         return modelNameHandler.getModelName();
977     }
978
979     private static class ModelNameHandler extends DefaultHandler {
980
981         private @Nullable String modelName;
982         private boolean modelNameTag;
983
984         @Override
985         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
986                 @Nullable Attributes attributes) throws SAXException {
987             if ("modelName".equalsIgnoreCase(localName)) {
988                 modelNameTag = true;
989             }
990         }
991
992         @Override
993         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
994             if (modelNameTag && ch != null) {
995                 modelName = new String(ch, start, length);
996                 modelNameTag = false;
997             }
998         }
999
1000         public @Nullable String getModelName() {
1001             return modelName;
1002         }
1003     }
1004
1005     /**
1006      * The model name provided by upnp is formated like in the example form "Sonos PLAY:1" or "Sonos PLAYBAR"
1007      *
1008      * @param sonosModelName Sonos model name provided via upnp device
1009      * @return the extracted players model name without column (:) character used for ThingType creation
1010      */
1011     public static String extractModelName(String sonosModelName) {
1012         String ret = sonosModelName;
1013         Matcher matcher = Pattern.compile("\\s(.*)").matcher(ret);
1014         if (matcher.find()) {
1015             ret = matcher.group(1);
1016         }
1017         if (ret.contains(":")) {
1018             ret = ret.replace(":", "");
1019         }
1020         return ret;
1021     }
1022
1023     public static String compileMetadataString(SonosEntry entry) {
1024         /**
1025          * If the entry contains resource meta data we will override this with
1026          * that data.
1027          */
1028         String id = entry.getId();
1029         String parentId = entry.getParentId();
1030         String title = entry.getTitle();
1031         String upnpClass = entry.getUpnpClass();
1032
1033         /**
1034          * By default 'RINCON_AssociatedZPUDN' is used for most operations,
1035          * however when playing a favorite entry that is associated withh a
1036          * subscription like pandora we need to use the desc string asscoiated
1037          * with that item.
1038          */
1039         String desc = entry.getDesc();
1040         if (desc == null) {
1041             desc = "RINCON_AssociatedZPUDN";
1042         }
1043
1044         /**
1045          * If resource meta data exists, use it over the parent data
1046          */
1047         SonosResourceMetaData resourceMetaData = entry.getResourceMetaData();
1048         if (resourceMetaData != null) {
1049             id = resourceMetaData.getId();
1050             parentId = resourceMetaData.getParentId();
1051             title = resourceMetaData.getTitle();
1052             desc = resourceMetaData.getDesc();
1053             upnpClass = resourceMetaData.getUpnpClass();
1054         }
1055
1056         title = StringEscapeUtils.escapeXml(title);
1057
1058         String metadata = METADATA_FORMAT.format(new Object[] { id, parentId, title, upnpClass, desc });
1059
1060         return metadata;
1061     }
1062 }