-
Notifications
You must be signed in to change notification settings - Fork 231
/
music_library.py
662 lines (551 loc) · 25 KB
/
music_library.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
# Disable while we have Python 2.x compatability
# pylint: disable=useless-object-inheritance
"""Access to the Music Library.
The Music Library is the collection of music stored on your local network.
For access to third party music streaming services, see the
`music_service` module."""
import logging
from urllib.parse import quote as quote_url
import xmltodict
from . import discovery
from .data_structures import SearchResult, DidlResource, DidlObject, DidlMusicAlbum
from .data_structures_entry import from_didl_string
from .exceptions import SoCoUPnPException
from .utils import url_escape_path, really_unicode, camel_to_underscore
_LOG = logging.getLogger(__name__)
class MusicLibrary:
"""The Music Library."""
# Key words used when performing searches
SEARCH_TRANSLATION = {
"artists": "A:ARTIST",
"album_artists": "A:ALBUMARTIST",
"albums": "A:ALBUM",
"genres": "A:GENRE",
"composers": "A:COMPOSER",
"tracks": "A:TRACKS",
"playlists": "A:PLAYLISTS",
"share": "S:",
"sonos_playlists": "SQ:",
"categories": "A:",
"sonos_favorites": "FV:2",
"radio_stations": "R:0/0",
"radio_shows": "R:0/1",
}
# pylint: disable=invalid-name, protected-access
def __init__(self, soco=None):
"""
Args:
soco (`SoCo`, optional): A `SoCo` instance to query for music
library information. If `None`, or not supplied, a random
`SoCo` instance will be used.
"""
self.soco = soco if soco is not None else discovery.any_soco()
self.contentDirectory = self.soco.contentDirectory
def build_album_art_full_uri(self, url):
"""Ensure an Album Art URI is an absolute URI.
Args:
url (str): the album art URI.
Returns:
str: An absolute URI.
"""
# Add on the full album art link, as the URI version
# does not include the ipaddress
if not url.startswith(("http:", "https:")):
url = "http://" + self.soco.ip_address + ":1400" + url
return url
def _update_album_art_to_full_uri(self, item):
"""Update an item's Album Art URI to be an absolute URI.
Args:
item: The item to update the URI for
"""
if getattr(item, "album_art_uri", False):
item.album_art_uri = self.build_album_art_full_uri(item.album_art_uri)
def get_artists(self, *args, **kwargs):
"""Convenience method for `get_music_library_information`
with ``search_type='artists'``. For details of other arguments,
see `that method
<#soco.music_library.MusicLibrary.get_music_library_information>`_.
"""
args = tuple(["artists"] + list(args))
return self.get_music_library_information(*args, **kwargs)
def get_album_artists(self, *args, **kwargs):
"""Convenience method for `get_music_library_information`
with ``search_type='album_artists'``. For details of other arguments,
see `that method
<#soco.music_library.MusicLibrary.get_music_library_information>`_.
"""
args = tuple(["album_artists"] + list(args))
return self.get_music_library_information(*args, **kwargs)
def get_albums(self, *args, **kwargs):
"""Convenience method for `get_music_library_information`
with ``search_type='albums'``. For details of other arguments,
see `that method
<#soco.music_library.MusicLibrary.get_music_library_information>`_.
"""
args = tuple(["albums"] + list(args))
return self.get_music_library_information(*args, **kwargs)
def get_genres(self, *args, **kwargs):
"""Convenience method for `get_music_library_information`
with ``search_type='genres'``. For details of other arguments,
see `that method
<#soco.music_library.MusicLibrary.get_music_library_information>`_.
"""
args = tuple(["genres"] + list(args))
return self.get_music_library_information(*args, **kwargs)
def get_composers(self, *args, **kwargs):
"""Convenience method for `get_music_library_information`
with ``search_type='composers'``. For details of other arguments,
see `that method
<#soco.music_library.MusicLibrary.get_music_library_information>`_.
"""
args = tuple(["composers"] + list(args))
return self.get_music_library_information(*args, **kwargs)
def get_tracks(self, *args, **kwargs):
"""Convenience method for `get_music_library_information`
with ``search_type='tracks'``. For details of other arguments,
see `that method
<#soco.music_library.MusicLibrary.get_music_library_information>`_.
"""
args = tuple(["tracks"] + list(args))
return self.get_music_library_information(*args, **kwargs)
def get_playlists(self, *args, **kwargs):
"""Convenience method for `get_music_library_information`
with ``search_type='playlists'``. For details of other arguments,
see `that method
<#soco.music_library.MusicLibrary.get_music_library_information>`_.
Note:
The playlists that are referred to here are the playlists imported
from the music library, they are not the Sonos playlists.
"""
args = tuple(["playlists"] + list(args))
return self.get_music_library_information(*args, **kwargs)
def get_sonos_favorites(self, *args, **kwargs):
"""Convenience method for `get_music_library_information`
with ``search_type='sonos_favorites'``. For details of other arguments,
see `that method
<#soco.music_library.MusicLibrary.get_music_library_information>`_.
"""
args = tuple(["sonos_favorites"] + list(args))
return self.get_music_library_information(*args, **kwargs)
def get_favorite_radio_stations(self, *args, **kwargs):
"""Convenience method for `get_music_library_information`
with ``search_type='radio_stations'``. For details of other arguments,
see `that method
<#soco.music_library.MusicLibrary.get_music_library_information>`_.
"""
args = tuple(["radio_stations"] + list(args))
return self.get_music_library_information(*args, **kwargs)
def get_favorite_radio_shows(self, *args, **kwargs):
"""Convenience method for `get_music_library_information`
with ``search_type='radio_stations'``. For details of other arguments,
see `that method
<#soco.music_library.MusicLibrary.get_music_library_information>`_.
"""
args = tuple(["radio_shows"] + list(args))
return self.get_music_library_information(*args, **kwargs)
def get_music_library_information(
self,
search_type,
start=0,
max_items=100,
full_album_art_uri=False,
search_term=None,
subcategories=None,
complete_result=False,
):
"""Retrieve music information objects from the music library.
This method is the main method to get music information items, like
e.g. tracks, albums etc., from the music library with. It can be used
in a few different ways:
The ``search_term`` argument performs a fuzzy search on that string in
the results, so e.g calling::
get_music_library_information('artists', search_term='Metallica')
will perform a fuzzy search for the term 'Metallica' among all the
artists.
Using the ``subcategories`` argument, will jump directly into that
subcategory of the search and return results from there. So. e.g
knowing that among the artist is one called 'Metallica', calling::
get_music_library_information('artists',
subcategories=['Metallica'])
will jump directly into the 'Metallica' sub category and return the
albums associated with Metallica and::
get_music_library_information('artists',
subcategories=['Metallica', 'Black'])
will return the tracks of the album 'Black' by the artist 'Metallica'.
The order of sub category types is: Genres->Artists->Albums->Tracks.
It is also possible to combine the two, to perform a fuzzy search in a
sub category.
The ``start``, ``max_items`` and ``complete_result`` arguments all
have to do with paging of the results. By default the searches are
always paged, because there is a limit to how many items we can get at
a time. This paging is exposed to the user with the ``start`` and
``max_items`` arguments. So calling::
get_music_library_information('artists', start=0, max_items=100)
get_music_library_information('artists', start=100, max_items=100)
will get the first and next 100 items, respectively. It is also
possible to ask for all the elements at once::
get_music_library_information('artists', complete_result=True)
This will perform the paging internally and simply return all the
items.
Args:
search_type (str):
The kind of information to retrieve. Can be one of:
``'artists'``, ``'album_artists'``, ``'albums'``,
``'genres'``, ``'composers'``, ``'tracks'``, ``'share'``,
``'sonos_playlists'``, or ``'playlists'``, where playlists
are the imported playlists from the music library.
start (int, optional): starting number of returned matches
(zero based). Default 0.
max_items (int, optional): Maximum number of returned matches.
Default 100.
full_album_art_uri (bool):
whether the album art URI should be absolute (i.e. including
the IP address). Default `False`.
search_term (str, optional):
a string that will be used to perform a fuzzy search among the
search results. If used in combination with subcategories,
the fuzzy search will be performed in the subcategory.
subcategories (str, optional):
A list of strings that indicate one or more subcategories to
dive into.
complete_result (bool): if `True`, will disable
paging (ignore ``start`` and ``max_items``) and return all
results for the search.
Warning:
Getting e.g. all the tracks in a large collection might
take some time.
Returns:
`SearchResult`: an instance of `SearchResult`.
Note:
* The maximum numer of results may be restricted by the unit,
presumably due to transfer size consideration, so check the
returned number against that requested.
* The playlists that are returned with the ``'playlists'`` search,
are the playlists imported from the music library, they
are not the Sonos playlists.
Raises:
`SoCoException` upon errors.
"""
search = self.SEARCH_TRANSLATION[search_type]
# Add sub categories
# sub categories are not allowed when searching shares
if subcategories is not None and search_type != "share":
for category in subcategories:
search += "/" + url_escape_path(really_unicode(category))
# Add fuzzy search
if search_term is not None:
if search_type == "share":
# Don't insert ":" and don't escape "/" (so can't use url_escape_path)
search += quote_url(really_unicode(search_term).encode("utf-8"))
else:
search += ":" + url_escape_path(really_unicode(search_term))
item_list = []
metadata = {"total_matches": 100000}
while len(item_list) < metadata["total_matches"]:
# Change start and max for complete searches
if complete_result:
start, max_items = len(item_list), 100000
# Try and get this batch of results
try:
response, metadata = self._music_lib_search(search, start, max_items)
except SoCoUPnPException as exception:
# 'No such object' UPnP errors
if exception.error_code == "701":
return SearchResult([], search_type, 0, 0, None)
else:
raise exception
# Parse the results
items = from_didl_string(response["Result"])
for item in items:
# Check if the album art URI should be fully qualified
if full_album_art_uri:
self._update_album_art_to_full_uri(item)
# Append the item to the list
item_list.append(item)
# If we are not after the complete results, the stop after 1
# iteration
if not complete_result:
break
metadata["search_type"] = search_type
if complete_result:
metadata["number_returned"] = len(item_list)
# pylint: disable=star-args
return SearchResult(item_list, **metadata)
def browse(
self,
ml_item=None,
start=0,
max_items=100,
full_album_art_uri=False,
search_term=None,
subcategories=None,
):
"""Browse (get sub-elements from) a music library item.
Args:
ml_item (`DidlItem`): the item to browse, if left out or
`None`, items at the root level will be searched.
start (int): the starting index of the results.
max_items (int): the maximum number of items to return.
full_album_art_uri (bool): whether the album art URI should be
fully qualified with the relevant IP address.
search_term (str): A string that will be used to perform a fuzzy
search among the search results. If used in combination with
subcategories, the fuzzy search will be performed on the
subcategory. Note: Searching will not work if ``ml_item`` is
`None`.
subcategories (list): A list of strings that indicate one or more
subcategories to descend into. Note: Providing sub categories
will not work if ``ml_item`` is `None`.
Returns:
A `SearchResult` instance.
Raises:
AttributeError: if ``ml_item`` has no ``item_id`` attribute.
SoCoUPnPException: with ``error_code='701'`` if the item cannot be
browsed.
"""
if ml_item is None:
search = "A:"
else:
search = ml_item.item_id
# Add sub categories
if subcategories is not None:
for category in subcategories:
search += "/" + url_escape_path(really_unicode(category))
# Add fuzzy search
if search_term is not None:
search += ":" + url_escape_path(really_unicode(search_term))
try:
response, metadata = self._music_lib_search(search, start, max_items)
except SoCoUPnPException as exception:
# 'No such object' UPnP errors
if exception.error_code == "701":
return SearchResult([], "browse", 0, 0, None)
else:
raise exception
metadata["search_type"] = "browse"
# Parse the results
containers = from_didl_string(response["Result"])
item_list = []
for container in containers:
# Check if the album art URI should be fully qualified
if full_album_art_uri:
self._update_album_art_to_full_uri(container)
item_list.append(container)
# pylint: disable=star-args
return SearchResult(item_list, **metadata)
def browse_by_idstring(
self, search_type, idstring, start=0, max_items=100, full_album_art_uri=False
):
"""Browse (get sub-elements from) a given music library item,
specified by a string.
Args:
search_type (str): The kind of information to retrieve. Can be
one of: ``'artists'``, ``'album_artists'``, ``'albums'``,
``'genres'``, ``'composers'``, ``'tracks'``, ``'share'``,
``'sonos_playlists'``, and ``'playlists'``, where
playlists are the imported file based playlists from the
music library.
idstring (str): a term to search for.
start (int): starting number of returned matches. Default 0.
max_items (int): Maximum number of returned matches. Default 100.
full_album_art_uri (bool): whether the album art URI should be
absolute (i.e. including the IP address). Default `False`.
Returns:
`SearchResult`: a `SearchResult` instance.
Note:
The maximum numer of results may be restricted by the unit,
presumably due to transfer size consideration, so check the
returned number against that requested.
"""
search = self.SEARCH_TRANSLATION[search_type]
# Check if the string ID already has the type, if so we do not want to
# add one also Imported playlist have a full path to them, so they do
# not require the A:PLAYLISTS part first
if idstring.startswith(search) or (search_type == "playlists"):
search = ""
search_item_id = search + idstring
search_uri = "#" + search_item_id
# Not sure about the res protocol. But this seems to work
res = [DidlResource(uri=search_uri, protocol_info="x-rincon-playlist:*:*:*")]
search_item = DidlObject(
resources=res, title="", parent_id="", item_id=search_item_id
)
# Call the base version
return self.browse(search_item, start, max_items, full_album_art_uri)
def _music_lib_search(self, search, start, max_items):
"""Perform a music library search and extract search numbers.
You can get an overview of all the relevant search prefixes (like
'A:') and their meaning with the request:
.. code ::
response = device.contentDirectory.Browse([
('ObjectID', '0'),
('BrowseFlag', 'BrowseDirectChildren'),
('Filter', '*'),
('StartingIndex', 0),
('RequestedCount', 100),
('SortCriteria', '')
])
Args:
search (str): The ID to search.
start (int): The index of the forst item to return.
max_items (int): The maximum number of items to return.
Returns:
tuple: (response, metadata) where response is the returned metadata
and metadata is a dict with the 'number_returned',
'total_matches' and 'update_id' integers
"""
response = self.contentDirectory.Browse(
[
("ObjectID", search),
("BrowseFlag", "BrowseDirectChildren"),
("Filter", "*"),
("StartingIndex", start),
("RequestedCount", max_items),
("SortCriteria", ""),
]
)
# Get result information
metadata = {}
for tag in ["NumberReturned", "TotalMatches", "UpdateID"]:
metadata[camel_to_underscore(tag)] = int(response[tag])
return response, metadata
@property
def library_updating(self):
"""bool: whether the music library is in the process of being updated."""
result = self.contentDirectory.GetShareIndexInProgress()
return result["IsIndexing"] != "0"
def start_library_update(self, album_artist_display_option=""):
"""Start an update of the music library.
Args:
album_artist_display_option (str): a value for the album artist
compilation setting (see `album_artist_display_option`).
"""
return self.contentDirectory.RefreshShareIndex(
[
("AlbumArtistDisplayOption", album_artist_display_option),
]
)
def search_track(self, artist, album=None, track=None, full_album_art_uri=False):
"""Search for an artist, an artist's albums, or specific track.
Args:
artist (str): an artist's name.
album (str, optional): an album name. Default `None`.
track (str, optional): a track name. Default `None`.
full_album_art_uri (bool): whether the album art URI should be
absolute (i.e. including the IP address). Default `False`.
Returns:
A `SearchResult` instance.
"""
subcategories = [artist]
subcategories.append(album or "")
# Perform the search
result = self.get_album_artists(
full_album_art_uri=full_album_art_uri,
subcategories=subcategories,
search_term=track,
complete_result=True,
)
result._metadata["search_type"] = "search_track"
return result
def get_albums_for_artist(self, artist, full_album_art_uri=False):
"""Get an artist's albums.
Args:
artist (str): an artist's name.
full_album_art_uri: whether the album art URI should be
absolute (i.e. including the IP address). Default `False`.
Returns:
A `SearchResult` instance.
"""
subcategories = [artist]
result = self.get_album_artists(
full_album_art_uri=full_album_art_uri,
subcategories=subcategories,
complete_result=True,
)
reduced = [item for item in result if item.__class__ == DidlMusicAlbum]
# It is necessary to update the list of items in two places, due to
# a bug in SearchResult
result[:] = reduced
result._metadata.update(
{
"item_list": reduced,
"search_type": "albums_for_artist",
"number_returned": len(reduced),
"total_matches": len(reduced),
}
)
return result
def get_tracks_for_album(self, artist, album, full_album_art_uri=False):
"""Get the tracks of an artist's album.
Args:
artist (str): an artist's name.
album (str): an album name.
full_album_art_uri: whether the album art URI should be
absolute (i.e. including the IP address). Default `False`.
Returns:
A `SearchResult` instance.
"""
subcategories = [artist, album]
result = self.get_album_artists(
full_album_art_uri=full_album_art_uri,
subcategories=subcategories,
complete_result=True,
)
result._metadata["search_type"] = "tracks_for_album"
return result
@property
def album_artist_display_option(self):
"""str: The current value of the album artist compilation setting.
Possible values are:
* ``'WMP'`` - use Album Artists
* ``'ITUNES'`` - use iTunes® Compilations
* ``'NONE'`` - do not group compilations
See Also:
The Sonos `FAQ <https://sonos.custhelp.com
/app/answers/detail/a_id/3056/kw/artist%20compilation>`_ on
compilation albums.
To change the current setting, call `start_library_update` and
pass the new setting.
"""
result = self.contentDirectory.GetAlbumArtistDisplayOption()
return result["AlbumArtistDisplayOption"]
def list_library_shares(self):
"""Return a list of the music library shares.
Returns:
list: The music library shares, which are strings of the form
``'//hostname_or_IP/share_path'``.
"""
response = self.contentDirectory.Browse(
[
("ObjectID", "S:"),
("BrowseFlag", "BrowseDirectChildren"),
("Filter", "*"),
("StartingIndex", "0"),
("RequestedCount", "100"),
("SortCriteria", ""),
]
)
shares = []
matches = response["TotalMatches"]
# Zero matches
if matches == "0":
return shares
xml_dict = xmltodict.parse(response["Result"])
unpacked = xml_dict["DIDL-Lite"]["container"]
# One match
if matches == "1":
shares.append(unpacked["dc:title"])
return shares
# Otherwise it's multiple matches
for share in unpacked:
shares.append(share["dc:title"])
return shares
def delete_library_share(self, share_name):
"""Delete a music library share.
Args:
share_name (str): the name of the share to be deleted, which
should be of the form ``'//hostname_or_IP/share_path'``.
:raises: `SoCoUPnPException`
"""
# share_name must be prefixed with 'S:'
self.contentDirectory.DestroyObject([("ObjectID", "S:" + share_name)])