Home Assistant Unofficial Reference 2024.12.1
browse_media.py
Go to the documentation of this file.
1 """Browse media for forked-daapd."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Sequence
6 from dataclasses import dataclass
7 from typing import TYPE_CHECKING, Any, cast
8 from urllib.parse import quote, unquote
9 
11  BrowseError,
12  BrowseMedia,
13  MediaClass,
14  MediaType,
15 )
16 from homeassistant.helpers.network import is_internal_request
17 
18 from .const import CAN_PLAY_TYPE, URI_SCHEMA
19 
20 if TYPE_CHECKING:
21  from . import media_player
22 
23 MEDIA_TYPE_DIRECTORY = "directory"
24 
25 TOP_LEVEL_LIBRARY = {
26  "Albums": (MediaClass.ALBUM, MediaType.ALBUM, ""),
27  "Artists": (MediaClass.ARTIST, MediaType.ARTIST, ""),
28  "Playlists": (MediaClass.PLAYLIST, MediaType.PLAYLIST, ""),
29  "Albums by Genre": (MediaClass.GENRE, MediaType.GENRE, MediaType.ALBUM),
30  "Tracks by Genre": (MediaClass.GENRE, MediaType.GENRE, MediaType.TRACK),
31  "Artists by Genre": (MediaClass.GENRE, MediaType.GENRE, MediaType.ARTIST),
32  "Directories": (MediaClass.DIRECTORY, MEDIA_TYPE_DIRECTORY, ""),
33 }
34 MEDIA_TYPE_TO_MEDIA_CLASS = {
35  MediaType.ALBUM: MediaClass.ALBUM,
36  MediaType.APP: MediaClass.APP,
37  MediaType.ARTIST: MediaClass.ARTIST,
38  MediaType.TRACK: MediaClass.TRACK,
39  MediaType.PLAYLIST: MediaClass.PLAYLIST,
40  MediaType.GENRE: MediaClass.GENRE,
41  MEDIA_TYPE_DIRECTORY: MediaClass.DIRECTORY,
42 }
43 CAN_EXPAND_TYPE = {
44  MediaType.ALBUM,
45  MediaType.ARTIST,
46  MediaType.PLAYLIST,
47  MediaType.GENRE,
48  MEDIA_TYPE_DIRECTORY,
49 }
50 # The keys and values in the below dict are identical only because the
51 # HA constants happen to align with the OwnTone constants.
52 OWNTONE_TYPE_TO_MEDIA_TYPE = {
53  "track": MediaType.TRACK,
54  "playlist": MediaType.PLAYLIST,
55  "artist": MediaType.ARTIST,
56  "album": MediaType.ALBUM,
57  "genre": MediaType.GENRE,
58  MediaType.APP: MediaType.APP, # This is just for passthrough
59  MEDIA_TYPE_DIRECTORY: MEDIA_TYPE_DIRECTORY, # This is just for passthrough
60 }
61 MEDIA_TYPE_TO_OWNTONE_TYPE = {v: k for k, v in OWNTONE_TYPE_TO_MEDIA_TYPE.items()}
62 
63 # media_content_id is a uri in the form of SCHEMA:Title:OwnToneURI:Subtype (Subtype only used for Genre)
64 # OwnToneURI is in format library:type:id (for directories, id is path)
65 # media_content_type - type of item (mostly used to check if playable or can expand)
66 # OwnTone type may differ from media_content_type when media_content_type is a directory
67 # OwnTone type is used in our own branching, but media_content_type is used for determining playability
68 
69 
70 @dataclass
72  """Class for representing OwnTone media content."""
73 
74  title: str
75  type: str
76  id_or_path: str
77  subtype: str
78 
79  def __init__(self, media_content_id: str) -> None:
80  """Create MediaContent from media_content_id."""
81  (
82  _schema,
83  self.titletitle,
84  _library,
85  self.typetype,
86  self.id_or_pathid_or_path,
87  self.subtype,
88  ) = media_content_id.split(":")
89  self.titletitle = unquote(self.titletitle) # Title may have special characters
90  self.id_or_pathid_or_path = unquote(self.id_or_pathid_or_path) # May have special characters
91  self.typetype = OWNTONE_TYPE_TO_MEDIA_TYPE[self.typetype]
92 
93 
94 def create_owntone_uri(media_type: str, id_or_path: str) -> str:
95  """Create an OwnTone uri."""
96  return f"library:{MEDIA_TYPE_TO_OWNTONE_TYPE[media_type]}:{quote(id_or_path)}"
97 
98 
100  title: str,
101  owntone_uri: str = "",
102  media_type: str = "",
103  id_or_path: str = "",
104  subtype: str = "",
105 ) -> str:
106  """Create a media_content_id.
107 
108  Either owntone_uri or both type and id_or_path must be specified.
109  """
110  if not owntone_uri:
111  owntone_uri = create_owntone_uri(media_type, id_or_path)
112  return f"{URI_SCHEMA}:{quote(title)}:{owntone_uri}:{subtype}"
113 
114 
115 def is_owntone_media_content_id(media_content_id: str) -> bool:
116  """Return whether this media_content_id is from our integration."""
117  return media_content_id[: len(URI_SCHEMA)] == URI_SCHEMA
118 
119 
120 def convert_to_owntone_uri(media_content_id: str) -> str:
121  """Convert media_content_id to OwnTone URI."""
122  return ":".join(media_content_id.split(":")[2:-1])
123 
124 
126  master: media_player.ForkedDaapdMaster,
127  media_content_id: str,
128 ) -> BrowseMedia:
129  """Create response for the given media_content_id."""
130 
131  media_content = MediaContent(media_content_id)
132  result: list[dict[str, int | str]] | dict[str, Any] | None = None
133  if media_content.type == MediaType.APP:
134  return base_owntone_library()
135  # Query API for next level
136  if media_content.type == MEDIA_TYPE_DIRECTORY:
137  # returns tracks, directories, and playlists
138  directory_path = media_content.id_or_path
139  if directory_path:
140  result = await master.api.get_directory(directory=directory_path)
141  else:
142  result = await master.api.get_directory()
143  if result is None:
144  raise BrowseError(
145  f"Media not found for {media_content.type} / {media_content_id}"
146  )
147  # Fill in children with subdirectories
148  children = []
149  assert isinstance(result, dict)
150  for directory in result["directories"]:
151  path = directory["path"]
152  children.append(
153  BrowseMedia(
154  title=path,
155  media_class=MediaClass.DIRECTORY,
156  media_content_id=create_media_content_id(
157  title=path, media_type=MEDIA_TYPE_DIRECTORY, id_or_path=path
158  ),
159  media_content_type=MEDIA_TYPE_DIRECTORY,
160  can_play=False,
161  can_expand=True,
162  )
163  )
164  result = result["tracks"]["items"] + result["playlists"]["items"]
166  master,
167  media_content,
168  cast(list[dict[str, int | str]], result),
169  children,
170  )
171  if media_content.id_or_path == "": # top level search
172  if media_content.type == MediaType.ALBUM:
173  result = (
174  await master.api.get_albums()
175  ) # list of albums with name, artist, uri
176  elif media_content.type == MediaType.ARTIST:
177  result = await master.api.get_artists() # list of artists with name, uri
178  elif media_content.type == MediaType.GENRE:
179  if result := await master.api.get_genres(): # returns list of genre names
180  for item in result:
181  # add generated genre uris to list of genre names
182  item["uri"] = create_owntone_uri(
183  MediaType.GENRE, cast(str, item["name"])
184  )
185  elif media_content.type == MediaType.PLAYLIST:
186  result = (
187  await master.api.get_playlists()
188  ) # list of playlists with name, uri
189  if result is None:
190  raise BrowseError(
191  f"Media not found for {media_content.type} / {media_content_id}"
192  )
194  master,
195  media_content,
196  cast(list[dict[str, int | str]], result),
197  )
198  # Not a directory or top level of library
199  # We should have content type and id
200  if media_content.type == MediaType.ALBUM:
201  result = await master.api.get_tracks(album_id=media_content.id_or_path)
202  elif media_content.type == MediaType.ARTIST:
203  result = await master.api.get_albums(artist_id=media_content.id_or_path)
204  elif media_content.type == MediaType.GENRE:
205  if media_content.subtype in {
206  MediaType.ALBUM,
207  MediaType.ARTIST,
208  MediaType.TRACK,
209  }:
210  result = await master.api.get_genre(
211  media_content.id_or_path, media_type=media_content.subtype
212  )
213  elif media_content.type == MediaType.PLAYLIST:
214  result = await master.api.get_tracks(playlist_id=media_content.id_or_path)
215 
216  if result is None:
217  raise BrowseError(
218  f"Media not found for {media_content.type} / {media_content_id}"
219  )
220 
222  master, media_content, cast(list[dict[str, int | str]], result)
223  )
224 
225 
227  master: media_player.ForkedDaapdMaster,
228  media_content: MediaContent,
229  result: list[dict[str, int | str]],
230  children: list[BrowseMedia] | None = None,
231 ) -> BrowseMedia:
232  """Convert the results into a browse media response."""
233  internal_request = is_internal_request(master.hass)
234  if not children: # Directory searches will pass in subdirectories as children
235  children = []
236  for item in result:
237  if item.get("data_kind") == "spotify" or (
238  "path" in item and cast(str, item["path"]).startswith("spotify")
239  ): # Exclude spotify data from OwnTone library
240  continue
241  assert isinstance(item["uri"], str)
242  media_type = OWNTONE_TYPE_TO_MEDIA_TYPE[item["uri"].split(":")[1]]
243  title = item.get("name") or item.get("title") # only tracks use title
244  assert isinstance(title, str)
245  media_content_id = create_media_content_id(
246  title=f"{media_content.title} / {title}",
247  owntone_uri=item["uri"],
248  subtype=media_content.subtype,
249  )
250  if artwork := item.get("artwork_url"):
251  thumbnail = (
252  master.api.full_url(cast(str, artwork))
253  if internal_request
254  else master.get_browse_image_url(media_type, media_content_id)
255  )
256  else:
257  thumbnail = None
258  children.append(
259  BrowseMedia(
260  title=title,
261  media_class=MEDIA_TYPE_TO_MEDIA_CLASS[media_type],
262  media_content_id=media_content_id,
263  media_content_type=media_type,
264  can_play=media_type in CAN_PLAY_TYPE,
265  can_expand=media_type in CAN_EXPAND_TYPE,
266  thumbnail=thumbnail,
267  )
268  )
269  return BrowseMedia(
270  title=media_content.id_or_path
271  if media_content.type == MEDIA_TYPE_DIRECTORY
272  else media_content.title,
273  media_class=MEDIA_TYPE_TO_MEDIA_CLASS[media_content.type],
274  media_content_id="",
275  media_content_type=media_content.type,
276  can_play=media_content.type in CAN_PLAY_TYPE,
277  can_expand=media_content.type in CAN_EXPAND_TYPE,
278  children=children,
279  )
280 
281 
282 def base_owntone_library() -> BrowseMedia:
283  """Return the base of our OwnTone library."""
284  children = [
285  BrowseMedia(
286  title=name,
287  media_class=media_class,
288  media_content_id=create_media_content_id(
289  title=name, media_type=media_type, subtype=media_subtype
290  ),
291  media_content_type=MEDIA_TYPE_DIRECTORY,
292  can_play=False,
293  can_expand=True,
294  )
295  for name, (media_class, media_type, media_subtype) in TOP_LEVEL_LIBRARY.items()
296  ]
297  return BrowseMedia(
298  title="OwnTone Library",
299  media_class=MediaClass.APP,
300  media_content_id=create_media_content_id(
301  title="OwnTone Library", media_type=MediaType.APP
302  ),
303  media_content_type=MediaType.APP,
304  can_play=False,
305  can_expand=True,
306  children=children,
307  thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png",
308  )
309 
310 
311 def library(other: Sequence[BrowseMedia] | None) -> BrowseMedia:
312  """Create response to describe contents of library."""
313 
314  top_level_items = [
315  BrowseMedia(
316  title="OwnTone Library",
317  media_class=MediaClass.APP,
318  media_content_id=create_media_content_id(
319  title="OwnTone Library", media_type=MediaType.APP
320  ),
321  media_content_type=MediaType.APP,
322  can_play=False,
323  can_expand=True,
324  thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png",
325  )
326  ]
327  if other:
328  top_level_items.extend(other)
329 
330  return BrowseMedia(
331  title="OwnTone",
332  media_class=MediaClass.DIRECTORY,
333  media_content_id="",
334  media_content_type=MEDIA_TYPE_DIRECTORY,
335  can_play=False,
336  can_expand=True,
337  children=top_level_items,
338  )
BrowseMedia create_browse_media_response(media_player.ForkedDaapdMaster master, MediaContent media_content, list[dict[str, int|str]] result, list[BrowseMedia]|None children=None)
BrowseMedia library(Sequence[BrowseMedia]|None other)
str create_owntone_uri(str media_type, str id_or_path)
Definition: browse_media.py:94
bool is_owntone_media_content_id(str media_content_id)
BrowseMedia get_owntone_content(media_player.ForkedDaapdMaster master, str media_content_id)
str create_media_content_id(str title, str owntone_uri="", str media_type="", str id_or_path="", str subtype="")
bool is_internal_request(HomeAssistant hass)
Definition: network.py:31