mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Add browse media to forked-daapd (#79009)
* Add browse media to forked-daapd * Use elif in async_browse_image * Add tests * Add tests * Add test * Fix test
This commit is contained in:
parent
1e3a3d32ad
commit
499c3410d1
331
homeassistant/components/forked_daapd/browse_media.py
Normal file
331
homeassistant/components/forked_daapd/browse_media.py
Normal file
@ -0,0 +1,331 @@
|
||||
"""Browse media for forked-daapd."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Union, cast
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
|
||||
from homeassistant.components.media_player.errors import BrowseError
|
||||
from homeassistant.helpers.network import is_internal_request
|
||||
|
||||
from .const import CAN_PLAY_TYPE, URI_SCHEMA
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import media_player
|
||||
|
||||
MEDIA_TYPE_DIRECTORY = "directory"
|
||||
|
||||
TOP_LEVEL_LIBRARY = {
|
||||
"Albums": (MediaClass.ALBUM, MediaType.ALBUM, ""),
|
||||
"Artists": (MediaClass.ARTIST, MediaType.ARTIST, ""),
|
||||
"Playlists": (MediaClass.PLAYLIST, MediaType.PLAYLIST, ""),
|
||||
"Albums by Genre": (MediaClass.GENRE, MediaType.GENRE, MediaType.ALBUM),
|
||||
"Tracks by Genre": (MediaClass.GENRE, MediaType.GENRE, MediaType.TRACK),
|
||||
"Artists by Genre": (MediaClass.GENRE, MediaType.GENRE, MediaType.ARTIST),
|
||||
"Directories": (MediaClass.DIRECTORY, MEDIA_TYPE_DIRECTORY, ""),
|
||||
}
|
||||
MEDIA_TYPE_TO_MEDIA_CLASS = {
|
||||
MediaType.ALBUM: MediaClass.ALBUM,
|
||||
MediaType.APP: MediaClass.APP,
|
||||
MediaType.ARTIST: MediaClass.ARTIST,
|
||||
MediaType.TRACK: MediaClass.TRACK,
|
||||
MediaType.PLAYLIST: MediaClass.PLAYLIST,
|
||||
MediaType.GENRE: MediaClass.GENRE,
|
||||
MEDIA_TYPE_DIRECTORY: MediaClass.DIRECTORY,
|
||||
}
|
||||
CAN_EXPAND_TYPE = {
|
||||
MediaType.ALBUM,
|
||||
MediaType.ARTIST,
|
||||
MediaType.PLAYLIST,
|
||||
MediaType.GENRE,
|
||||
MEDIA_TYPE_DIRECTORY,
|
||||
}
|
||||
# The keys and values in the below dict are identical only because the
|
||||
# HA constants happen to align with the Owntone constants.
|
||||
OWNTONE_TYPE_TO_MEDIA_TYPE = {
|
||||
"track": MediaType.TRACK,
|
||||
"playlist": MediaType.PLAYLIST,
|
||||
"artist": MediaType.ARTIST,
|
||||
"album": MediaType.ALBUM,
|
||||
"genre": MediaType.GENRE,
|
||||
MediaType.APP: MediaType.APP, # This is just for passthrough
|
||||
MEDIA_TYPE_DIRECTORY: MEDIA_TYPE_DIRECTORY, # This is just for passthrough
|
||||
}
|
||||
MEDIA_TYPE_TO_OWNTONE_TYPE = {v: k for k, v in OWNTONE_TYPE_TO_MEDIA_TYPE.items()}
|
||||
|
||||
"""
|
||||
media_content_id is a uri in the form of SCHEMA:Title:OwnToneURI:Subtype (Subtype only used for Genre)
|
||||
OwnToneURI is in format library:type:id (for directories, id is path)
|
||||
media_content_type - type of item (mostly used to check if playable or can expand)
|
||||
Owntone type may differ from media_content_type when media_content_type is a directory
|
||||
Owntown type is used in our own branching, but media_content_type is used for determining playability
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaContent:
|
||||
"""Class for representing Owntone media content."""
|
||||
|
||||
title: str
|
||||
type: str
|
||||
id_or_path: str
|
||||
subtype: str
|
||||
|
||||
def __init__(self, media_content_id: str) -> None:
|
||||
"""Create MediaContent from media_content_id."""
|
||||
(
|
||||
_schema,
|
||||
self.title,
|
||||
_library,
|
||||
self.type,
|
||||
self.id_or_path,
|
||||
self.subtype,
|
||||
) = media_content_id.split(":")
|
||||
self.title = unquote(self.title) # Title may have special characters
|
||||
self.id_or_path = unquote(self.id_or_path) # May have special characters
|
||||
self.type = OWNTONE_TYPE_TO_MEDIA_TYPE[self.type]
|
||||
|
||||
|
||||
def create_owntone_uri(media_type: str, id_or_path: str) -> str:
|
||||
"""Create an Owntone uri."""
|
||||
return f"library:{MEDIA_TYPE_TO_OWNTONE_TYPE[media_type]}:{quote(id_or_path)}"
|
||||
|
||||
|
||||
def create_media_content_id(
|
||||
title: str,
|
||||
owntone_uri: str = "",
|
||||
media_type: str = "",
|
||||
id_or_path: str = "",
|
||||
subtype: str = "",
|
||||
) -> str:
|
||||
"""Create a media_content_id.
|
||||
|
||||
Either owntone_uri or both type and id_or_path must be specified.
|
||||
"""
|
||||
if not owntone_uri:
|
||||
owntone_uri = create_owntone_uri(media_type, id_or_path)
|
||||
return f"{URI_SCHEMA}:{quote(title)}:{owntone_uri}:{subtype}"
|
||||
|
||||
|
||||
def is_owntone_media_content_id(media_content_id: str) -> bool:
|
||||
"""Return whether this media_content_id is from our integration."""
|
||||
return media_content_id[: len(URI_SCHEMA)] == URI_SCHEMA
|
||||
|
||||
|
||||
def convert_to_owntone_uri(media_content_id: str) -> str:
|
||||
"""Convert media_content_id to Owntone URI."""
|
||||
return ":".join(media_content_id.split(":")[2:-1])
|
||||
|
||||
|
||||
async def get_owntone_content(
|
||||
master: media_player.ForkedDaapdMaster,
|
||||
media_content_id: str,
|
||||
) -> BrowseMedia:
|
||||
"""Create response for the given media_content_id."""
|
||||
|
||||
media_content = MediaContent(media_content_id)
|
||||
result: list[dict[str, int | str]] | dict[str, Any] | None = None
|
||||
if media_content.type == MediaType.APP:
|
||||
return base_owntone_library()
|
||||
# Query API for next level
|
||||
if media_content.type == MEDIA_TYPE_DIRECTORY:
|
||||
# returns tracks, directories, and playlists
|
||||
directory_path = media_content.id_or_path
|
||||
if directory_path:
|
||||
result = await master.api.get_directory(directory=directory_path)
|
||||
else:
|
||||
result = await master.api.get_directory()
|
||||
if result is None:
|
||||
raise BrowseError(
|
||||
f"Media not found for {media_content.type} / {media_content_id}"
|
||||
)
|
||||
# Fill in children with subdirectories
|
||||
children = []
|
||||
assert isinstance(result, dict)
|
||||
for directory in result["directories"]:
|
||||
path = directory["path"]
|
||||
children.append(
|
||||
BrowseMedia(
|
||||
title=path,
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id=create_media_content_id(
|
||||
title=path, media_type=MEDIA_TYPE_DIRECTORY, id_or_path=path
|
||||
),
|
||||
media_content_type=MEDIA_TYPE_DIRECTORY,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
)
|
||||
)
|
||||
result = result["tracks"]["items"] + result["playlists"]["items"]
|
||||
return create_browse_media_response(
|
||||
master,
|
||||
media_content,
|
||||
cast(list[dict[str, Union[int, str]]], result),
|
||||
children,
|
||||
)
|
||||
if media_content.id_or_path == "": # top level search
|
||||
if media_content.type == MediaType.ALBUM:
|
||||
result = (
|
||||
await master.api.get_albums()
|
||||
) # list of albums with name, artist, uri
|
||||
elif media_content.type == MediaType.ARTIST:
|
||||
result = await master.api.get_artists() # list of artists with name, uri
|
||||
elif media_content.type == MediaType.GENRE:
|
||||
if result := await master.api.get_genres(): # returns list of genre names
|
||||
for item in result: # pylint: disable=not-an-iterable
|
||||
# add generated genre uris to list of genre names
|
||||
item["uri"] = create_owntone_uri(
|
||||
MediaType.GENRE, cast(str, item["name"])
|
||||
)
|
||||
elif media_content.type == MediaType.PLAYLIST:
|
||||
result = (
|
||||
await master.api.get_playlists()
|
||||
) # list of playlists with name, uri
|
||||
if result is None:
|
||||
raise BrowseError(
|
||||
f"Media not found for {media_content.type} / {media_content_id}"
|
||||
)
|
||||
return create_browse_media_response(
|
||||
master,
|
||||
media_content,
|
||||
cast(list[dict[str, Union[int, str]]], result),
|
||||
)
|
||||
# Not a directory or top level of library
|
||||
# We should have content type and id
|
||||
if media_content.type == MediaType.ALBUM:
|
||||
result = await master.api.get_tracks(album_id=media_content.id_or_path)
|
||||
elif media_content.type == MediaType.ARTIST:
|
||||
result = await master.api.get_albums(artist_id=media_content.id_or_path)
|
||||
elif media_content.type == MediaType.GENRE:
|
||||
if media_content.subtype in {
|
||||
MediaType.ALBUM,
|
||||
MediaType.ARTIST,
|
||||
MediaType.TRACK,
|
||||
}:
|
||||
result = await master.api.get_genre(
|
||||
media_content.id_or_path, media_type=media_content.subtype
|
||||
)
|
||||
elif media_content.type == MediaType.PLAYLIST:
|
||||
result = await master.api.get_tracks(playlist_id=media_content.id_or_path)
|
||||
|
||||
if result is None:
|
||||
raise BrowseError(
|
||||
f"Media not found for {media_content.type} / {media_content_id}"
|
||||
)
|
||||
|
||||
return create_browse_media_response(
|
||||
master, media_content, cast(list[dict[str, Union[int, str]]], result)
|
||||
)
|
||||
|
||||
|
||||
def create_browse_media_response(
|
||||
master: media_player.ForkedDaapdMaster,
|
||||
media_content: MediaContent,
|
||||
result: list[dict[str, int | str]],
|
||||
children: list[BrowseMedia] | None = None,
|
||||
) -> BrowseMedia:
|
||||
"""Convert the results into a browse media response."""
|
||||
internal_request = is_internal_request(master.hass)
|
||||
if not children: # Directory searches will pass in subdirectories as children
|
||||
children = []
|
||||
for item in result:
|
||||
assert isinstance(item["uri"], str)
|
||||
media_type = OWNTONE_TYPE_TO_MEDIA_TYPE[item["uri"].split(":")[1]]
|
||||
title = item.get("name") or item.get("title") # only tracks use title
|
||||
assert isinstance(title, str)
|
||||
media_content_id = create_media_content_id(
|
||||
title=f"{media_content.title} / {title}",
|
||||
owntone_uri=item["uri"],
|
||||
subtype=media_content.subtype,
|
||||
)
|
||||
if artwork := item.get("artwork_url"):
|
||||
thumbnail = (
|
||||
master.api.full_url(cast(str, artwork))
|
||||
if internal_request
|
||||
else master.get_browse_image_url(media_type, media_content_id)
|
||||
)
|
||||
else:
|
||||
thumbnail = None
|
||||
children.append(
|
||||
BrowseMedia(
|
||||
title=title,
|
||||
media_class=MEDIA_TYPE_TO_MEDIA_CLASS[media_type],
|
||||
media_content_id=media_content_id,
|
||||
media_content_type=media_type,
|
||||
can_play=media_type in CAN_PLAY_TYPE,
|
||||
can_expand=media_type in CAN_EXPAND_TYPE,
|
||||
thumbnail=thumbnail,
|
||||
)
|
||||
)
|
||||
return BrowseMedia(
|
||||
title=media_content.id_or_path
|
||||
if media_content.type == MEDIA_TYPE_DIRECTORY
|
||||
else media_content.title,
|
||||
media_class=MEDIA_TYPE_TO_MEDIA_CLASS[media_content.type],
|
||||
media_content_id="",
|
||||
media_content_type=media_content.type,
|
||||
can_play=media_content.type in CAN_PLAY_TYPE,
|
||||
can_expand=media_content.type in CAN_EXPAND_TYPE,
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def base_owntone_library() -> BrowseMedia:
|
||||
"""Return the base of our Owntone library."""
|
||||
children = [
|
||||
BrowseMedia(
|
||||
title=name,
|
||||
media_class=media_class,
|
||||
media_content_id=create_media_content_id(
|
||||
title=name, media_type=media_type, subtype=media_subtype
|
||||
),
|
||||
media_content_type=MEDIA_TYPE_DIRECTORY,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
)
|
||||
for name, (media_class, media_type, media_subtype) in TOP_LEVEL_LIBRARY.items()
|
||||
]
|
||||
return BrowseMedia(
|
||||
title="Owntone Library",
|
||||
media_class=MediaClass.APP,
|
||||
media_content_id=create_media_content_id(
|
||||
title="Owntone Library", media_type=MediaType.APP
|
||||
),
|
||||
media_content_type=MediaType.APP,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=children,
|
||||
thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png",
|
||||
)
|
||||
|
||||
|
||||
def library(other: Sequence[BrowseMedia] | None) -> BrowseMedia:
|
||||
"""Create response to describe contents of library."""
|
||||
|
||||
top_level_items = [
|
||||
BrowseMedia(
|
||||
title="Owntone Library",
|
||||
media_class=MediaClass.APP,
|
||||
media_content_id=create_media_content_id(
|
||||
title="Owntone Library", media_type=MediaType.APP
|
||||
),
|
||||
media_content_type=MediaType.APP,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png",
|
||||
)
|
||||
]
|
||||
if other:
|
||||
top_level_items.extend(other)
|
||||
|
||||
return BrowseMedia(
|
||||
title="Owntone",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id="",
|
||||
media_content_type=MEDIA_TYPE_DIRECTORY,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=top_level_items,
|
||||
)
|
@ -1,5 +1,5 @@
|
||||
"""Const for forked-daapd."""
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType
|
||||
|
||||
CALLBACK_TIMEOUT = 8 # max time between command and callback from forked-daapd server
|
||||
CAN_PLAY_TYPE = {
|
||||
@ -11,6 +11,12 @@ CAN_PLAY_TYPE = {
|
||||
"audio/x-ms-wma",
|
||||
"audio/aiff",
|
||||
"audio/wav",
|
||||
MediaType.TRACK,
|
||||
MediaType.PLAYLIST,
|
||||
MediaType.ARTIST,
|
||||
MediaType.ALBUM,
|
||||
MediaType.GENRE,
|
||||
MediaType.MUSIC,
|
||||
}
|
||||
CONF_LIBRESPOT_JAVA_PORT = "librespot_java_port"
|
||||
CONF_MAX_PLAYLISTS = "max_playlists"
|
||||
@ -82,3 +88,4 @@ SUPPORTED_FEATURES_ZONE = (
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
)
|
||||
TTS_TIMEOUT = 20 # max time to wait between TTS getting sent and starting to play
|
||||
URI_SCHEMA = "owntone"
|
||||
|
@ -32,6 +32,12 @@ from homeassistant.helpers.dispatcher import (
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .browse_media import (
|
||||
convert_to_owntone_uri,
|
||||
get_owntone_content,
|
||||
is_owntone_media_content_id,
|
||||
library,
|
||||
)
|
||||
from .const import (
|
||||
CALLBACK_TIMEOUT,
|
||||
CAN_PLAY_TYPE,
|
||||
@ -238,7 +244,8 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
self, clientsession, api, ip_address, api_port, api_password, config_entry
|
||||
):
|
||||
"""Initialize the ForkedDaapd Master Device."""
|
||||
self._api = api
|
||||
# Leave the api public so the browse media helpers can use it
|
||||
self.api = api
|
||||
self._player = STARTUP_DATA[
|
||||
"player"
|
||||
] # _player, _outputs, and _queue are loaded straight from api
|
||||
@ -416,13 +423,13 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Restore the last on outputs state."""
|
||||
# restore state
|
||||
await self._api.set_volume(volume=self._last_volume * 100)
|
||||
await self.api.set_volume(volume=self._last_volume * 100)
|
||||
if self._last_outputs:
|
||||
futures: list[asyncio.Task[int]] = []
|
||||
for output in self._last_outputs:
|
||||
futures.append(
|
||||
asyncio.create_task(
|
||||
self._api.change_output(
|
||||
self.api.change_output(
|
||||
output["id"],
|
||||
selected=output["selected"],
|
||||
volume=output["volume"],
|
||||
@ -431,7 +438,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
)
|
||||
await asyncio.wait(futures)
|
||||
else: # enable all outputs
|
||||
await self._api.set_enabled_outputs(
|
||||
await self.api.set_enabled_outputs(
|
||||
[output["id"] for output in self._outputs]
|
||||
)
|
||||
|
||||
@ -440,7 +447,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
await self.async_media_pause()
|
||||
self._last_outputs = self._outputs
|
||||
if any(output["selected"] for output in self._outputs):
|
||||
await self._api.set_enabled_outputs([])
|
||||
await self.api.set_enabled_outputs([])
|
||||
|
||||
async def async_toggle(self) -> None:
|
||||
"""Toggle the power on the device.
|
||||
@ -568,32 +575,32 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
target_volume = 0
|
||||
else:
|
||||
target_volume = self._last_volume # restore volume level
|
||||
await self._api.set_volume(volume=target_volume * 100)
|
||||
await self.api.set_volume(volume=target_volume * 100)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume - input range [0,1]."""
|
||||
await self._api.set_volume(volume=volume * 100)
|
||||
await self.api.set_volume(volume=volume * 100)
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Start playback."""
|
||||
if self._use_pipe_control():
|
||||
await self._pipe_call(self._use_pipe_control(), "async_media_play")
|
||||
else:
|
||||
await self._api.start_playback()
|
||||
await self.api.start_playback()
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Pause playback."""
|
||||
if self._use_pipe_control():
|
||||
await self._pipe_call(self._use_pipe_control(), "async_media_pause")
|
||||
else:
|
||||
await self._api.pause_playback()
|
||||
await self.api.pause_playback()
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Stop playback."""
|
||||
if self._use_pipe_control():
|
||||
await self._pipe_call(self._use_pipe_control(), "async_media_stop")
|
||||
else:
|
||||
await self._api.stop_playback()
|
||||
await self.api.stop_playback()
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Skip to previous track."""
|
||||
@ -602,32 +609,32 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
self._use_pipe_control(), "async_media_previous_track"
|
||||
)
|
||||
else:
|
||||
await self._api.previous_track()
|
||||
await self.api.previous_track()
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Skip to next track."""
|
||||
if self._use_pipe_control():
|
||||
await self._pipe_call(self._use_pipe_control(), "async_media_next_track")
|
||||
else:
|
||||
await self._api.next_track()
|
||||
await self.api.next_track()
|
||||
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Seek to position."""
|
||||
await self._api.seek(position_ms=position * 1000)
|
||||
await self.api.seek(position_ms=position * 1000)
|
||||
|
||||
async def async_clear_playlist(self) -> None:
|
||||
"""Clear playlist."""
|
||||
await self._api.clear_queue()
|
||||
await self.api.clear_queue()
|
||||
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
"""Enable/disable shuffle mode."""
|
||||
await self._api.shuffle(shuffle)
|
||||
await self.api.shuffle(shuffle)
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Image url of current playing media."""
|
||||
if url := self._track_info.get("artwork_url"):
|
||||
url = self._api.full_url(url)
|
||||
url = self.api.full_url(url)
|
||||
return url
|
||||
|
||||
async def _save_and_set_tts_volumes(self):
|
||||
@ -635,11 +642,11 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
self._last_volume = self.volume_level
|
||||
self._last_outputs = self._outputs
|
||||
if self._outputs:
|
||||
await self._api.set_volume(volume=self._tts_volume * 100)
|
||||
await self.api.set_volume(volume=self._tts_volume * 100)
|
||||
futures = []
|
||||
for output in self._outputs:
|
||||
futures.append(
|
||||
self._api.change_output(
|
||||
self.api.change_output(
|
||||
output["id"], selected=True, volume=self._tts_volume * 100
|
||||
)
|
||||
)
|
||||
@ -660,12 +667,20 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
"""Play a URI."""
|
||||
|
||||
# Preprocess media_ids
|
||||
if media_source.is_media_source_id(media_id):
|
||||
media_type = MediaType.MUSIC
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = play_item.url
|
||||
elif is_owntone_media_content_id(media_id):
|
||||
media_id = convert_to_owntone_uri(media_id)
|
||||
|
||||
if media_type not in CAN_PLAY_TYPE:
|
||||
_LOGGER.warning("Media type '%s' not supported", media_type)
|
||||
return
|
||||
|
||||
if media_type == MediaType.MUSIC:
|
||||
media_id = async_process_play_media_url(self.hass, media_id)
|
||||
@ -684,7 +699,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE
|
||||
)
|
||||
if enqueue in {True, MediaPlayerEnqueue.ADD, MediaPlayerEnqueue.REPLACE}:
|
||||
return await self._api.add_to_queue(
|
||||
return await self.api.add_to_queue(
|
||||
uris=media_id,
|
||||
playback="start",
|
||||
clear=enqueue == MediaPlayerEnqueue.REPLACE,
|
||||
@ -699,13 +714,13 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
0,
|
||||
)
|
||||
if enqueue == MediaPlayerEnqueue.NEXT:
|
||||
return await self._api.add_to_queue(
|
||||
return await self.api.add_to_queue(
|
||||
uris=media_id,
|
||||
playback="start",
|
||||
position=current_position + 1,
|
||||
)
|
||||
# enqueue == MediaPlayerEnqueue.PLAY
|
||||
return await self._api.add_to_queue(
|
||||
return await self.api.add_to_queue(
|
||||
uris=media_id,
|
||||
playback="start",
|
||||
position=current_position,
|
||||
@ -732,7 +747,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
)
|
||||
self._tts_requested = True
|
||||
await sleep_future
|
||||
await self._api.add_to_queue(uris=media_id, playback="start", clear=True)
|
||||
await self.api.add_to_queue(uris=media_id, playback="start", clear=True)
|
||||
try:
|
||||
async with async_timeout.timeout(TTS_TIMEOUT):
|
||||
await self._tts_playing_event.wait()
|
||||
@ -751,7 +766,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
if saved_mute: # mute if we were muted
|
||||
await self.async_mute_volume(True)
|
||||
if self._use_pipe_control(): # resume pipe
|
||||
await self._api.add_to_queue(
|
||||
await self.api.add_to_queue(
|
||||
uris=self._sources_uris[self._source], clear=True
|
||||
)
|
||||
if saved_state == MediaPlayerState.PLAYING:
|
||||
@ -760,13 +775,13 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
if not saved_queue:
|
||||
return
|
||||
# Restore stashed queue
|
||||
await self._api.add_to_queue(
|
||||
await self.api.add_to_queue(
|
||||
uris=",".join(item["uri"] for item in saved_queue["items"]),
|
||||
playback="start",
|
||||
playback_from_position=saved_queue_position,
|
||||
clear=True,
|
||||
)
|
||||
await self._api.seek(position_ms=saved_song_position)
|
||||
await self.api.seek(position_ms=saved_song_position)
|
||||
if saved_state == MediaPlayerState.PAUSED:
|
||||
await self.async_media_pause()
|
||||
return
|
||||
@ -788,9 +803,9 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
if not self._use_pipe_control(): # playlist or clear ends up at default
|
||||
self._source = SOURCE_NAME_DEFAULT
|
||||
if self._sources_uris.get(source): # load uris for pipes or playlists
|
||||
await self._api.add_to_queue(uris=self._sources_uris[source], clear=True)
|
||||
await self.api.add_to_queue(uris=self._sources_uris[source], clear=True)
|
||||
elif source == SOURCE_NAME_CLEAR: # clear playlist
|
||||
await self._api.clear_queue()
|
||||
await self.api.clear_queue()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _use_pipe_control(self):
|
||||
@ -813,11 +828,50 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
media_content_id: str | None = None,
|
||||
) -> BrowseMedia:
|
||||
"""Implement the websocket media browsing helper."""
|
||||
return await media_source.async_browse_media(
|
||||
self.hass,
|
||||
media_content_id,
|
||||
content_filter=lambda bm: bm.media_content_type in CAN_PLAY_TYPE,
|
||||
)
|
||||
if media_content_id is None or media_source.is_media_source_id(
|
||||
media_content_id
|
||||
):
|
||||
ms_result = await media_source.async_browse_media(
|
||||
self.hass,
|
||||
media_content_id,
|
||||
content_filter=lambda bm: bm.media_content_type in CAN_PLAY_TYPE,
|
||||
)
|
||||
if media_content_type is None:
|
||||
# This is the base level, so we combine our library with the media source
|
||||
return library(ms_result.children)
|
||||
return ms_result
|
||||
# media_content_type should only be None if media_content_id is None
|
||||
assert media_content_type
|
||||
return await get_owntone_content(self, media_content_id)
|
||||
|
||||
async def async_get_browse_image(
|
||||
self,
|
||||
media_content_type: str,
|
||||
media_content_id: str,
|
||||
media_image_id: str | None = None,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
"""Fetch image for media browser."""
|
||||
|
||||
if media_content_type not in {
|
||||
MediaType.TRACK,
|
||||
MediaType.ALBUM,
|
||||
MediaType.ARTIST,
|
||||
}:
|
||||
return None, None
|
||||
owntone_uri = convert_to_owntone_uri(media_content_id)
|
||||
item_id_str = owntone_uri.rsplit(":", maxsplit=1)[-1]
|
||||
if media_content_type == MediaType.TRACK:
|
||||
result = await self.api.get_track(int(item_id_str))
|
||||
elif media_content_type == MediaType.ALBUM:
|
||||
if result := await self.api.get_albums():
|
||||
result = next(
|
||||
(item for item in result if item["id"] == item_id_str), None
|
||||
)
|
||||
elif result := await self.api.get_artists():
|
||||
result = next((item for item in result if item["id"] == item_id_str), None)
|
||||
if url := result.get("artwork_url"):
|
||||
return await self._async_fetch_image(self.api.full_url(url))
|
||||
return None, None
|
||||
|
||||
|
||||
class ForkedDaapdUpdater:
|
||||
|
28
tests/components/forked_daapd/conftest.py
Normal file
28
tests/components/forked_daapd/conftest.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""Common fixtures for forked_daapd tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.forked_daapd.const import CONF_TTS_PAUSE_TIME, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry")
|
||||
def config_entry_fixture():
|
||||
"""Create hass config_entry fixture."""
|
||||
data = {
|
||||
CONF_HOST: "192.168.1.1",
|
||||
CONF_PORT: "2345",
|
||||
CONF_PASSWORD: "",
|
||||
}
|
||||
return MockConfigEntry(
|
||||
version=1,
|
||||
domain=DOMAIN,
|
||||
title="",
|
||||
data=data,
|
||||
options={CONF_TTS_PAUSE_TIME: 0},
|
||||
source=SOURCE_USER,
|
||||
entry_id=1,
|
||||
)
|
294
tests/components/forked_daapd/test_browse_media.py
Normal file
294
tests/components/forked_daapd/test_browse_media.py
Normal file
@ -0,0 +1,294 @@
|
||||
"""Media browsing tests for the forked_daapd media player platform."""
|
||||
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.forked_daapd.browse_media import create_media_content_id
|
||||
from homeassistant.components.media_player import MediaType
|
||||
from homeassistant.components.websocket_api.const import TYPE_RESULT
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
TEST_MASTER_ENTITY_NAME = "media_player.forked_daapd_server"
|
||||
|
||||
|
||||
async def test_async_browse_media(hass, hass_ws_client, config_entry):
|
||||
"""Test browse media."""
|
||||
|
||||
assert await async_setup_component(hass, media_source.DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI",
|
||||
autospec=True,
|
||||
) as mock_api:
|
||||
config_entry.add_to_hass(hass)
|
||||
await config_entry.async_setup(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_api.return_value.full_url = lambda x: "http://owntone_instance/" + x
|
||||
mock_api.return_value.get_directory.side_effect = [
|
||||
{
|
||||
"directories": [
|
||||
{"path": "/music/srv/Audiobooks"},
|
||||
{"path": "/music/srv/Music"},
|
||||
{"path": "/music/srv/Playlists"},
|
||||
{"path": "/music/srv/Podcasts"},
|
||||
],
|
||||
"tracks": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "input.pipe",
|
||||
"artist": "Unknown artist",
|
||||
"artist_sort": "Unknown artist",
|
||||
"album": "Unknown album",
|
||||
"album_sort": "Unknown album",
|
||||
"album_id": "4201163758598356043",
|
||||
"album_artist": "Unknown artist",
|
||||
"album_artist_sort": "Unknown artist",
|
||||
"album_artist_id": "4187901437947843388",
|
||||
"genre": "Unknown genre",
|
||||
"year": 0,
|
||||
"track_number": 0,
|
||||
"disc_number": 0,
|
||||
"length_ms": 0,
|
||||
"play_count": 0,
|
||||
"skip_count": 0,
|
||||
"time_added": "2018-11-24T08:41:35Z",
|
||||
"seek_ms": 0,
|
||||
"media_kind": "music",
|
||||
"data_kind": "pipe",
|
||||
"path": "/music/srv/input.pipe",
|
||||
"uri": "library:track:1",
|
||||
"artwork_url": "/artwork/item/1",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"offset": 0,
|
||||
"limit": -1,
|
||||
},
|
||||
"playlists": {
|
||||
"items": [
|
||||
{
|
||||
"id": 8,
|
||||
"name": "radio",
|
||||
"path": "/music/srv/radio.m3u",
|
||||
"smart_playlist": True,
|
||||
"uri": "library:playlist:8",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"offset": 0,
|
||||
"limit": -1,
|
||||
},
|
||||
}
|
||||
] + 4 * [
|
||||
{"directories": [], "tracks": {"items": []}, "playlists": {"items": []}}
|
||||
]
|
||||
mock_api.return_value.get_albums.return_value = [
|
||||
{
|
||||
"id": "8009851123233197743",
|
||||
"name": "Add Violence",
|
||||
"name_sort": "Add Violence",
|
||||
"artist": "Nine Inch Nails",
|
||||
"artist_id": "32561671101664759",
|
||||
"track_count": 5,
|
||||
"length_ms": 1634961,
|
||||
"uri": "library:album:8009851123233197743",
|
||||
},
|
||||
]
|
||||
mock_api.return_value.get_artists.return_value = [
|
||||
{
|
||||
"id": "3815427709949443149",
|
||||
"name": "ABAY",
|
||||
"name_sort": "ABAY",
|
||||
"album_count": 1,
|
||||
"track_count": 10,
|
||||
"length_ms": 2951554,
|
||||
"uri": "library:artist:3815427709949443149",
|
||||
},
|
||||
]
|
||||
mock_api.return_value.get_genres.return_value = [
|
||||
{"name": "Classical"},
|
||||
{"name": "Drum & Bass"},
|
||||
{"name": "Pop"},
|
||||
{"name": "Rock/Pop"},
|
||||
{"name": "'90s Alternative"},
|
||||
]
|
||||
mock_api.return_value.get_playlists.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "radio",
|
||||
"path": "/music/srv/radio.m3u",
|
||||
"smart_playlist": False,
|
||||
"uri": "library:playlist:1",
|
||||
},
|
||||
]
|
||||
|
||||
# Request playlist through WebSocket
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "media_player/browse_media",
|
||||
"entity_id": TEST_MASTER_ENTITY_NAME,
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
# Assert WebSocket response
|
||||
assert msg["id"] == 1
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert msg["success"]
|
||||
|
||||
msg_id = 2
|
||||
|
||||
async def browse_children(children):
|
||||
"""Browse the children of this BrowseMedia."""
|
||||
nonlocal msg_id
|
||||
for child in children:
|
||||
if child["can_expand"]:
|
||||
print("EXPANDING CHILD", child)
|
||||
await client.send_json(
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "media_player/browse_media",
|
||||
"entity_id": TEST_MASTER_ENTITY_NAME,
|
||||
"media_content_type": child["media_content_type"],
|
||||
"media_content_id": child["media_content_id"],
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
msg_id += 1
|
||||
await browse_children(msg["result"]["children"])
|
||||
|
||||
await browse_children(msg["result"]["children"])
|
||||
|
||||
|
||||
async def test_async_browse_media_not_found(hass, hass_ws_client, config_entry):
|
||||
"""Test browse media not found."""
|
||||
|
||||
assert await async_setup_component(hass, media_source.DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI",
|
||||
autospec=True,
|
||||
) as mock_api:
|
||||
config_entry.add_to_hass(hass)
|
||||
await config_entry.async_setup(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_api.return_value.get_directory.return_value = None
|
||||
mock_api.return_value.get_albums.return_value = None
|
||||
mock_api.return_value.get_artists.return_value = None
|
||||
mock_api.return_value.get_genres.return_value = None
|
||||
mock_api.return_value.get_playlists.return_value = None
|
||||
|
||||
# Request playlist through WebSocket
|
||||
client = await hass_ws_client(hass)
|
||||
msg_id = 1
|
||||
for media_type in (
|
||||
"directory",
|
||||
MediaType.ALBUM,
|
||||
MediaType.ARTIST,
|
||||
MediaType.GENRE,
|
||||
MediaType.PLAYLIST,
|
||||
):
|
||||
await client.send_json(
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "media_player/browse_media",
|
||||
"entity_id": TEST_MASTER_ENTITY_NAME,
|
||||
"media_content_type": media_type,
|
||||
"media_content_id": (
|
||||
media_content_id := create_media_content_id(
|
||||
"title", f"library:{media_type}:"
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
# Assert WebSocket response
|
||||
assert msg["id"] == msg_id
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert not msg["success"]
|
||||
assert (
|
||||
msg["error"]["message"]
|
||||
== f"Media not found for {media_type} / {media_content_id}"
|
||||
)
|
||||
msg_id += 1
|
||||
|
||||
|
||||
async def test_async_browse_image(hass, hass_client, config_entry):
|
||||
"""Test browse media images."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI",
|
||||
autospec=True,
|
||||
) as mock_api:
|
||||
config_entry.add_to_hass(hass)
|
||||
await config_entry.async_setup(hass)
|
||||
await hass.async_block_till_done()
|
||||
client = await hass_client()
|
||||
mock_api.return_value.full_url = lambda x: "http://owntone_instance/" + x
|
||||
mock_api.return_value.get_albums.return_value = [
|
||||
{"id": "8009851123233197743", "artwork_url": "some_album_image"},
|
||||
]
|
||||
mock_api.return_value.get_artists.return_value = [
|
||||
{"id": "3815427709949443149", "artwork_url": "some_artist_image"},
|
||||
]
|
||||
mock_api.return_value.get_track.return_value = {
|
||||
"id": 456,
|
||||
"artwork_url": "some_track_image",
|
||||
}
|
||||
media_content_id = create_media_content_id(
|
||||
"title", media_type=MediaType.ALBUM, id_or_path="8009851123233197743"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.media_player.async_fetch_image"
|
||||
) as mock_fetch_image:
|
||||
for media_type, media_id in (
|
||||
(MediaType.ALBUM, "8009851123233197743"),
|
||||
(MediaType.ARTIST, "3815427709949443149"),
|
||||
(MediaType.TRACK, "456"),
|
||||
):
|
||||
mock_fetch_image.return_value = (b"image_bytes", media_type)
|
||||
media_content_id = create_media_content_id(
|
||||
"title", media_type=media_type, id_or_path=media_id
|
||||
)
|
||||
resp = await client.get(
|
||||
f"/api/media_player_proxy/{TEST_MASTER_ENTITY_NAME}/browse_media/{media_type}/{media_content_id}"
|
||||
)
|
||||
assert (
|
||||
mock_fetch_image.call_args[0][2]
|
||||
== f"http://owntone_instance/some_{media_type}_image"
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert resp.content_type == media_type
|
||||
assert await resp.read() == b"image_bytes"
|
||||
|
||||
|
||||
async def test_async_browse_image_missing(hass, hass_client, config_entry, caplog):
|
||||
"""Test browse media images with no image available."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI",
|
||||
autospec=True,
|
||||
) as mock_api:
|
||||
config_entry.add_to_hass(hass)
|
||||
await config_entry.async_setup(hass)
|
||||
await hass.async_block_till_done()
|
||||
client = await hass_client()
|
||||
mock_api.return_value.full_url = lambda x: "http://owntone_instance/" + x
|
||||
mock_api.return_value.get_track.return_value = {}
|
||||
|
||||
media_content_id = create_media_content_id(
|
||||
"title", media_type=MediaType.TRACK, id_or_path="456"
|
||||
)
|
||||
resp = await client.get(
|
||||
f"/api/media_player_proxy/{TEST_MASTER_ENTITY_NAME}/browse_media/{MediaType.TRACK}/{media_content_id}"
|
||||
)
|
||||
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
@ -4,12 +4,12 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.forked_daapd.browse_media import create_media_content_id
|
||||
from homeassistant.components.forked_daapd.const import (
|
||||
CONF_LIBRESPOT_JAVA_PORT,
|
||||
CONF_MAX_PLAYLISTS,
|
||||
CONF_TTS_PAUSE_TIME,
|
||||
CONF_TTS_VOLUME,
|
||||
DOMAIN,
|
||||
SIGNAL_UPDATE_OUTPUTS,
|
||||
SIGNAL_UPDATE_PLAYER,
|
||||
SIGNAL_UPDATE_QUEUE,
|
||||
@ -54,25 +54,21 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerEnqueue,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_FRIENDLY_NAME,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
STATE_ON,
|
||||
STATE_PAUSED,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry, async_mock_signal
|
||||
from tests.common import async_mock_signal
|
||||
|
||||
TEST_MASTER_ENTITY_NAME = "media_player.forked_daapd_server"
|
||||
TEST_ZONE_ENTITY_NAMES = [
|
||||
"media_player.forked_daapd_output_" + x
|
||||
for x in ["kitchen", "computer", "daapd_fifo"]
|
||||
for x in ("kitchen", "computer", "daapd_fifo")
|
||||
]
|
||||
|
||||
OPTIONS_DATA = {
|
||||
@ -290,25 +286,6 @@ SAMPLE_PIPES = [
|
||||
SAMPLE_PLAYLISTS = [{"id": 7, "name": "test_playlist", "uri": "library:playlist:2"}]
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry")
|
||||
def config_entry_fixture():
|
||||
"""Create hass config_entry fixture."""
|
||||
data = {
|
||||
CONF_HOST: "192.168.1.1",
|
||||
CONF_PORT: "2345",
|
||||
CONF_PASSWORD: "",
|
||||
}
|
||||
return MockConfigEntry(
|
||||
version=1,
|
||||
domain=DOMAIN,
|
||||
title="",
|
||||
data=data,
|
||||
options={CONF_TTS_PAUSE_TIME: 0},
|
||||
source=SOURCE_USER,
|
||||
entry_id=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="get_request_return_values")
|
||||
async def get_request_return_values_fixture():
|
||||
"""Get request return values we can change later."""
|
||||
@ -888,3 +865,29 @@ async def test_async_play_media_enqueue(hass, mock_api_object):
|
||||
mock_api_object.add_to_queue.assert_called_with(
|
||||
uris="http://example.com/next.mp3", playback="start", position=1
|
||||
)
|
||||
|
||||
|
||||
async def test_play_owntone_media(hass, mock_api_object):
|
||||
"""Test async play media with an owntone source."""
|
||||
initial_state = hass.states.get(TEST_MASTER_ENTITY_NAME)
|
||||
await _service_call(
|
||||
hass,
|
||||
TEST_MASTER_ENTITY_NAME,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
{
|
||||
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
|
||||
ATTR_MEDIA_CONTENT_ID: create_media_content_id(
|
||||
"some song", "library:track:456"
|
||||
),
|
||||
ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.PLAY,
|
||||
},
|
||||
)
|
||||
state = hass.states.get(TEST_MASTER_ENTITY_NAME)
|
||||
assert state.state == initial_state.state
|
||||
assert state.last_updated > initial_state.last_updated
|
||||
mock_api_object.add_to_queue.assert_called_with(
|
||||
uris="library:track:456",
|
||||
playback="start",
|
||||
position=0,
|
||||
playback_from_position=0,
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user