]> git.basschouten.com Git - openhab-addons.git/blob
ce04c67f5093944f0e1320f153b75118684a187b
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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, @Nullable 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, @Nullable 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) {
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) {
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                     finalID = Integer.parseInt(id);
534                     finalVolume = Integer.parseInt(volume);
535                 } catch (Exception e) {
536                     LOGGER.debug("Error parsing Integer");
537                 }
538
539                 alarms.add(new SonosAlarm(finalID, startTime, duration, recurrence, finalEnabled, roomUUID, programURI,
540                         programMetaData, playMode, finalVolume, finalIncludeLinkedZones));
541             }
542         }
543
544         public List<SonosAlarm> getAlarms() {
545             return alarms;
546         }
547     }
548
549     private static class ZoneGroupHandler extends DefaultHandler {
550
551         private final List<SonosZoneGroup> groups = new ArrayList<>();
552         private final List<String> currentGroupPlayers = new ArrayList<>();
553         private final List<String> currentGroupPlayerZones = new ArrayList<>();
554         private String coordinator = "";
555         private String groupId = "";
556
557         @Override
558         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
559                 @Nullable Attributes attributes) throws SAXException {
560             if ("ZoneGroup".equals(qName) && attributes != null) {
561                 groupId = attributes.getValue("ID");
562                 coordinator = attributes.getValue("Coordinator");
563             } else if ("ZoneGroupMember".equals(qName) && attributes != null) {
564                 currentGroupPlayers.add(attributes.getValue("UUID"));
565                 String zoneName = attributes.getValue("ZoneName");
566                 if (zoneName != null) {
567                     currentGroupPlayerZones.add(zoneName);
568                 }
569                 String htInfoSet = attributes.getValue("HTSatChanMapSet");
570                 if (htInfoSet != null) {
571                     currentGroupPlayers.addAll(getAllHomeTheaterMembers(htInfoSet));
572                 }
573             }
574         }
575
576         @Override
577         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
578                 throws SAXException {
579             if ("ZoneGroup".equals(qName)) {
580                 groups.add(new SonosZoneGroup(groupId, coordinator, currentGroupPlayers, currentGroupPlayerZones));
581                 currentGroupPlayers.clear();
582                 currentGroupPlayerZones.clear();
583             }
584         }
585
586         public List<SonosZoneGroup> getGroups() {
587             return groups;
588         }
589
590         private Set<String> getAllHomeTheaterMembers(String homeTheaterDescription) {
591             Set<String> homeTheaterMembers = new HashSet<>();
592             Matcher matcher = Pattern.compile("(RINCON_\\w+)").matcher(homeTheaterDescription);
593             while (matcher.find()) {
594                 String member = matcher.group();
595                 homeTheaterMembers.add(member);
596             }
597             return homeTheaterMembers;
598         }
599     }
600
601     private static class OpmlHandler extends DefaultHandler {
602
603         // <opml version="1">
604         // <head>
605         // <status>200</status>
606         //
607         // </head>
608         // <body>
609         // <outline type="text" text="Q-Music 103.3" guide_id="s2398" key="station"
610         // image="http://radiotime-logos.s3.amazonaws.com/s87683q.png" preset_id="s2398"/>
611         // <outline type="text" text="Bjorn Verhoeven" guide_id="p257265" seconds_remaining="2230" duration="7200"
612         // key="show"/>
613         // <outline type="text" text="Top 40-Pop"/>
614         // <outline type="text" text="37m remaining"/>
615         // <outline type="object" text="NowPlaying">
616         // <nowplaying>
617         // <logo>http://radiotime-logos.s3.amazonaws.com/s87683.png</logo>
618         // <twitter_id />
619         // </nowplaying>
620         // </outline>
621         // </body>
622         // </opml>
623
624         private final List<String> textFields = new ArrayList<>();
625         private @Nullable String textField;
626         private @Nullable String type;
627         // private String logo;
628
629         @Override
630         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
631                 @Nullable Attributes attributes) throws SAXException {
632             if ("outline".equals(qName)) {
633                 type = attributes == null ? null : attributes.getValue("type");
634                 if ("text".equals(type)) {
635                     textField = attributes == null ? null : attributes.getValue("text");
636                 } else {
637                     textField = null;
638                 }
639             }
640         }
641
642         @Override
643         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
644                 throws SAXException {
645             if ("outline".equals(qName)) {
646                 String field = textField;
647                 if (field != null) {
648                     textFields.add(field);
649                 }
650             }
651         }
652
653         public List<String> getTextFields() {
654             return textFields;
655         }
656     }
657
658     private static class AVTransportEventHandler extends DefaultHandler {
659
660         /*
661          * <Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/">
662          * <InstanceID val="0">
663          * <TransportState val="PLAYING"/>
664          * <CurrentPlayMode val="NORMAL"/>
665          * <CurrentPlayMode val="0"/>
666          * <NumberOfTracks val="29"/>
667          * <CurrentTrack val="12"/>
668          * <CurrentSection val="0"/>
669          * <CurrentTrackURI val=
670          * "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"
671          * />
672          * <CurrentTrackDuration val="0:03:02"/>
673          * <CurrentTrackMetaData val=
674          * "&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;"
675          * /><r:NextTrackURI val=
676          * "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"
677          * /><r:NextTrackMetaData val=
678          * "&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;"
679          * /><r:EnqueuedTransportURI
680          * val="x-rincon-playlist:RINCON_000E582126EE01400#A:ALBUMARTIST/Queens%20Of%20The%20Stone%20Age"/><r:
681          * EnqueuedTransportURIMetaData 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;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;"
683          * />
684          * <PlaybackStorageMedium val="NETWORK"/>
685          * <AVTransportURI val="x-rincon-queue:RINCON_000E5812BC1801400#0"/>
686          * <AVTransportURIMetaData val=""/>
687          * <CurrentTransportActions val="Play, Stop, Pause, Seek, Next, Previous"/>
688          * <TransportStatus val="OK"/>
689          * <r:SleepTimerGeneration val="0"/>
690          * <r:AlarmRunning val="0"/>
691          * <r:SnoozeRunning val="0"/>
692          * <r:RestartPending val="0"/>
693          * <TransportPlaySpeed val="NOT_IMPLEMENTED"/>
694          * <CurrentMediaDuration val="NOT_IMPLEMENTED"/>
695          * <RecordStorageMedium val="NOT_IMPLEMENTED"/>
696          * <PossiblePlaybackStorageMedia val="NONE, NETWORK"/>
697          * <PossibleRecordStorageMedia val="NOT_IMPLEMENTED"/>
698          * <RecordMediumWriteStatus val="NOT_IMPLEMENTED"/>
699          * <CurrentRecordQualityMode val="NOT_IMPLEMENTED"/>
700          * <PossibleRecordQualityModes val="NOT_IMPLEMENTED"/>
701          * <NextAVTransportURI val="NOT_IMPLEMENTED"/>
702          * <NextAVTransportURIMetaData val="NOT_IMPLEMENTED"/>
703          * </InstanceID>
704          * </Event>
705          */
706
707         private final Map<String, @Nullable String> changes = new HashMap<>();
708
709         @Override
710         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
711                 @Nullable Attributes attributes) throws SAXException {
712             /*
713              * The events are all of the form <qName val="value"/> so we can get all
714              * the info we need from here.
715              */
716             if (localName == null) {
717                 // this means that localName isn't defined in EventType, which is expected for some elements
718                 LOGGER.info("{} is not defined in EventType. ", localName);
719             } else {
720                 String val = attributes == null ? null : attributes.getValue("val");
721                 if (val != null) {
722                     changes.put(localName, val);
723                 }
724             }
725         }
726
727         public Map<String, @Nullable String> getChanges() {
728             return changes;
729         }
730     }
731
732     private static class MetaDataHandler extends DefaultHandler {
733
734         private @Nullable CurrentElement currentElement;
735
736         private String id = "-1";
737         private String parentId = "-1";
738         private StringBuilder resource = new StringBuilder();
739         private StringBuilder streamContent = new StringBuilder();
740         private StringBuilder albumArtUri = new StringBuilder();
741         private StringBuilder title = new StringBuilder();
742         private StringBuilder upnpClass = new StringBuilder();
743         private StringBuilder creator = new StringBuilder();
744         private StringBuilder album = new StringBuilder();
745         private StringBuilder albumArtist = new StringBuilder();
746
747         @Override
748         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
749                 @Nullable Attributes attributes) throws SAXException {
750             String name = localName == null ? "" : localName;
751             switch (name) {
752                 case "item":
753                     currentElement = CurrentElement.item;
754                     if (attributes != null) {
755                         id = attributes.getValue("id");
756                         parentId = attributes.getValue("parentID");
757                     }
758                     break;
759                 case "res":
760                     currentElement = CurrentElement.res;
761                     break;
762                 case "streamContent":
763                     currentElement = CurrentElement.streamContent;
764                     break;
765                 case "albumArtURI":
766                     currentElement = CurrentElement.albumArtURI;
767                     break;
768                 case "title":
769                     currentElement = CurrentElement.title;
770                     break;
771                 case "class":
772                     currentElement = CurrentElement.upnpClass;
773                     break;
774                 case "creator":
775                     currentElement = CurrentElement.creator;
776                     break;
777                 case "album":
778                     currentElement = CurrentElement.album;
779                     break;
780                 case "albumArtist":
781                     currentElement = CurrentElement.albumArtist;
782                     break;
783                 default:
784                     // unknown element
785                     currentElement = null;
786                     break;
787             }
788         }
789
790         @Override
791         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
792             CurrentElement elt = currentElement;
793             if (elt == null) {
794                 return;
795             }
796             switch (elt) {
797                 case item:
798                     break;
799                 case res:
800                     resource.append(ch, start, length);
801                     break;
802                 case streamContent:
803                     streamContent.append(ch, start, length);
804                     break;
805                 case albumArtURI:
806                     albumArtUri.append(ch, start, length);
807                     break;
808                 case title:
809                     title.append(ch, start, length);
810                     break;
811                 case upnpClass:
812                     upnpClass.append(ch, start, length);
813                     break;
814                 case creator:
815                     creator.append(ch, start, length);
816                     break;
817                 case album:
818                     album.append(ch, start, length);
819                     break;
820                 case albumArtist:
821                     albumArtist.append(ch, start, length);
822                     break;
823                 case desc:
824                     break;
825             }
826         }
827
828         public SonosMetaData getMetaData() {
829             return new SonosMetaData(id, parentId, resource.toString(), streamContent.toString(),
830                     albumArtUri.toString(), title.toString(), upnpClass.toString(), creator.toString(),
831                     album.toString(), albumArtist.toString());
832         }
833     }
834
835     private static class RenderingControlEventHandler extends DefaultHandler {
836
837         private final Map<String, @Nullable String> changes = new HashMap<>();
838
839         private boolean getPresetName = false;
840         private @Nullable String presetName;
841
842         @Override
843         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
844                 @Nullable Attributes attributes) throws SAXException {
845             if (qName == null) {
846                 return;
847             }
848             String channel;
849             String val;
850             switch (qName) {
851                 case "Volume":
852                 case "Mute":
853                 case "Loudness":
854                     channel = attributes == null ? null : attributes.getValue("channel");
855                     val = attributes == null ? null : attributes.getValue("val");
856                     if (channel != null && val != null) {
857                         changes.put(qName + channel, val);
858                     }
859                     break;
860                 case "Bass":
861                 case "Treble":
862                 case "OutputFixed":
863                     val = attributes == null ? null : attributes.getValue("val");
864                     if (val != null) {
865                         changes.put(qName, val);
866                     }
867                     break;
868                 case "PresetNameList":
869                     getPresetName = true;
870                     break;
871                 default:
872                     break;
873             }
874         }
875
876         @Override
877         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
878             if (getPresetName) {
879                 presetName = new String(ch, start, length);
880             }
881         }
882
883         @Override
884         public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
885                 throws SAXException {
886             if (getPresetName) {
887                 getPresetName = false;
888                 String preset = presetName;
889                 if (qName != null && preset != null) {
890                     changes.put(qName, preset);
891                 }
892             }
893         }
894
895         public Map<String, @Nullable String> getChanges() {
896             return changes;
897         }
898     }
899
900     private static class MusicServiceHandler extends DefaultHandler {
901
902         private final List<SonosMusicService> services = new ArrayList<>();
903
904         @Override
905         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
906                 @Nullable Attributes attributes) throws SAXException {
907             // All services are of the form <services Id="value" Name="value">...</Service>
908             if ("Service".equals(qName) && attributes != null && attributes.getValue("Id") != null
909                     && attributes.getValue("Name") != null) {
910                 services.add(new SonosMusicService(attributes.getValue("Id"), attributes.getValue("Name")));
911             }
912         }
913
914         public List<SonosMusicService> getServices() {
915             return services;
916         }
917     }
918
919     public static @Nullable String getRoomName(String descriptorXML) {
920         RoomNameHandler roomNameHandler = new RoomNameHandler();
921         try {
922             XMLReader reader = XMLReaderFactory.createXMLReader();
923             reader.setContentHandler(roomNameHandler);
924             URL url = new URL(descriptorXML);
925             reader.parse(new InputSource(url.openStream()));
926         } catch (IOException | SAXException e) {
927             LOGGER.error("Could not parse Sonos room name from string '{}'", descriptorXML);
928         }
929         return roomNameHandler.getRoomName();
930     }
931
932     private static class RoomNameHandler extends DefaultHandler {
933
934         private @Nullable String roomName;
935         private boolean roomNameTag;
936
937         @Override
938         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
939                 @Nullable Attributes attributes) throws SAXException {
940             if ("roomName".equalsIgnoreCase(localName)) {
941                 roomNameTag = true;
942             }
943         }
944
945         @Override
946         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
947             if (roomNameTag) {
948                 roomName = new String(ch, start, length);
949                 roomNameTag = false;
950             }
951         }
952
953         public @Nullable String getRoomName() {
954             return roomName;
955         }
956     }
957
958     public static @Nullable String parseModelDescription(URL descriptorURL) {
959         ModelNameHandler modelNameHandler = new ModelNameHandler();
960         try {
961             XMLReader reader = XMLReaderFactory.createXMLReader();
962             reader.setContentHandler(modelNameHandler);
963             URL url = new URL(descriptorURL.toString());
964             reader.parse(new InputSource(url.openStream()));
965         } catch (IOException | SAXException e) {
966             LOGGER.error("Could not parse Sonos model name from string '{}'", descriptorURL.toString());
967         }
968         return modelNameHandler.getModelName();
969     }
970
971     private static class ModelNameHandler extends DefaultHandler {
972
973         private @Nullable String modelName;
974         private boolean modelNameTag;
975
976         @Override
977         public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
978                 @Nullable Attributes attributes) throws SAXException {
979             if ("modelName".equalsIgnoreCase(localName)) {
980                 modelNameTag = true;
981             }
982         }
983
984         @Override
985         public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
986             if (modelNameTag) {
987                 modelName = new String(ch, start, length);
988                 modelNameTag = false;
989             }
990         }
991
992         public @Nullable String getModelName() {
993             return modelName;
994         }
995     }
996
997     /**
998      * The model name provided by upnp is formated like in the example form "Sonos PLAY:1" or "Sonos PLAYBAR"
999      *
1000      * @param sonosModelName Sonos model name provided via upnp device
1001      * @return the extracted players model name without column (:) character used for ThingType creation
1002      */
1003     public static String extractModelName(String sonosModelName) {
1004         String ret = sonosModelName;
1005         Matcher matcher = Pattern.compile("\\s(.*)").matcher(ret);
1006         if (matcher.find()) {
1007             ret = matcher.group(1);
1008         }
1009         if (ret.contains(":")) {
1010             ret = ret.replace(":", "");
1011         }
1012         return ret;
1013     }
1014
1015     public static String compileMetadataString(SonosEntry entry) {
1016         /**
1017          * If the entry contains resource meta data we will override this with
1018          * that data.
1019          */
1020         String id = entry.getId();
1021         String parentId = entry.getParentId();
1022         String title = entry.getTitle();
1023         String upnpClass = entry.getUpnpClass();
1024
1025         /**
1026          * By default 'RINCON_AssociatedZPUDN' is used for most operations,
1027          * however when playing a favorite entry that is associated withh a
1028          * subscription like pandora we need to use the desc string asscoiated
1029          * with that item.
1030          */
1031         String desc = entry.getDesc();
1032         if (desc == null) {
1033             desc = "RINCON_AssociatedZPUDN";
1034         }
1035
1036         /**
1037          * If resource meta data exists, use it over the parent data
1038          */
1039         SonosResourceMetaData resourceMetaData = entry.getResourceMetaData();
1040         if (resourceMetaData != null) {
1041             id = resourceMetaData.getId();
1042             parentId = resourceMetaData.getParentId();
1043             title = resourceMetaData.getTitle();
1044             desc = resourceMetaData.getDesc();
1045             upnpClass = resourceMetaData.getUpnpClass();
1046         }
1047
1048         title = StringEscapeUtils.escapeXml(title);
1049
1050         String metadata = METADATA_FORMAT.format(new Object[] { id, parentId, title, upnpClass, desc });
1051
1052         return metadata;
1053     }
1054 }