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:
uvjustin 2022-09-26 17:40:23 -07:00 committed by GitHub
parent 1e3a3d32ad
commit 499c3410d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 776 additions and 59 deletions

View 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,
)

View File

@ -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"

View File

@ -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:

View 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,
)

View 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

View File

@ -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,
)