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