mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 12:47:08 +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."""
|
"""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
|
CALLBACK_TIMEOUT = 8 # max time between command and callback from forked-daapd server
|
||||||
CAN_PLAY_TYPE = {
|
CAN_PLAY_TYPE = {
|
||||||
@ -11,6 +11,12 @@ CAN_PLAY_TYPE = {
|
|||||||
"audio/x-ms-wma",
|
"audio/x-ms-wma",
|
||||||
"audio/aiff",
|
"audio/aiff",
|
||||||
"audio/wav",
|
"audio/wav",
|
||||||
|
MediaType.TRACK,
|
||||||
|
MediaType.PLAYLIST,
|
||||||
|
MediaType.ARTIST,
|
||||||
|
MediaType.ALBUM,
|
||||||
|
MediaType.GENRE,
|
||||||
|
MediaType.MUSIC,
|
||||||
}
|
}
|
||||||
CONF_LIBRESPOT_JAVA_PORT = "librespot_java_port"
|
CONF_LIBRESPOT_JAVA_PORT = "librespot_java_port"
|
||||||
CONF_MAX_PLAYLISTS = "max_playlists"
|
CONF_MAX_PLAYLISTS = "max_playlists"
|
||||||
@ -82,3 +88,4 @@ SUPPORTED_FEATURES_ZONE = (
|
|||||||
| MediaPlayerEntityFeature.TURN_OFF
|
| MediaPlayerEntityFeature.TURN_OFF
|
||||||
)
|
)
|
||||||
TTS_TIMEOUT = 20 # max time to wait between TTS getting sent and starting to play
|
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.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util.dt import utcnow
|
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 (
|
from .const import (
|
||||||
CALLBACK_TIMEOUT,
|
CALLBACK_TIMEOUT,
|
||||||
CAN_PLAY_TYPE,
|
CAN_PLAY_TYPE,
|
||||||
@ -238,7 +244,8 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
self, clientsession, api, ip_address, api_port, api_password, config_entry
|
self, clientsession, api, ip_address, api_port, api_password, config_entry
|
||||||
):
|
):
|
||||||
"""Initialize the ForkedDaapd Master Device."""
|
"""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[
|
self._player = STARTUP_DATA[
|
||||||
"player"
|
"player"
|
||||||
] # _player, _outputs, and _queue are loaded straight from api
|
] # _player, _outputs, and _queue are loaded straight from api
|
||||||
@ -416,13 +423,13 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
async def async_turn_on(self) -> None:
|
async def async_turn_on(self) -> None:
|
||||||
"""Restore the last on outputs state."""
|
"""Restore the last on outputs state."""
|
||||||
# restore 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:
|
if self._last_outputs:
|
||||||
futures: list[asyncio.Task[int]] = []
|
futures: list[asyncio.Task[int]] = []
|
||||||
for output in self._last_outputs:
|
for output in self._last_outputs:
|
||||||
futures.append(
|
futures.append(
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
self._api.change_output(
|
self.api.change_output(
|
||||||
output["id"],
|
output["id"],
|
||||||
selected=output["selected"],
|
selected=output["selected"],
|
||||||
volume=output["volume"],
|
volume=output["volume"],
|
||||||
@ -431,7 +438,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
)
|
)
|
||||||
await asyncio.wait(futures)
|
await asyncio.wait(futures)
|
||||||
else: # enable all outputs
|
else: # enable all outputs
|
||||||
await self._api.set_enabled_outputs(
|
await self.api.set_enabled_outputs(
|
||||||
[output["id"] for output in self._outputs]
|
[output["id"] for output in self._outputs]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -440,7 +447,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
await self.async_media_pause()
|
await self.async_media_pause()
|
||||||
self._last_outputs = self._outputs
|
self._last_outputs = self._outputs
|
||||||
if any(output["selected"] for output in 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:
|
async def async_toggle(self) -> None:
|
||||||
"""Toggle the power on the device.
|
"""Toggle the power on the device.
|
||||||
@ -568,32 +575,32 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
target_volume = 0
|
target_volume = 0
|
||||||
else:
|
else:
|
||||||
target_volume = self._last_volume # restore volume level
|
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:
|
async def async_set_volume_level(self, volume: float) -> None:
|
||||||
"""Set volume - input range [0,1]."""
|
"""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:
|
async def async_media_play(self) -> None:
|
||||||
"""Start playback."""
|
"""Start playback."""
|
||||||
if self._use_pipe_control():
|
if self._use_pipe_control():
|
||||||
await self._pipe_call(self._use_pipe_control(), "async_media_play")
|
await self._pipe_call(self._use_pipe_control(), "async_media_play")
|
||||||
else:
|
else:
|
||||||
await self._api.start_playback()
|
await self.api.start_playback()
|
||||||
|
|
||||||
async def async_media_pause(self) -> None:
|
async def async_media_pause(self) -> None:
|
||||||
"""Pause playback."""
|
"""Pause playback."""
|
||||||
if self._use_pipe_control():
|
if self._use_pipe_control():
|
||||||
await self._pipe_call(self._use_pipe_control(), "async_media_pause")
|
await self._pipe_call(self._use_pipe_control(), "async_media_pause")
|
||||||
else:
|
else:
|
||||||
await self._api.pause_playback()
|
await self.api.pause_playback()
|
||||||
|
|
||||||
async def async_media_stop(self) -> None:
|
async def async_media_stop(self) -> None:
|
||||||
"""Stop playback."""
|
"""Stop playback."""
|
||||||
if self._use_pipe_control():
|
if self._use_pipe_control():
|
||||||
await self._pipe_call(self._use_pipe_control(), "async_media_stop")
|
await self._pipe_call(self._use_pipe_control(), "async_media_stop")
|
||||||
else:
|
else:
|
||||||
await self._api.stop_playback()
|
await self.api.stop_playback()
|
||||||
|
|
||||||
async def async_media_previous_track(self) -> None:
|
async def async_media_previous_track(self) -> None:
|
||||||
"""Skip to previous track."""
|
"""Skip to previous track."""
|
||||||
@ -602,32 +609,32 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
self._use_pipe_control(), "async_media_previous_track"
|
self._use_pipe_control(), "async_media_previous_track"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self._api.previous_track()
|
await self.api.previous_track()
|
||||||
|
|
||||||
async def async_media_next_track(self) -> None:
|
async def async_media_next_track(self) -> None:
|
||||||
"""Skip to next track."""
|
"""Skip to next track."""
|
||||||
if self._use_pipe_control():
|
if self._use_pipe_control():
|
||||||
await self._pipe_call(self._use_pipe_control(), "async_media_next_track")
|
await self._pipe_call(self._use_pipe_control(), "async_media_next_track")
|
||||||
else:
|
else:
|
||||||
await self._api.next_track()
|
await self.api.next_track()
|
||||||
|
|
||||||
async def async_media_seek(self, position: float) -> None:
|
async def async_media_seek(self, position: float) -> None:
|
||||||
"""Seek to position."""
|
"""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:
|
async def async_clear_playlist(self) -> None:
|
||||||
"""Clear playlist."""
|
"""Clear playlist."""
|
||||||
await self._api.clear_queue()
|
await self.api.clear_queue()
|
||||||
|
|
||||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||||
"""Enable/disable shuffle mode."""
|
"""Enable/disable shuffle mode."""
|
||||||
await self._api.shuffle(shuffle)
|
await self.api.shuffle(shuffle)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_image_url(self):
|
def media_image_url(self):
|
||||||
"""Image url of current playing media."""
|
"""Image url of current playing media."""
|
||||||
if url := self._track_info.get("artwork_url"):
|
if url := self._track_info.get("artwork_url"):
|
||||||
url = self._api.full_url(url)
|
url = self.api.full_url(url)
|
||||||
return url
|
return url
|
||||||
|
|
||||||
async def _save_and_set_tts_volumes(self):
|
async def _save_and_set_tts_volumes(self):
|
||||||
@ -635,11 +642,11 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
self._last_volume = self.volume_level
|
self._last_volume = self.volume_level
|
||||||
self._last_outputs = self._outputs
|
self._last_outputs = self._outputs
|
||||||
if 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 = []
|
futures = []
|
||||||
for output in self._outputs:
|
for output in self._outputs:
|
||||||
futures.append(
|
futures.append(
|
||||||
self._api.change_output(
|
self.api.change_output(
|
||||||
output["id"], selected=True, volume=self._tts_volume * 100
|
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
|
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Play a URI."""
|
"""Play a URI."""
|
||||||
|
|
||||||
|
# Preprocess media_ids
|
||||||
if media_source.is_media_source_id(media_id):
|
if media_source.is_media_source_id(media_id):
|
||||||
media_type = MediaType.MUSIC
|
media_type = MediaType.MUSIC
|
||||||
play_item = await media_source.async_resolve_media(
|
play_item = await media_source.async_resolve_media(
|
||||||
self.hass, media_id, self.entity_id
|
self.hass, media_id, self.entity_id
|
||||||
)
|
)
|
||||||
media_id = play_item.url
|
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:
|
if media_type == MediaType.MUSIC:
|
||||||
media_id = async_process_play_media_url(self.hass, media_id)
|
media_id = async_process_play_media_url(self.hass, media_id)
|
||||||
@ -684,7 +699,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE
|
ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE
|
||||||
)
|
)
|
||||||
if enqueue in {True, MediaPlayerEnqueue.ADD, 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,
|
uris=media_id,
|
||||||
playback="start",
|
playback="start",
|
||||||
clear=enqueue == MediaPlayerEnqueue.REPLACE,
|
clear=enqueue == MediaPlayerEnqueue.REPLACE,
|
||||||
@ -699,13 +714,13 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if enqueue == MediaPlayerEnqueue.NEXT:
|
if enqueue == MediaPlayerEnqueue.NEXT:
|
||||||
return await self._api.add_to_queue(
|
return await self.api.add_to_queue(
|
||||||
uris=media_id,
|
uris=media_id,
|
||||||
playback="start",
|
playback="start",
|
||||||
position=current_position + 1,
|
position=current_position + 1,
|
||||||
)
|
)
|
||||||
# enqueue == MediaPlayerEnqueue.PLAY
|
# enqueue == MediaPlayerEnqueue.PLAY
|
||||||
return await self._api.add_to_queue(
|
return await self.api.add_to_queue(
|
||||||
uris=media_id,
|
uris=media_id,
|
||||||
playback="start",
|
playback="start",
|
||||||
position=current_position,
|
position=current_position,
|
||||||
@ -732,7 +747,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
)
|
)
|
||||||
self._tts_requested = True
|
self._tts_requested = True
|
||||||
await sleep_future
|
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:
|
try:
|
||||||
async with async_timeout.timeout(TTS_TIMEOUT):
|
async with async_timeout.timeout(TTS_TIMEOUT):
|
||||||
await self._tts_playing_event.wait()
|
await self._tts_playing_event.wait()
|
||||||
@ -751,7 +766,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
if saved_mute: # mute if we were muted
|
if saved_mute: # mute if we were muted
|
||||||
await self.async_mute_volume(True)
|
await self.async_mute_volume(True)
|
||||||
if self._use_pipe_control(): # resume pipe
|
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
|
uris=self._sources_uris[self._source], clear=True
|
||||||
)
|
)
|
||||||
if saved_state == MediaPlayerState.PLAYING:
|
if saved_state == MediaPlayerState.PLAYING:
|
||||||
@ -760,13 +775,13 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
if not saved_queue:
|
if not saved_queue:
|
||||||
return
|
return
|
||||||
# Restore stashed queue
|
# 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"]),
|
uris=",".join(item["uri"] for item in saved_queue["items"]),
|
||||||
playback="start",
|
playback="start",
|
||||||
playback_from_position=saved_queue_position,
|
playback_from_position=saved_queue_position,
|
||||||
clear=True,
|
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:
|
if saved_state == MediaPlayerState.PAUSED:
|
||||||
await self.async_media_pause()
|
await self.async_media_pause()
|
||||||
return
|
return
|
||||||
@ -788,9 +803,9 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
if not self._use_pipe_control(): # playlist or clear ends up at default
|
if not self._use_pipe_control(): # playlist or clear ends up at default
|
||||||
self._source = SOURCE_NAME_DEFAULT
|
self._source = SOURCE_NAME_DEFAULT
|
||||||
if self._sources_uris.get(source): # load uris for pipes or playlists
|
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
|
elif source == SOURCE_NAME_CLEAR: # clear playlist
|
||||||
await self._api.clear_queue()
|
await self.api.clear_queue()
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
def _use_pipe_control(self):
|
def _use_pipe_control(self):
|
||||||
@ -813,11 +828,50 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
|||||||
media_content_id: str | None = None,
|
media_content_id: str | None = None,
|
||||||
) -> BrowseMedia:
|
) -> BrowseMedia:
|
||||||
"""Implement the websocket media browsing helper."""
|
"""Implement the websocket media browsing helper."""
|
||||||
return await media_source.async_browse_media(
|
if media_content_id is None or media_source.is_media_source_id(
|
||||||
self.hass,
|
media_content_id
|
||||||
media_content_id,
|
):
|
||||||
content_filter=lambda bm: bm.media_content_type in CAN_PLAY_TYPE,
|
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:
|
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
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.forked_daapd.browse_media import create_media_content_id
|
||||||
from homeassistant.components.forked_daapd.const import (
|
from homeassistant.components.forked_daapd.const import (
|
||||||
CONF_LIBRESPOT_JAVA_PORT,
|
CONF_LIBRESPOT_JAVA_PORT,
|
||||||
CONF_MAX_PLAYLISTS,
|
CONF_MAX_PLAYLISTS,
|
||||||
CONF_TTS_PAUSE_TIME,
|
CONF_TTS_PAUSE_TIME,
|
||||||
CONF_TTS_VOLUME,
|
CONF_TTS_VOLUME,
|
||||||
DOMAIN,
|
|
||||||
SIGNAL_UPDATE_OUTPUTS,
|
SIGNAL_UPDATE_OUTPUTS,
|
||||||
SIGNAL_UPDATE_PLAYER,
|
SIGNAL_UPDATE_PLAYER,
|
||||||
SIGNAL_UPDATE_QUEUE,
|
SIGNAL_UPDATE_QUEUE,
|
||||||
@ -54,25 +54,21 @@ from homeassistant.components.media_player import (
|
|||||||
MediaPlayerEnqueue,
|
MediaPlayerEnqueue,
|
||||||
MediaType,
|
MediaType,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import SOURCE_USER
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID,
|
ATTR_ENTITY_ID,
|
||||||
ATTR_FRIENDLY_NAME,
|
ATTR_FRIENDLY_NAME,
|
||||||
ATTR_SUPPORTED_FEATURES,
|
ATTR_SUPPORTED_FEATURES,
|
||||||
CONF_HOST,
|
|
||||||
CONF_PASSWORD,
|
|
||||||
CONF_PORT,
|
|
||||||
STATE_ON,
|
STATE_ON,
|
||||||
STATE_PAUSED,
|
STATE_PAUSED,
|
||||||
STATE_UNAVAILABLE,
|
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_MASTER_ENTITY_NAME = "media_player.forked_daapd_server"
|
||||||
TEST_ZONE_ENTITY_NAMES = [
|
TEST_ZONE_ENTITY_NAMES = [
|
||||||
"media_player.forked_daapd_output_" + x
|
"media_player.forked_daapd_output_" + x
|
||||||
for x in ["kitchen", "computer", "daapd_fifo"]
|
for x in ("kitchen", "computer", "daapd_fifo")
|
||||||
]
|
]
|
||||||
|
|
||||||
OPTIONS_DATA = {
|
OPTIONS_DATA = {
|
||||||
@ -290,25 +286,6 @@ SAMPLE_PIPES = [
|
|||||||
SAMPLE_PLAYLISTS = [{"id": 7, "name": "test_playlist", "uri": "library:playlist:2"}]
|
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")
|
@pytest.fixture(name="get_request_return_values")
|
||||||
async def get_request_return_values_fixture():
|
async def get_request_return_values_fixture():
|
||||||
"""Get request return values we can change later."""
|
"""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(
|
mock_api_object.add_to_queue.assert_called_with(
|
||||||
uris="http://example.com/next.mp3", playback="start", position=1
|
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