sql_query = """SELECT item_id FROM provider_mappings
WHERE prov_item_id = ? AND provider = ? AND media_type = ?;"""
async with db_conn.execute(
- sql_query, (prov_item_id, provider_id, media_type)
+ sql_query, (prov_item_id, provider_id, int(media_type))
) as cursor:
item_id = await cursor.fetchone()
if item_id:
db_row["playlist_id"], MediaType.Playlist, db_conn
),
tags=await self.__async_get_tags(
- db_row["playlist_id"], MediaType.Playlist, db_conn
+ db_row["playlist_id"], int(MediaType.Playlist), db_conn
),
external_ids=await self.__async_get_external_ids(
db_row["playlist_id"], MediaType.Playlist, db_conn
item_id = try_parse_int(item_id)
sql_query = """INSERT or REPLACE INTO library_items
(item_id, provider, media_type) VALUES(?,?,?);"""
- await db_conn.execute(sql_query, (item_id, provider, media_type))
+ await db_conn.execute(sql_query, (item_id, provider, int(media_type)))
await db_conn.commit()
async def async_remove_from_library(
async with DbConnect(self._dbfile) as db_conn:
item_id = try_parse_int(item_id)
sql_query = "DELETE FROM library_items WHERE item_id=? AND provider=? AND media_type=?;"
- await db_conn.execute(sql_query, (item_id, provider, media_type))
+ await db_conn.execute(sql_query, (item_id, provider, int(media_type)))
if media_type == MediaType.Playlist:
sql_query = "DELETE FROM playlists WHERE playlist_id=?;"
await db_conn.execute(sql_query, (item_id,))
sql_query = """DELETE FROM provider_mappings WHERE
item_id=? AND media_type=? AND provider=?;"""
- await db_conn.execute(sql_query, (item_id, media_type, provider))
+ await db_conn.execute(sql_query, (item_id, int(media_type), provider))
await db_conn.commit()
async def async_get_artists(
album.artist.item_id,
album.name,
album.version,
- album.year,
- album.album_type,
+ int(album.year),
+ int(album.album_type),
),
) as cursor:
res = await cursor.fetchone()
query_params = (
album.artist.item_id,
album.name,
- album.album_type,
+ int(album.album_type),
album.year,
album.version,
)
if value:
sql_query = """INSERT or REPLACE INTO metadata
(item_id, media_type, key, value) VALUES(?,?,?,?);"""
- await db_conn.execute(sql_query, (item_id, media_type, key, value))
+ await db_conn.execute(sql_query, (item_id, int(media_type), key, value))
async def __async_get_metadata(
self,
)
if filter_key:
sql_query += ' AND key = "%s"' % filter_key
- async with db_conn.execute(sql_query, (item_id, media_type)) as cursor:
+ async with db_conn.execute(sql_query, (item_id, int(media_type))) as cursor:
db_rows = await cursor.fetchall()
for db_row in db_rows:
key = db_row[0]
tag_id = cursor.lastrowid
sql_query = """INSERT or IGNORE INTO media_tags
(item_id, media_type, tag_id) VALUES(?,?,?);"""
- await db_conn.execute(sql_query, (item_id, media_type, tag_id))
+ await db_conn.execute(sql_query, (item_id, int(media_type), tag_id))
async def __async_get_tags(
self, item_id: int, media_type: MediaType, db_conn: sqlite3.Connection
tags = []
sql_query = """SELECT name FROM tags INNER JOIN media_tags ON
tags.tag_id = media_tags.tag_id WHERE item_id = ? AND media_type = ?"""
- async with db_conn.execute(sql_query, (item_id, media_type)) as cursor:
+ async with db_conn.execute(sql_query, (item_id, int(media_type))) as cursor:
db_rows = await cursor.fetchall()
for db_row in db_rows:
tags.append(db_row[0])
for key, value in external_ids.items():
sql_query = """INSERT or REPLACE INTO external_ids
(item_id, media_type, key, value) VALUES(?,?,?,?);"""
- await db_conn.execute(sql_query, (item_id, media_type, key, value))
+ await db_conn.execute(sql_query, (item_id, int(media_type), key, value))
async def __async_get_external_ids(
self, item_id: int, media_type: MediaType, db_conn: sqlite3.Connection
sql_query = (
"SELECT key, value FROM external_ids WHERE item_id = ? AND media_type = ?"
)
- for db_row in await db_conn.execute_fetchall(sql_query, (item_id, media_type)):
+ for db_row in await db_conn.execute_fetchall(
+ sql_query, (item_id, int(media_type))
+ ):
external_ids[db_row[0]] = db_row[1]
return external_ids
sql_query,
(
item_id,
- media_type,
+ int(media_type),
prov.item_id,
prov.provider,
- prov.quality,
+ int(prov.quality),
prov.details,
),
)
sql_query = "SELECT prov_item_id, provider, quality, details \
FROM provider_mappings \
WHERE item_id = ? AND media_type = ?"
- for db_row in await db_conn.execute_fetchall(sql_query, (item_id, media_type)):
+ for db_row in await db_conn.execute_fetchall(
+ sql_query, (item_id, int(media_type))
+ ):
prov_mapping = MediaItemProviderId(
provider=db_row["provider"],
item_id=db_row["prov_item_id"],
"SELECT provider FROM library_items WHERE item_id = ? AND media_type = ?"
)
for db_row in await db_conn.execute_fetchall(
- sql_query, (db_item_id, media_type)
+ sql_query, (db_item_id, int(media_type))
):
providers.append(db_row[0])
return providers
sql_query = "SELECT (item_id) FROM external_ids \
WHERE media_type=? AND key=? AND value=?;"
for db_row in await db_conn.execute_fetchall(
- sql_query, (media_item.media_type, key, value)
+ sql_query, (int(media_item.media_type), key, value)
):
if db_row:
return db_row[0]
from aiohttp import web
from music_assistant.constants import EVENT_STREAM_ENDED, EVENT_STREAM_STARTED
from music_assistant.models.media_types import MediaType
+from music_assistant.models.player_queue import QueueItem
from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
from music_assistant.utils import create_tempfile, get_ip, try_parse_int
from music_assistant.web import require_local_subnet
self.analyze_jobs = {}
self.stream_clients = []
+ async def async_stream_media_item(self, http_request):
+ """Start stream for a single media item, player independent."""
+ # make sure we have valid params
+ media_type = MediaType.from_string(http_request.match_info["media_type"])
+ if media_type not in [MediaType.Track, MediaType.Radio]:
+ return web.Response(status=404, reason="Media item is not playable!")
+ provider = http_request.match_info["provider"]
+ item_id = http_request.match_info["item_id"]
+ player_id = http_request.remote # fake player id
+ # prepare headers as audio/flac content
+ resp = web.StreamResponse(
+ status=200, reason="OK", headers={"Content-Type": "audio/flac"}
+ )
+ await resp.prepare(http_request)
+ # collect tracks to play
+ media_item = await self.mass.music_manager.async_get_item(
+ item_id, provider, media_type
+ )
+ queue_item = QueueItem(media_item)
+ # run the streamer in executor to prevent the subprocess locking up our eventloop
+ cancelled = threading.Event()
+ bg_task = self.mass.loop.run_in_executor(
+ None,
+ self.__get_queue_item_stream,
+ player_id,
+ queue_item,
+ resp,
+ cancelled,
+ )
+ # let the streaming begin!
+ try:
+ await asyncio.gather(bg_task)
+ except (asyncio.CancelledError, asyncio.TimeoutError) as exc:
+ cancelled.set()
+ raise exc # re-raise
+ return resp
+
@require_local_subnet
async def async_stream(self, http_request):
"""Start stream for a player."""
):
"""Get audio stream from provider and apply additional effects/processing if needed."""
# pylint: disable=subprocess-popen-preexec-fn
- player_queue = self.mass.player_manager.get_player_queue(player_id)
streamdetails = self.mass.add_job(
- player_queue.async_get_stream_details(player_id, queue_item)
+ self.mass.music_manager.async_get_stream_details(queue_item, player_id)
).result()
if not streamdetails:
LOGGER.warning("no stream details for %s", queue_item.name)
from enum import Enum
from typing import Any, List, Optional, Tuple
+from mashumaro import DataClassDictMixin
-class ConfigEntryType(str, Enum):
+
+class ConfigEntryType(Enum):
"""Enum for the type of a config entry."""
BOOL = "boolean"
@dataclass
-class ConfigEntry:
+class ConfigEntry(DataClassDictMixin):
"""Model for a Config Entry."""
entry_key: str
from dataclasses import dataclass, field
from enum import Enum
-from typing import List, Optional
+from typing import Any, List, Optional
+from mashumaro import DataClassDictMixin
+from music_assistant.utils import CustomIntEnum
-class MediaType(int, Enum):
+
+class MediaType(CustomIntEnum):
"""Enum for MediaType."""
Artist = 1
Radio = 5
-def media_type_from_string(media_type_str: str) -> MediaType:
- """Convert a string to a MediaType."""
- media_type_str = media_type_str.lower()
- if "artist" in media_type_str or media_type_str == "1":
- return MediaType.Artist
- if "album" in media_type_str or media_type_str == "2":
- return MediaType.Album
- if "track" in media_type_str or media_type_str == "3":
- return MediaType.Track
- if "playlist" in media_type_str or media_type_str == "4":
- return MediaType.Playlist
- if "radio" in media_type_str or media_type_str == "5":
- return MediaType.Radio
- return None
-
-
-class ContributorRole(int, Enum):
+class ContributorRole(CustomIntEnum):
"""Enum for Contributor Role."""
Artist = 1
Producer = 3
-class AlbumType(int, Enum):
+class AlbumType(CustomIntEnum):
"""Enum for Album type."""
Album = 1
Compilation = 3
-class TrackQuality(int, Enum):
+class TrackQuality(CustomIntEnum):
"""Enum for Track Quality."""
LOSSY_MP3 = 0
@dataclass
-class MediaItemProviderId:
+class MediaItemProviderId(DataClassDictMixin):
"""Model for a MediaItem's provider id."""
provider: str
details: Optional[str] = None
-class ExternalId(str, Enum):
+class ExternalId(Enum):
"""Enum with external id's."""
MUSICBRAINZ = "musicbrainz"
@dataclass
-class MediaItem:
+class MediaItem(DataClassDictMixin):
"""Representation of a media item."""
item_id: str = ""
provider: str = ""
name: str = ""
- metadata: dict = field(default_factory=dict)
+ metadata: Any = field(default_factory=dict)
tags: List[str] = field(default_factory=list)
- external_ids: dict = field(default_factory=dict)
+ external_ids: Any = field(default_factory=dict)
provider_ids: List[MediaItemProviderId] = field(default_factory=list)
in_library: List[str] = field(default_factory=list)
is_lazy: bool = False
media_type: MediaType = MediaType.Playlist
owner: str = ""
- checksum: [Optional[str]] = None # some value to detect playlist track changes
+ checksum: str = "" # some value to detect playlist track changes
is_editable: bool = False
@dataclass
-class SearchResult:
+class SearchResult(DataClassDictMixin):
"""Model for Media Item Search result."""
artists: List[Artist] = field(default_factory=list)
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
-from typing import Any, Awaitable, Callable, List, Optional, Union
+from typing import Any, List, Optional
+from mashumaro import DataClassDictMixin
from music_assistant.models.config_entry import ConfigEntry
+from music_assistant.utils import CustomIntEnum
-class PlayerState(str, Enum):
+class PlayerState(Enum):
"""Enum for the playstate of a player."""
Off = "off"
@dataclass
-class DeviceInfo:
+class DeviceInfo(DataClassDictMixin):
"""Model for a player's deviceinfo."""
model: Optional[str] = ""
manufacturer: Optional[str] = ""
-class PlayerFeature(int, Enum):
+class PlayerFeature(CustomIntEnum):
"""Enum for player features."""
QUEUE = 0
@dataclass
-class Player:
+class Player(DataClassDictMixin):
"""Model for a MusicPlayer."""
player_id: str
muted: bool = False
is_group_player: bool = False
group_childs: List[str] = field(default_factory=list)
- device_info: Optional[DeviceInfo] = None
+ device_info: DeviceInfo = None
should_poll: bool = False
features: List[PlayerFeature] = field(default_factory=list)
config_entries: List[ConfigEntry] = field(default_factory=list)
self._on_update(self.player_id, name)
-class PlayerControlType(int, Enum):
+class PlayerControlType(CustomIntEnum):
"""Enum with different player control types."""
POWER = 0
@dataclass
-class PlayerControl:
+class PlayerControl(DataClassDictMixin):
"""
Model for a player control.
id: str = ""
name: str = ""
state: Optional[Any] = None
- set_state: Callable[..., Union[None, Awaitable]] = None
+ set_state: Any = None
EVENT_QUEUE_ITEMS_UPDATED,
EVENT_QUEUE_UPDATED,
)
-from music_assistant.models.media_types import MediaType, Track
+from music_assistant.models.media_types import Track
from music_assistant.models.player import PlayerFeature, PlayerState
from music_assistant.models.streamdetails import StreamDetails
from music_assistant.utils import callback
LOGGER = logging.getLogger("mass")
-class QueueOption(str, Enum):
+class QueueOption(Enum):
"""Enum representation of the queue (play) options."""
Play = "play"
self._last_queue_startindex = self._next_queue_startindex
return self.get_item(self._next_queue_startindex)
- async def async_get_stream_details(
- self, player_id: str, queue_item: QueueItem
- ) -> StreamDetails:
- """
- Get streamdetails for the given queue_item.
-
- This is called just-in-time when a player/queue wants a QueueItem to be played.
- Do not try to request streamdetails in advance as this is expiring data.
- param player_id: The id of the player that will be playing the stream.
- param queue_item: The QueueItem for which to request the streamdetails for.
- """
- # always request the full db track as there might be other qualities available
- # except for radio
- if queue_item.media_type == MediaType.Radio:
- full_track = queue_item
- else:
- full_track = await self.mass.music_manager.async_get_track(
- queue_item.item_id, queue_item.provider, lazy=True, refresh=True
- )
- # sort by quality and check track availability
- for prov_media in sorted(
- full_track.provider_ids, key=lambda x: x.quality, reverse=True
- ):
- # get streamdetails from provider
- music_prov = self.mass.get_provider(prov_media.provider)
- if not music_prov:
- continue # provider temporary unavailable ?
-
- streamdetails: StreamDetails = await music_prov.async_get_stream_details(
- prov_media.item_id
- )
-
- if streamdetails:
- streamdetails.player_id = player_id
- # set streamdetails as attribute on the queue_item
- queue_item.streamdetails = streamdetails
- return streamdetails
- return None
-
def to_dict(self):
"""Instance attributes as dict so it can be serialized to json."""
return {
from music_assistant.mass import MusicAssistant
-class ProviderType(str, Enum):
+class ProviderType(Enum):
"""Enum with plugin types."""
MUSIC_PROVIDER = "music_provider"
from enum import Enum
from typing import Any, Optional
+from mashumaro import DataClassDictMixin
-class StreamType(str, Enum):
+
+class StreamType(Enum):
"""Enum with stream types."""
EXECUTABLE = "executable"
FILE = "file"
-class ContentType(str, Enum):
+class ContentType(Enum):
"""Enum with stream content types."""
OGG = "ogg"
@dataclass
-class StreamDetails:
+class StreamDetails(DataClassDictMixin):
"""Model for streamdetails."""
type: StreamType
)
from music_assistant.models.musicprovider import MusicProvider
from music_assistant.models.provider import ProviderType
+from music_assistant.models.streamdetails import StreamDetails
from music_assistant.utils import compare_strings, run_periodic
from PIL import Image
# return file from cache
return cache_file_sized
+ async def async_get_stream_details(
+ self, media_item: MediaItem, player_id: str = ""
+ ) -> StreamDetails:
+ """
+ Get streamdetails for the given media_item.
+
+ This is called just-in-time when a player/queue wants a MediaItem to be played.
+ Do not try to request streamdetails in advance as this is expiring data.
+ param media_item: The MediaItem (track/radio) for which to request the streamdetails for.
+ param player_id: Optionally provide the player_id which will play this stream.
+ """
+ if media_item.streamdetails:
+ media_item.streamdetails.player_id = player_id
+ return media_item.streamdetails # already present, no need to fetch again!
+ # always request the full db track as there might be other qualities available
+ # except for radio
+ if media_item.media_type == MediaType.Radio:
+ full_track = media_item
+ else:
+ full_track = await self.async_get_track(
+ media_item.item_id, media_item.provider, lazy=True, refresh=True
+ )
+ # sort by quality and check track availability
+ for prov_media in sorted(
+ full_track.provider_ids, key=lambda x: x.quality, reverse=True
+ ):
+ # get streamdetails from provider
+ music_prov = self.mass.get_provider(prov_media.provider)
+ if not music_prov:
+ continue # provider temporary unavailable ?
+
+ streamdetails = await music_prov.async_get_stream_details(
+ prov_media.item_id
+ )
+
+ if streamdetails:
+ streamdetails.player_id = player_id
+ # set streamdetails as attribute on the media_item
+ media_item.streamdetails = streamdetails
+ return streamdetails
+ return None
+
################ Library synchronization logic ################
@run_periodic(3600 * 3)
async def async_get_stream_details(self, item_id: str) -> StreamDetails:
"""Return the content details for the given track when it will be streamed."""
- streamdetails = None
+ streamdata = None
for format_id in [27, 7, 6, 5]:
# it seems that simply requesting for highest available quality does not work
# from time to time the api response is empty for this request ?!
params = {"format_id": format_id, "track_id": item_id, "intent": "stream"}
- streamdetails = await self.__async_get_data(
+ streamdata = await self.__async_get_data(
"track/getFileUrl", params, sign_request=True
)
- if streamdetails and streamdetails.get("url"):
+ if streamdata and streamdata.get("url"):
break
- if not streamdetails or not streamdetails.get("url"):
+ if not streamdata or not streamdata.get("url"):
LOGGER.error("Unable to retrieve stream url for track %s", item_id)
return None
return StreamDetails(
type=StreamType.URL,
item_id=str(item_id),
provider=PROV_ID,
- path=streamdetails["url"],
- content_type=ContentType(streamdetails["mime_type"].split("/")[1]),
- sample_rate=int(streamdetails["sampling_rate"] * 1000),
- bit_depth=streamdetails["bit_depth"],
- details=streamdetails, # we need these details for reporting playback
+ path=streamdata["url"],
+ content_type=ContentType(streamdata["mime_type"].split("/")[1]),
+ sample_rate=int(streamdata["sampling_rate"] * 1000),
+ bit_depth=streamdata["bit_depth"],
+ details=streamdata, # we need these details for reporting playback
)
async def async_mass_event(self, msg, msg_details):
"""Helper and utility functions."""
import asyncio
-import dataclasses
import functools
import logging
import os
def default(self, obj):
"""Return default handler."""
# pylint: disable=method-hidden
- if dataclasses.is_dataclass(obj):
- return dataclasses.asdict(obj)
- if isinstance(obj, Enum):
- return str(obj)
- if isinstance(obj, Enum):
- return int(obj)
+ try:
+ # as most of our objects are dataclass, we just try this first
+ return obj.to_dict()
+ except AttributeError:
+ pass
if isinstance(obj, datetime):
return obj.isoformat()
- if hasattr(obj, "to_dict"):
- return obj.to_dict()
- if hasattr(obj, "items"):
- return obj.items()
+ if isinstance(obj, Enum):
+ return str(obj)
return super().default(obj)
buffering=0
)
return tempfile.NamedTemporaryFile(buffering=0)
+
+
+class CustomIntEnum(Enum):
+ """Base for IntEnum with some helpers."""
+
+ # when serializing we prefer the string (name) representation
+ # internally (database) we use the int value
+
+ def __int__(self):
+ """Return integer value."""
+ return super().value
+
+ def __str__(self):
+ """Return string value."""
+ # pylint: disable=no-member
+ return self._name_.lower()
+
+ @property
+ def value(self):
+ """Return the (json friendly) string name."""
+ return self.__str__()
+
+ @classmethod
+ def from_string(cls, string):
+ """Create IntEnum from it's string equivalent."""
+ for key, value in cls.__dict__.items():
+ if key.lower() == string or value == try_parse_int(string):
+ return value
+ return KeyError
CONF_KEY_PLAYERSETTINGS,
CONF_KEY_PROVIDERS,
)
-from music_assistant.models.media_types import MediaType, media_type_from_string
-from music_assistant.utils import (
- EnhancedJSONEncoder,
- get_external_ip,
- get_hostname,
- get_ip,
- json_serializer,
-)
+from music_assistant.models.media_types import MediaType
+from music_assistant.models.player_queue import QueueOption
+from music_assistant.utils import get_external_ip, get_hostname, get_ip, json_serializer
LOGGER = logging.getLogger("mass")
self.mass.http_streamer.async_stream,
allow_head=False,
),
+ web.get(
+ "/stream_media/{media_type}/{provider}/{item_id}",
+ self.mass.http_streamer.async_stream_media_item,
+ allow_head=False,
+ ),
web.get("/", self.async_index),
+ web.post("/login", self.async_login),
web.get("/jsonrpc.js", self.async_json_rpc),
web.post("/jsonrpc.js", self.async_json_rpc),
web.get("/ws", self.async_websocket_handler),
"version": 1,
}
- @routes.post("/login")
+ @routes.post("/api/login")
async def async_login(self, request):
"""Handle the retrieval of a JWT token."""
form = await request.json()
async def async_get_image(self, request):
"""Get (resized) thumb image."""
media_type_str = request.match_info.get("media_type")
- media_type = media_type_from_string(media_type_str)
+ media_type = MediaType.from_string(media_type_str)
media_id = request.match_info.get("media_id")
provider = request.rel_url.query.get("provider")
if media_id is None or provider is None:
@routes.post("/api/players/{player_id}/cmd/{cmd}")
async def async_player_command(self, request):
"""Issue player command."""
- result = False
+ success = False
player_id = request.match_info.get("player_id")
cmd = request.match_info.get("cmd")
try:
cmd_args = None
player_cmd = getattr(self.mass.player_manager, f"async_cmd_{cmd}", None)
if player_cmd and cmd_args is not None:
- result = await player_cmd(player_id, cmd_args)
+ success = await player_cmd(player_id, cmd_args)
elif player_cmd:
- result = await player_cmd(player_id)
+ success = await player_cmd(player_id)
else:
return web.Response(text="invalid command", status=501)
+ result = {"success": success in [True, None]}
return web.json_response(result, dumps=json_serializer)
@login_required
player = self.mass.player_manager.get_player(player_id)
if not player:
return web.Response(status=404)
- queue_opt = request.match_info.get("queue_opt", "play")
+ queue_opt = QueueOption(request.match_info.get("queue_opt", "play"))
body = await request.json()
media_items = await self.__async_media_items_from_body(body)
- result = await self.mass.player_manager.async_play_media(
+ success = await self.mass.player_manager.async_play_media(
player_id, media_items, queue_opt
)
+ result = {"success": success in [True, None]}
return web.json_response(result, dumps=json_serializer)
@login_required
media_items = []
for item in data:
media_item = await self.mass.music_manager.async_get_item(
- item["item_id"], item["provider"], item["media_type"], lazy=True
+ item["item_id"],
+ item["provider"],
+ MediaType.from_string(item["media_type"]),
+ lazy=True,
)
media_items.append(media_item)
return media_items
async for item in iterator:
# write each item into the items object of the json
if count:
- json_response = "," + json.dumps(item, cls=EnhancedJSONEncoder)
+ json_response = "," + json_serializer(item)
else:
- json_response = json.dumps(item, cls=EnhancedJSONEncoder)
+ json_response = json_serializer(item)
await resp.write(json_response.encode("utf-8"))
count += 1
# write json close tag
passlib==1.7.2
cryptography==3.1
python-vlc==3.0.11115
+mashumaro==1.12