From c180788d462a46e260319742ec317756187497e4 Mon Sep 17 00:00:00 2001 From: marcelveldt Date: Fri, 11 Oct 2019 00:24:14 +0200 Subject: [PATCH] refactoring - wip restrucring of player and queue --- music_assistant/main.py | 4 +- music_assistant/models/__init__.py | 0 music_assistant/models/media_types.py | 140 +++++ .../{models.py => models/musicprovider.py} | 205 +------ music_assistant/models/player.py | 525 ++++++++++++++++++ music_assistant/models/player_queue.py | 160 ++++++ music_assistant/models/playerprovider.py | 51 ++ music_assistant/modules/http_streamer.py | 30 +- .../modules/{music.py => music_manager.py} | 8 +- music_assistant/modules/player.py | 441 --------------- music_assistant/modules/player_manager.py | 89 +++ .../modules/playerproviders/chromecast.py | 322 +++-------- .../modules/playerproviders/lms.py | 4 - .../modules/playerproviders/pylms.py | 70 +-- music_assistant/modules/web.py | 23 +- music_assistant/utils.py | 8 +- 16 files changed, 1144 insertions(+), 936 deletions(-) create mode 100644 music_assistant/models/__init__.py create mode 100755 music_assistant/models/media_types.py rename music_assistant/{models.py => models/musicprovider.py} (78%) create mode 100755 music_assistant/models/player.py create mode 100755 music_assistant/models/player_queue.py create mode 100755 music_assistant/models/playerprovider.py rename music_assistant/modules/{music.py => music_manager.py} (98%) delete mode 100755 music_assistant/modules/player.py create mode 100755 music_assistant/modules/player_manager.py diff --git a/music_assistant/main.py b/music_assistant/main.py index e9e5530c..8b31d17f 100755 --- a/music_assistant/main.py +++ b/music_assistant/main.py @@ -20,7 +20,7 @@ from utils import run_periodic, LOGGER from modules.metadata import MetaData from modules.cache import Cache from modules.music import Music -from modules.player import Player +from modules.playermanager import PlayerManager from modules.http_streamer import HTTPStreamer from modules.homeassistant import setup as hass_setup from modules.web import setup as web_setup @@ -48,7 +48,7 @@ class Main(): self.web = web_setup(self) self.hass = hass_setup(self) self.music = Music(self) - self.player = Player(self) + self.player = PlayerManager(self) self.http_streamer = HTTPStreamer(self) # agent = stackimpact.start( diff --git a/music_assistant/models/__init__.py b/music_assistant/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/music_assistant/models/media_types.py b/music_assistant/models/media_types.py new file mode 100755 index 00000000..1c968a65 --- /dev/null +++ b/music_assistant/models/media_types.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +from enum import Enum, IntEnum + +class MediaType(IntEnum): + Artist = 1 + Album = 2 + Track = 3 + Playlist = 4 + Radio = 5 + +def media_type_from_string(media_type_str): + media_type_str = media_type_str.lower() + if 'artist' in media_type_str or media_type_str == '1': + return MediaType.Artist + elif 'album' in media_type_str or media_type_str == '2': + return MediaType.Album + elif 'track' in media_type_str or media_type_str == '3': + return MediaType.Track + elif 'playlist' in media_type_str or media_type_str == '4': + return MediaType.Playlist + elif 'radio' in media_type_str or media_type_str == '5': + return MediaType.Radio + else: + return None + +class ContributorRole(IntEnum): + Artist = 1 + Writer = 2 + Producer = 3 + +class AlbumType(IntEnum): + Album = 1 + Single = 2 + Compilation = 3 + +class TrackQuality(IntEnum): + LOSSY_MP3 = 0 + LOSSY_OGG = 1 + LOSSY_AAC = 2 + FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits HI-RES + FLAC_LOSSLESS_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES + + +class Artist(object): + ''' representation of an artist ''' + def __init__(self): + self.item_id = None + self.provider = 'database' + self.name = '' + self.sort_name = '' + self.metadata = {} + self.tags = [] + self.external_ids = [] + self.provider_ids = [] + self.media_type = MediaType.Artist + self.in_library = [] + self.is_lazy = False + +class Album(object): + ''' representation of an album ''' + def __init__(self): + self.item_id = None + self.provider = 'database' + self.name = '' + self.metadata = {} + self.version = '' + self.external_ids = [] + self.tags = [] + self.albumtype = AlbumType.Album + self.year = 0 + self.artist = None + self.labels = [] + self.provider_ids = [] + self.media_type = MediaType.Album + self.in_library = [] + self.is_lazy = False + +class Track(object): + ''' representation of a track ''' + def __init__(self): + self.item_id = None + self.provider = 'database' + self.name = '' + self.duration = 0 + self.version = '' + self.external_ids = [] + self.metadata = { } + self.tags = [] + self.artists = [] + self.provider_ids = [] + self.album = None + self.disc_number = 1 + self.track_number = 1 + self.media_type = MediaType.Track + self.in_library = [] + self.is_lazy = False + self.uri = "" + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return (self.name == other.name and + self.version == other.version and + self.item_id == other.item_id and + self.provider == other.provider) + def __ne__(self, other): + return not self.__eq__(other) + +class Playlist(object): + ''' representation of a playlist ''' + def __init__(self): + self.item_id = None + self.provider = 'database' + self.name = '' + self.owner = '' + self.provider_ids = [] + self.metadata = {} + self.media_type = MediaType.Playlist + self.in_library = [] + self.is_editable = False + +class Radio(Track): + ''' representation of a radio station ''' + def __init__(self): + super().__init__() + self.item_id = None + self.provider = 'database' + self.name = '' + self.provider_ids = [] + self.metadata = {} + self.media_type = MediaType.Radio + self.in_library = [] + self.is_editable = False + self.duration = 0 + + diff --git a/music_assistant/models.py b/music_assistant/models/musicprovider.py similarity index 78% rename from music_assistant/models.py rename to music_assistant/models/musicprovider.py index 7492af1f..c75e16a5 100755 --- a/music_assistant/models.py +++ b/music_assistant/models/musicprovider.py @@ -1,149 +1,12 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- -from enum import Enum, IntEnum from typing import List -import sys -sys.path.append("..") -from utils import run_periodic, LOGGER, parse_track_title -from difflib import SequenceMatcher as Matcher +from ..utils import run_periodic, LOGGER, parse_track_title import asyncio -from modules.cache import use_cache - - -class MediaType(IntEnum): - Artist = 1 - Album = 2 - Track = 3 - Playlist = 4 - Radio = 5 - -def media_type_from_string(media_type_str): - media_type_str = media_type_str.lower() - if 'artist' in media_type_str or media_type_str == '1': - return MediaType.Artist - elif 'album' in media_type_str or media_type_str == '2': - return MediaType.Album - elif 'track' in media_type_str or media_type_str == '3': - return MediaType.Track - elif 'playlist' in media_type_str or media_type_str == '4': - return MediaType.Playlist - elif 'radio' in media_type_str or media_type_str == '5': - return MediaType.Radio - else: - return None - -class ContributorRole(IntEnum): - Artist = 1 - Writer = 2 - Producer = 3 - -class AlbumType(IntEnum): - Album = 1 - Single = 2 - Compilation = 3 - -class TrackQuality(IntEnum): - LOSSY_MP3 = 0 - LOSSY_OGG = 1 - LOSSY_AAC = 2 - FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits HI-RES - FLAC_LOSSLESS_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES - FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES - FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES - FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES - - -class Artist(object): - ''' representation of an artist ''' - def __init__(self): - self.item_id = None - self.provider = 'database' - self.name = '' - self.sort_name = '' - self.metadata = {} - self.tags = [] - self.external_ids = [] - self.provider_ids = [] - self.media_type = MediaType.Artist - self.in_library = [] - self.is_lazy = False - -class Album(object): - ''' representation of an album ''' - def __init__(self): - self.item_id = None - self.provider = 'database' - self.name = '' - self.metadata = {} - self.version = '' - self.external_ids = [] - self.tags = [] - self.albumtype = AlbumType.Album - self.year = 0 - self.artist = None - self.labels = [] - self.provider_ids = [] - self.media_type = MediaType.Album - self.in_library = [] - self.is_lazy = False - -class Track(object): - ''' representation of a track ''' - def __init__(self): - self.item_id = None - self.provider = 'database' - self.name = '' - self.duration = 0 - self.version = '' - self.external_ids = [] - self.metadata = { } - self.tags = [] - self.artists = [] - self.provider_ids = [] - self.album = None - self.disc_number = 1 - self.track_number = 1 - self.media_type = MediaType.Track - self.in_library = [] - self.is_lazy = False - self.uri = "" - def __eq__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented - return (self.name == other.name and - self.version == other.version and - self.item_id == other.item_id and - self.provider == other.provider) - def __ne__(self, other): - return not self.__eq__(other) - -class Playlist(object): - ''' representation of a playlist ''' - def __init__(self): - self.item_id = None - self.provider = 'database' - self.name = '' - self.owner = '' - self.provider_ids = [] - self.metadata = {} - self.media_type = MediaType.Playlist - self.in_library = [] - self.is_editable = False - -class Radio(Track): - ''' representation of a radio station ''' - def __init__(self): - super().__init__() - self.item_id = None - self.provider = 'database' - self.name = '' - self.provider_ids = [] - self.metadata = {} - self.media_type = MediaType.Radio - self.in_library = [] - self.is_editable = False - self.duration = 0 +from ..modules.cache import use_cache +from media_types import * + class MusicProvider(): ''' @@ -492,31 +355,6 @@ class MusicProvider(): ''' get audio stream for a track ''' raise NotImplementedError - -class PlayerState(str, Enum): - Off = "off" - Stopped = "stopped" - Paused = "paused" - Playing = "playing" - -class MusicPlayer(): - ''' representation of a musicplayer ''' - def __init__(self): - self.player_id = None - self.player_provider = None - self.name = '' - self.state = PlayerState.Stopped - self.powered = False - self.cur_item = None - self.cur_item_time = 0 - self.volume_level = 0 - self.shuffle_enabled = True - self.repeat_enabled = False - self.muted = False - self.group_parent = None - self.is_group = False - self.settings = {} - self.enabled = True class PlayerProvider(): ''' @@ -527,13 +365,36 @@ class PlayerProvider(): name = 'My great Musicplayer provider' # display name prov_id = 'my_provider' # used as id icon = '' - supported_musicproviders = ['qobuz', 'file', 'spotify', 'http'] # list of supported music provider uri's this playerprovider supports NATIVELY - + def __init__(self, mass): self.mass = mass ### Common methods and properties #### + async def players(self): + ''' return all players for this provider ''' + return self.mass.provider_players(self.prov_id) + + async def get_player(self, player_id): + ''' return player by id ''' + return self.mass.get_player(player_id) + + async def add_player(self, player_id, name='', is_group=False): + ''' register a new player ''' + return self.mass.player.add_player(player_id, + self.prov_id, name=name, is_group=is_group) + + async def remove_player(self, player_id): + ''' remove a player ''' + return self.mass.player.remove_player(player_id) + + ### Provider specific implementation ##### + + async def player_config_entries(self): + ''' get the player config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, True, CONF_ENABLED) + ] async def play_media(self, player_id, media_items:List[Track], queue_opt='play'): ''' @@ -549,15 +410,9 @@ class PlayerProvider(): ''' raise NotImplementedError - - ### Provider specific implementation ##### - async def player_command(self, player_id, cmd:str, cmd_args=None): - ''' issue command on player (play, pause, next, previous, stop, power, volume) ''' + ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' raise NotImplementedError - async def player_queue(self, player_id, offset=0, limit=50): - ''' return the items in the player's queue ''' - raise NotImplementedError diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py new file mode 100755 index 00000000..05f7ad55 --- /dev/null +++ b/music_assistant/models/player.py @@ -0,0 +1,525 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +from enum import Enum +from typing import List +from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, try_parse_bool, try_parse_float +from ..constants import CONF_ENABLED +from ..modules.cache import use_cache +from media_types import Track, MediaType +from player_queue import PlayerQueue, QueueItem + + +class PlayerState(str, Enum): + Off = "off" + Stopped = "stopped" + Paused = "paused" + Playing = "playing" + +class Player(): + ''' representation of a player ''' + + #### Provider specific implementation, should be overridden #### + + async def get_config_entries(self): + ''' [MAY OVERRIDE] get the player-specific config entries for this player (list with key/value pairs)''' + return [] + + async def __stop(self): + ''' [MUST OVERRIDE] send stop command to player ''' + raise NotImplementedError + + async def __play(self): + ''' [MUST OVERRIDE] send play (unpause) command to player ''' + raise NotImplementedError + + async def __pause(self): + ''' [MUST OVERRIDE] send pause command to player ''' + raise NotImplementedError + + async def __power_on(self): + ''' [MUST OVERRIDE] send power ON command to player ''' + raise NotImplementedError + + async def __power_off(self): + ''' [MUST OVERRIDE] send power TOGGLE command to player ''' + raise NotImplementedError + + async def __volume_set(self, volume_level): + ''' [MUST OVERRIDE] send new volume level command to player ''' + raise NotImplementedError + + async def __volume_mute(self, is_muted=False): + ''' [MUST OVERRIDE] send mute command to player ''' + raise NotImplementedError + + async def __play_queue(self): + ''' [MUST OVERRIDE] tell player to start playing the queue ''' + raise NotImplementedError + + #### Common implementation, should NOT be overrridden ##### + + def __init__(self, mass, player_id, prov_id): + self.mass = mass + self._player_id = player_id + self._prov_id = prov_id + self._name = '' + self._is_group = False + self._state = PlayerState.Stopped + self._powered = False + self._cur_time = 0 + self._volume_level = 0 + self._muted = False + self._group_parent = None + self._queue = PlayerQueue(mass, self) + + @property + def player_id(self): + ''' [PROTECTED] player_id of this player ''' + return self._player_id + + @property + def player_provider(self): + ''' [PROTECTED] provider id of this player ''' + return self._prov_id + + @property + def name(self): + ''' [PROTECTED] name of this player ''' + if self.settings.get('name'): + return self.settings['name'] + else: + return self._name + + @name.setter + def name(self, name): + ''' [PROTECTED] set (real) name of this player ''' + if name != self._name: + self._name = name + self.mass.event_loop.create_task(self.update()) + + @property + def is_group(self): + ''' [PROTECTED] is_group property of this player ''' + return self._is_group + + @is_group.setter + def is_group(self, is_group:bool): + ''' [PROTECTED] set is_group property of this player ''' + if is_group != self._is_group: + self._is_group = is_group + self.mass.event_loop.create_task(self.update()) + + @property + def state(self): + ''' [PROTECTED] state property of this player ''' + if not self.powered: + return PlayerState.Off + if self.group_parent: + group_player = self.mass.event_loop.run_until_complete( + self.mass.player.get_player(self.group_parent)) + if group_player: + return group_player.state + return self._state + + @state.setter + def state(self, state:PlayerState): + ''' [PROTECTED] set state property of this player ''' + if state != self.state: + self._state = state + self.mass.event_loop.create_task(self.update()) + + @property + def powered(self): + ''' [PROTECTED] return power state for this player ''' + # homeassistant integration + if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'): + hass_state = self.mass.event_loop.run_until_complete( + self.mass.hass.get_state( + self.settings['hass_power_entity'], + attribute='source', + register_listener=self.update())) + return hass_state == self.settings['hass_power_entity_source'] + elif self.settings.get('hass_power_entity'): + hass_state = self.mass.event_loop.run_until_complete( + self.mass.hass.get_state( + self.settings['hass_power_entity'], + attribute='state', + register_listener=self.update())) + return hass_state != 'off' + # mute as power + elif self.settings.get('mute_as_power'): + return self.muted + else: + return self._powered + + @powered.setter + def powered(self, powered): + ''' [PROTECTED] set (real) power state for this player ''' + self._powered = powered + + @property + def cur_time(self): + ''' [PROTECTED] cur_time (player's elapsed time) property of this player ''' + # handle group player + if self.group_parent: + group_player = self.mass.event_loop.run_until_complete( + self.mass.player.get_player(self.group_parent)) + if group_player: + return group_player.cur_time + return self._cur_time + + @cur_time.setter + def cur_time(self, cur_time:int): + ''' [PROTECTED] set cur_time (player's elapsed time) property of this player ''' + if cur_time != self._cur_time: + self._cur_time = cur_time + self.mass.event_loop.create_task(self.update()) + + @property + def volume_level(self): + ''' [PROTECTED] volume_level property of this player ''' + # handle group volume + if self.is_group: + group_volume = 0 + active_players = 0 + for child_player in self.group_childs: + if child_player.enabled and child_player.powered: + group_volume += child_player.volume_level + active_players += 1 + if active_players: + group_volume = group_volume / active_players + return group_volume + # handle hass integration + elif self.mass.hass and self.settings.get('hass_volume_entity'): + hass_state = self.mass.event_loop.run_until_complete( + self.mass.hass.get_state( + self.settings['hass_volume_entity'], + attribute='volume_level', + register_listener=self.update())) + return int(try_parse_float(hass_state)*100) + else: + return self._volume_level + + @volume_level.setter + def volume_level(self, volume_level:int): + ''' [PROTECTED] set volume_level property of this player ''' + volume_level = try_parse_int(volume_level) + if volume_level != self._volume_level: + self._volume_level = volume_level + self.mass.event_loop.create_task(self.update()) + + @property + def muted(self): + ''' [PROTECTED] muted property of this player ''' + return self._muted + + @muted.setter + def muted(self, is_muted:bool): + ''' [PROTECTED] set muted property of this player ''' + is_muted = try_parse_bool(is_muted) + if is_muted != self._muted: + self._muted = is_muted + self.mass.event_loop.create_task(self.update()) + + @property + def group_parent(self): + ''' [PROTECTED] group_parent property of this player ''' + return self._group_parent + + @group_parent.setter + def group_parent(self, group_parent:str): + ''' [PROTECTED] set muted property of this player ''' + if group_parent != self._group_parent: + self._group_parent = group_parent + self.mass.create_task(self.update()) + + @property + def group_childs(self): + ''' [PROTECTED] return group childs ''' + if not self.is_group: + return [] + return [item for item in self.mass.player.players if item.group_parent == self.player_id] + + @property + def settings(self): + ''' [PROTECTED] get the player config settings ''' + player_settings = self.mass.config['player_settings'].get(self.player_id) + if not player_settings: + return self.mass.event_loop.run_until_complete(self.__update_player_settings()) + + @property + def enabled(self): + ''' [PROTECTED] player enabled config setting ''' + return self.settings.get('enabled') + + @property + def queue(self): + ''' [PROTECTED] player's queue ''' + # handle group player + if self.group_parent: + group_player = self.mass.event_loop.run_until_complete( + self.mass.player.get_player(self.group_parent)) + if group_player: + return group_player.queue + return self._queue + + async def stop(self): + ''' [PROTECTED] send stop command to player ''' + if self.group_parent: + # redirect playback related commands to parent player + group_player = await self.mass.player.get(self.group_parent) + if group_player: + return await group_player.stop() + else: + return await self.__stop() + + async def play(self): + ''' [PROTECTED] send play (unpause) command to player ''' + if self.group_parent: + # redirect playback related commands to parent player + group_player = await self.mass.player.get_player(self.group_parent) + if group_player: + return await group_player.play() + elif self.state == PlayerState.Paused: + return await self.__play() + elif self.state != PlayerState.Playing: + return await self.play_queue() + + async def pause(self): + ''' [PROTECTED] send pause command to player ''' + if self.group_parent: + # redirect playback related commands to parent player + group_player = await self.mass.player.get_player(self.group_parent) + if group_player: + return await group_player.pause() + else: + return await self.__pause() + + async def power_on(self): + ''' [PROTECTED] send power ON command to player ''' + self.__power_on() + # handle mute as power + if self.settings['mute_as_power']: + self.volume_mute(False) + # handle hass integration + if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'): + cur_source = await self.mass.hass.get_state(self.settings['hass_power_entity'], attribute='source') + if not cur_source: + service_data = { + 'entity_id': self.settings['hass_power_entity'], + 'source':self.settings['hass_power_entity_source'] + } + await self.mass.hass.call_service('media_player', 'select_source', service_data) + elif self.settings.get('hass_power_entity'): + domain = self.settings['hass_power_entity'].split('.')[0] + service_data = { 'entity_id': self.settings['hass_power_entity']} + await self.mass.hass.call_service(domain, 'turn_on', service_data) + # handle play on power on + if self.settings['play_power_on']: + self.play() + # handle group power + if self.group_parent: + # player has a group parent, check if it should be turned on + group_player = await self.mass.player.get_player(self.group_parent) + if group_player and not group_player.powered: + return await group_player.power_on() + + async def power_off(self): + ''' [PROTECTED] send power TOGGLE command to player ''' + self.__power_off() + # handle mute as power + if self.settings['mute_as_power']: + self.volume_mute(True) + # handle hass integration + if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'): + cur_source = await self.mass.hass.get_state(self.settings['hass_power_entity'], attribute='source') + if cur_source == self.settings['hass_power_entity_source']: + service_data = { 'entity_id': self.settings['hass_power_entity'] } + await self.mass.hass.call_service('media_player', 'turn_off', service_data) + elif self.mass.hass and self.settings.get('hass_power_entity'): + domain = self.settings['hass_power_entity'].split('.')[0] + service_data = { 'entity_id': self.settings['hass_power_entity']} + await self.mass.hass.call_service(domain, 'turn_ff', service_data) + # handle group power + if self.is_group: + # player is group, turn off all childs + for item in self.group_childs: + if item.powered: + await item.power_off() + elif self.group_parent: + # player has a group parent, check if it should be turned off + group_player = await self.mass.player.get_player(self.group_parent) + if group_player.powered: + needs_power = False + for child_player in group_player.group_childs: + if child_player.player_id != self.player_id and child_player.powered: + needs_power = True + break + if not needs_power: + await group_player.power_off() + + async def power_toggle(self): + ''' [PROTECTED] send toggle power command to player ''' + if self.powered: + return await self.power_off() + else: + return await self.power_on() + + async def volume_set(self, volume_level): + ''' [PROTECTED] send new volume level command to player ''' + volume_level = try_parse_int(volume_level) + # handle group volume + if self.is_group: + cur_volume = self.volume_level + new_volume = volume_level + volume_dif = new_volume - cur_volume + if cur_volume == 0: + volume_dif_percent = 1+(new_volume/100) + else: + volume_dif_percent = volume_dif/cur_volume + for child_player in self.group_childs: + if child_player.enabled and child_player.powered: + cur_child_volume = child_player.volume_level + new_child_volume = cur_child_volume + (cur_child_volume * volume_dif_percent) + await child_player.volume_set(new_child_volume) + # handle hass integration + elif self.mass.hass and self.settings.get('hass_volume_entity'): + service_data = { + 'entity_id': self.settings['hass_volume_entity'], + 'volume_level': volume_level/100 + } + await self.mass.hass.call_service('media_player', 'volume_set', service_data) + await self.__volume_set(100) # just force full volume on actual player if volume is outsourced to hass + else: + await self.__volume_set(volume_level) + + async def volume_up(self): + ''' [MAY OVERRIDE] send volume up command to player ''' + new_level = self.volume_level + 1 + return await self.volume_set(new_level) + + async def volume_down(self): + ''' [MAY OVERRIDE] send volume down command to player ''' + new_level = self.volume_level - 1 + if new_level < 0: + new_level = 0 + return await self.volume_set(new_level) + + async def volume_mute(self, is_muted=False): + ''' [MUST OVERRIDE] send mute command to player ''' + return await self.__volume_mute(is_muted) + + async def play_queue(self): + ''' [PROTECTED] send play_queue (start stream) command to player ''' + if self.group_parent: + # redirect playback related commands to parent player + group_player = await self.mass.player.get_player(self.group_parent) + if group_player: + return await group_player.play_queue() + elif self.queue.items: + return await self.__play_queue() + + async def play_media(self, media_item, queue_opt='play'): + ''' + play media item(s) on this player + media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio) + single item or list of items + queue_opt: + play -> insert new items in queue and start playing at the inserted position + replace -> replace queue contents with these items + next -> play item(s) after current playing item + add -> append new items at end of the queue + ''' + # a single item or list of items may be provided + media_items = media_item if isinstance(media_item, list) else [media_item] + queue_tracks = [] + for media_item in media_items: + # collect tracks to play + if media_item.media_type == MediaType.Artist: + tracks = await self.mass.music.artist_toptracks(media_item.item_id, + provider=media_item.provider) + elif media_item.media_type == MediaType.Album: + tracks = await self.mass.music.album_tracks(media_item.item_id, + provider=media_item.provider) + elif media_item.media_type == MediaType.Playlist: + tracks = await self.mass.music.playlist_tracks(media_item.item_id, + provider=media_item.provider, offset=0, limit=0) + else: + tracks = [media_item] # single track + for track in tracks: + queue_item = QueueItem() + queue_item.name = track.name + queue_item.artists = track.artists + queue_item.album = track.album + queue_item.duration = track.duration + queue_item.version = track.version + queue_item.metadata = track.metadata + queue_item.media_type = track.media_type + queue_item.uri = 'http://%s:%s/stream_queue?player_id=%s'% ( + self.local_ip, self.mass.config['base']['web']['http_port'], player_id) + # sort by quality and check track availability + for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True): + media_provider = prov_media['provider'] + media_item_id = prov_media['item_id'] + player_supported_provs = player_prov.supported_musicproviders + if media_provider in player_supported_provs and not self.mass.config['player_settings'][player_id]['force_http_streamer']: + # the provider can handle this media_type directly ! + track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, is_radio=is_radio) + playable_tracks.append(track) + match_found = True + elif 'http' in player_prov.supported_musicproviders: + # fallback to http streaming if supported + track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, True, is_radio=is_radio) + queue_tracks.append(track) + match_found = True + if match_found: + break + if queue_tracks: + if self._players[player_id].shuffle_enabled: + random.shuffle(playable_tracks) + if queue_opt in ['next', 'play'] and len(playable_tracks) > 1: + queue_opt = 'replace' # always assume playback of multiple items as new queue + return await player_prov.play_media(player_id, playable_tracks, queue_opt) + else: + raise Exception("Musicprovider and/or media not supported by player %s !" % (player_id) ) + + async def update(self): + ''' [PROTECTED] signal player updated ''' + self.__update_player_settings() + LOGGER.info("player updated: %s" % self.name) + self.mass.signal_event('player changed', self) + + async def __update_player_settings(self): + ''' [PROTECTED] get (or create) player config settings ''' + config_entries = [ # default config entries for a player + ("enabled", False, "player_enabled"), + ("name", "", "player_name"), + ("mute_as_power", False, "player_mute_power"), + ("max_sample_rate", 96000, "max_sample_rate"), + ('volume_normalisation', True, 'enable_r128_volume_normalisation'), + ('target_volume', '-23', 'target_volume_lufs'), + ('fallback_gain_correct', '-12', 'fallback_gain_correct') + ] + # append player specific settings + config_entries += await self.get_config_entries() + if self.is_group or not self.group_parent: + config_entries += [ # play on power on setting + ("play_power_on", False, "player_power_play"), + ] + if self.mass.config['base'].get('homeassistant',{}).get("enabled"): + # append hass specific config entries + config_entries += [("hass_power_entity", "", "hass_player_power"), + ("hass_power_entity_source", "", "hass_player_source"), + ("hass_volume_entity", "", "hass_player_volume")] + player_settings = self.mass.config['player_settings'].get(self.player_id,{}) + for key, def_value, desc in config_entries: + if not key in player_settings: + if (isinstance(def_value, str) and def_value.startswith('<')): + player_settings[key] = None + else: + player_settings[key] = def_value + self.mass.config['player_settings'][self.player_id] = player_settings + self.mass.config['player_settings'][self.player_id]['__desc__'] = config_entries + return player_settings + \ No newline at end of file diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py new file mode 100755 index 00000000..95d64375 --- /dev/null +++ b/music_assistant/models/player_queue.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +from ..utils import LOGGER +from ..constants import CONF_ENABLED +from typing import List +from player import PlayerState +from media_types import Track, TrackQuality +import operator +import random + +class QueueItem(object): + ''' representation of a queue item, simplified version of track ''' + def __init__(self): + self.item_id = None + self.provider = None + self.name = '' + self.duration = 0 + self.version = '' + self.quality = TrackQuality.FLAC_LOSSLESS + self.metadata = {} + self.artists = [] + self.album = None + self.uri = "" + self.is_radio = False + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return (self.name == other.name and + self.version == other.version and + self.item_id == other.item_id and + self.provider == other.provider) + def __ne__(self, other): + return not self.__eq__(other) + +class PlayerQueue(): + ''' + basic implementation of a queue for a player + if no player specific queue exists, this will be used + ''' + # TODO: Persistent storage in DB + + def __init__(self, mass, player): + self.mass = mass + self._player = player + self._items = [] + self._shuffle_enabled = True + self._repeat_enabled = True + self._cur_index = None + + @property + def shuffle_enabled(self): + return self._shuffle_enabled + + @property + def repeat_enabled(self): + return self._repeat_enabled + + @property + def cur_index(self): + return self._cur_index + + @property + def cur_item(self): + if self._cur_index == None: + return None + return self.mass.event_loop.run_until_complete(self.get_item(self._cur_index)) + + @property + async def next_index(self): + ''' + return the next queue index for this player + ''' + if not self.items: + # queue is empty + return None + if self.cur_index == None: + # playback started + return 0 + else: + # player already playing (or paused) so return the next item + if len(self.items) > (self.cur_index + 1): + return self.cur_index + 1 + elif self._repeat_enabled: + # repeat enabled, start queue at beginning + return 0 + return None + + @property + async def next_item(self): + ''' + return the next item in the queue + ''' + return self.mass.event_loop.run_until_complete( + self.get_item(self.next_index)) + + @property + async def items(self): + ''' + return all queue items for this player + ''' + return self._items + + async def get_item(self, index): + ''' get item by index from queue ''' + if len(self._items) > index: + return self._items[index] + return None + + async def shuffle(self, enable_shuffle:bool): + ''' enable/disable shuffle ''' + if not self._shuffle_enabled and enable_shuffle: + # shuffle requested + self._shuffle_enabled = True + self._items = await self.__shuffle_items(self._items) + self._cur_index = None + await self._player.play_queue() + self.mass.event_loop.create_task(self._player.update()) + elif self._shuffle_enabled and not enable_shuffle: + self._shuffle_enabled = False + # TODO: Unshuffle the list ? + self.mass.event_loop.create_task(self._player.update()) + + async def load(self, queue_items:List[QueueItem]): + ''' load (overwrite) queue with new items ''' + if self._shuffle_enabled: + queue_items = await self.__shuffle_items(queue_items) + self._items = queue_items + self._cur_index = None + await self._player.play_queue() + + async def insert(self, queue_items:List[QueueItem], offset=0): + ''' + insert new items at offset x from current position + keeps remaining items in queue + if offset 0 or None, will start playing newly added item(s) + ''' + insert_at_index = self.cur_index + offset + if not self.items or insert_at_index >= len(self.items): + return await self.load(queue_items) + if self.shuffle_enabled: + queue_items = await self.__shuffle_items(queue_items) + self._items = self._items[:insert_at_index] + queue_items + self._items[insert_at_index:] + if not offset: + await self._player.stop() + await self._player.play_queue() + + async def append(self, queue_items:List[QueueItem]): + ''' + append new items at the end of the queue + ''' + if self.shuffle_enabled: + queue_items = await self.__shuffle_items(queue_items) + self._items = self._items + queue_items + + async def __shuffle_items(self, queue_items): + ''' shuffle a list of tracks ''' + # for now we use default python random function + # can be extended with some more magic last last_played and stuff + return random.sample(queue_items, len(queue_items)) \ No newline at end of file diff --git a/music_assistant/models/playerprovider.py b/music_assistant/models/playerprovider.py new file mode 100755 index 00000000..d2868b01 --- /dev/null +++ b/music_assistant/models/playerprovider.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +from enum import Enum +from typing import List +from ..utils import run_periodic, LOGGER, parse_track_title +from ..constants import CONF_ENABLED +from ..modules.cache import use_cache +from player_queue import PlayerQueue +from media_types import Track +from player import Player + + +class PlayerProvider(): + ''' + Model for a Playerprovider + Common methods usable for every provider + Provider specific methods should be overriden in the provider specific implementation + ''' + + + def __init__(self, mass): + self.mass = mass + self.name = 'My great Musicplayer provider' # display name + self.prov_id = 'my_provider' # used as id + + ### Common methods and properties #### + + @property + async def players(self): + ''' return all players for this provider ''' + return self.mass.player.get_provider_players(self.prov_id) + + async def get_player(self, player_id:str): + ''' return player by id ''' + return self.mass.player.get_player(player_id) + + async def add_player(self, player:Player): + ''' register a new player ''' + return self.mass.player.add_player(player) + + async def remove_player(self, player_id:str): + ''' remove a player ''' + return self.mass.player.remove_player(player_id) + + ### Provider specific implementation ##### + + + + + diff --git a/music_assistant/modules/http_streamer.py b/music_assistant/modules/http_streamer.py index 19ff243d..e4c02612 100755 --- a/music_assistant/modules/http_streamer.py +++ b/music_assistant/modules/http_streamer.py @@ -24,20 +24,6 @@ class HTTPStreamer(): self.create_config_entries() self.local_ip = get_ip() self.analyze_jobs = {} - - def create_config_entries(self): - ''' sets the config entries for this module (list with key/value pairs)''' - config_entries = [ - ('volume_normalisation', True, 'enable_r128_volume_normalisation'), - ('target_volume', '-23', 'target_volume_lufs'), - ('fallback_gain_correct', '-12', 'fallback_gain_correct') - ] - if not self.mass.config['base'].get('http_streamer'): - self.mass.config['base']['http_streamer'] = {} - self.mass.config['base']['http_streamer']['__desc__'] = config_entries - for key, def_value, desc in config_entries: - if not key in self.mass.config['base']['http_streamer']: - self.mass.config['base']['http_streamer'][key] = def_value async def stream_track(self, http_request): ''' start streaming track from provider ''' @@ -129,14 +115,12 @@ class HTTPStreamer(): raise asyncio.CancelledError() return resp - async def stream_queue(self, http_request): + async def stream(self, http_request): ''' - stream all tracks in queue from player with http - loads large part of audiodata in memory so only recommended for high performance servers - use case is enable crossfade/gapless support for chromecast devices + stream queue track(s) for player with http ''' - player_id = http_request.query.get('player_id') - startindex = int(http_request.query.get('startindex')) + player_id = request.match_info.get('player_id','') + #startindex = int(http_request.query.get('startindex')) cancelled = threading.Event() resp = web.StreamResponse(status=200, reason='OK', @@ -157,10 +141,10 @@ class HTTPStreamer(): break await resp.write(chunk) queue.task_done() - LOGGER.info("stream_queue fininished for %s" % player_id) + LOGGER.info("stream fininished for %s" % player_id) except asyncio.CancelledError: cancelled.set() - LOGGER.info("stream_queue interrupted for %s" % player_id) + LOGGER.info("stream interrupted for %s" % player_id) raise asyncio.CancelledError() return resp @@ -189,7 +173,7 @@ class HTTPStreamer(): LOGGER.info("Start Queue Stream for player %s at index %s" %(player.name, queue_index)) last_fadeout_data = b'' # report start of queue playback so we can calculate current track/duration etc. - self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, True)) + # self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, True)) while True: # get the (next) track in queue try: diff --git a/music_assistant/modules/music.py b/music_assistant/modules/music_manager.py similarity index 98% rename from music_assistant/modules/music.py rename to music_assistant/modules/music_manager.py index ad4bdc14..f0689915 100755 --- a/music_assistant/modules/music.py +++ b/music_assistant/modules/music_manager.py @@ -2,14 +2,12 @@ # -*- coding:utf-8 -*- import asyncio -import os -from utils import run_periodic, run_async_background_task, LOGGER, try_parse_int, try_supported -import aiohttp -from difflib import SequenceMatcher as Matcher -from models import MediaType, Track, Artist, Album, Playlist, Radio from typing import List import toolz import operator +import os +from ..utils import run_periodic, LOGGER, try_supported +from ..models.media_types import MediaType, Track, Artist, Album, Playlist, Radio BASE_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/music_assistant/modules/player.py b/music_assistant/modules/player.py deleted file mode 100755 index 9f4e460c..00000000 --- a/music_assistant/modules/player.py +++ /dev/null @@ -1,441 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task -from models import MediaType, PlayerState, MusicPlayer, TrackQuality -import operator -import random -from copy import deepcopy -import functools -import urllib - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" ) - - -class Player(): - ''' several helpers to handle playback through player providers ''' - - def __init__(self, mass): - self.mass = mass - self.providers = {} - self._players = {} - self.local_ip = get_ip() - # dynamically load provider modules - self.load_providers() - - async def players(self): - ''' return all players ''' - items = list(self._players.values()) - items.sort(key=lambda x: x.name, reverse=False) - return items - - async def player(self, player_id): - ''' return players by id ''' - return self._players[player_id] - - async def player_command(self, player_id, cmd, cmd_args=None, skip_group_power=False): - ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' - if player_id not in self._players: - return - player = self._players[player_id] - prov_id = player.player_provider - prov = self.providers[prov_id] - LOGGER.debug('received command %s for player %s' %(cmd, player.name)) - # handle some common workarounds - if (cmd in ['pause', 'play'] and cmd_args == 'toggle') or cmd == 'playpause': - cmd = 'pause' if player.state == PlayerState.Playing else 'play' - if cmd == 'power' and (cmd_args == 'toggle' or not cmd_args): - cmd_args = 'off' if player.powered else 'on' - if cmd == 'volume' and cmd_args == 'up': - cmd_args = player.volume_level + 1 - elif cmd == 'volume' and cmd_args == 'down': - cmd_args = player.volume_level - 1 - elif cmd == 'volume' and '+' in str(cmd_args): - cmd_args = player.volume_level + try_parse_int(cmd_args.replace('+','')) - elif cmd == 'volume' and '-' in str(cmd_args): - cmd_args = player.volume_level - try_parse_int(cmd_args.replace('-','')) - if cmd == 'mute' and (cmd_args == 'toggle' or not cmd_args): - cmd_args = 'off' if player.muted else 'on' - if cmd == 'volume' and cmd_args: - if try_parse_int(cmd_args) > 100: - cmd_args = 100 - elif try_parse_int(cmd_args) < 0: - cmd_args = 0 - if cmd == 'volume' and player.is_group and player.settings['apply_group_volume']: - # group volume - return await self.__player_command_group_volume(player, cmd_args) - - # redirect playlist related commands to parent player - if player.group_parent and cmd not in ['power', 'volume', 'mute']: - return await self.player_command(player.group_parent, cmd, cmd_args) - # handle hass integration - await self.__player_command_hass_integration(player, cmd, cmd_args) - # handle group power for group players - if not skip_group_power: - asyncio.create_task(self.__player_command_group_power(player, cmd, cmd_args)) - # handle play on power on - if cmd == 'power' and cmd_args == 'on' and player.settings['play_power_on']: - cmd = 'play' - cmd_args = None - # handle mute as power - if cmd == 'power' and player.settings['mute_as_power']: - cmd = 'mute' - cmd_args = 'on' if cmd_args == 'off' else 'off' # invert logic (power ON is mute OFF) - # normal execution of command on player - await prov.player_command(player_id, cmd, cmd_args) - - async def __player_command_hass_integration(self, player, cmd, cmd_args): - ''' handle hass integration in player command ''' - if not self.mass.hass: - return - if cmd == 'power' and player.settings.get('hass_power_entity') and player.settings.get('hass_power_entity_source'): - cur_source = await self.mass.hass.get_state(player.settings['hass_power_entity'], attribute='source') - if cmd_args == 'on' and not cur_source: - service_data = { 'entity_id': player.settings['hass_power_entity'], 'source':player.settings['hass_power_entity_source'] } - await self.mass.hass.call_service('media_player', 'select_source', service_data) - elif cmd_args == 'off' and cur_source == player.settings['hass_power_entity_source']: - service_data = { 'entity_id': player.settings['hass_power_entity'] } - await self.mass.hass.call_service('media_player', 'turn_off', service_data) - else: - LOGGER.debug('Ignoring power command as required source is not active') - elif cmd == 'power' and player.settings.get('hass_power_entity'): - domain = player.settings['hass_power_entity'].split('.')[0] - service_data = { 'entity_id': player.settings['hass_power_entity']} - await self.mass.hass.call_service(domain, 'turn_%s' % cmd_args, service_data) - if cmd == 'volume' and player.settings.get('hass_volume_entity'): - service_data = { 'entity_id': player.settings['hass_power_entity'], 'volume_level': int(cmd_args)/100} - await self.mass.hass.call_service('media_player', 'volume_set', service_data) - cmd_args = 100 # just force full volume on actual player if volume is outsourced to hass - - async def __player_command_group_volume(self, player, new_volume): - ''' handle group volume ''' - cur_volume = player.volume_level - new_volume = try_parse_int(new_volume) - volume_dif = new_volume - cur_volume - if cur_volume == 0: - volume_dif_percent = 1+(new_volume/100) - else: - volume_dif_percent = volume_dif/cur_volume - player_childs = [item for item in self._players.values() if item.group_parent == player.player_id] - for child_player in player_childs: - if child_player.enabled and child_player.powered: - cur_child_volume = child_player.volume_level - new_child_volume = cur_child_volume + (cur_child_volume * volume_dif_percent) - child_player.volume_level = new_child_volume - await self.player_command(child_player.player_id, 'volume', new_child_volume) - player.volume_level = new_volume - - async def __player_command_group_power(self, player, cmd, cmd_args): - ''' handle group power command ''' - if player.is_group and player.settings['apply_group_power'] and cmd == 'power' and cmd_args == 'off': - # turn off all child players - player_childs = [item for item in self._players.values() if item.group_parent == player.player_id] - for item in player_childs: - if item.powered: - await self.player_command(item.player_id, cmd, cmd_args, True) - elif player.group_parent and player.group_parent in self._players: - group_player = self._players[player.group_parent] - if group_player.settings['apply_group_power']: - if cmd == 'power' and cmd_args == 'on': - if not group_player.powered: - return await self.player_command(group_player.player_id, 'power', 'on', True) - player_childs = [item for item in self._players.values() if item.group_parent == group_player.player_id] - if group_player.powered and cmd == 'power' and cmd_args == 'off': - # check if the group player should (still) be turned on - needs_power = False - for child_player in player_childs: - if child_player.player_id != player.player_id and child_player.powered: - needs_power = True - break - if not needs_power: - await self.player_command(group_player.player_id, 'power', 'off', True) - - async def remove_player(self, player_id): - ''' handle a player remove ''' - self._players.pop(player_id, None) - self.mass.signal_event('player removed', player_id) - - async def trigger_update(self, player_id): - ''' manually trigger update for a player ''' - if player_id in self._players: - await self.update_player(self._players[player_id]) - - async def update_player(self, player_details): - ''' update (or add) player ''' - player_details = deepcopy(player_details) - player_id = player_details.player_id - player_changed = False - if not player_id in self._players: - # first message from player - self._players[player_id] = MusicPlayer() - player = self._players[player_id] - player.player_id = player_id - player.player_provider = player_details.player_provider - player_changed = True - else: - player = self._players[player_id] - player.settings = await self.__get_player_settings(player_details) - # handle basic player settings - player_details.enabled = player.settings['enabled'] - player_details.name = player.settings['name'] if player.settings['name'] else player_details.name - # handle hass integration - await self.__update_player_hass_settings(player_details, player.settings) - # handle mute as power setting - if player.settings['mute_as_power']: - player_details.powered = not player_details.muted - # combine state of group parent - if player_details.group_parent and player_details.group_parent in self._players: - parent_player = self._players[player_details.group_parent] - player_details.cur_item_time = parent_player.cur_item_time - player_details.cur_item = parent_player.cur_item - player_details.state = parent_player.state - - # handle group volume/power setting - if player_details.is_group: - player_childs = [item for item in self._players.values() if item.group_parent == player_id] - if player.settings['apply_group_volume']: - player_details.volume_level = await self.__get_group_volume(player_childs) - # detect current track changes - if player.cur_item and player_details.cur_item and player.cur_item.name != player_details.cur_item.name: - # track changed - player_changed = True - if not player.group_parent: - LOGGER.info("%s -- STOP PLAYING %s -- SECONDS PLAYED: %s" %(player.name, player.cur_item.name, player.cur_item_time)) - LOGGER.info("%s -- START PLAYING %s" %(player.name, player_details.cur_item.name)) - player.cur_item = player_details.cur_item - elif not player.cur_item and player_details.cur_item: - # player started playing - player_changed = True - if not player.group_parent: - LOGGER.info("%s -- START PLAYING %s" %(player.name, player_details.cur_item.name)) - player.cur_item = player_details.cur_item - elif player.cur_item and not player_details.cur_item: - # player queue cleared - player_changed = True - if not player.group_parent: - LOGGER.info("%s -- STOP PLAYING %s -- SECONDS PLAYED: %s" %(player.name, player.cur_item.name, player.cur_item_time)) - player.cur_item = player_details.cur_item - # compare values to detect changes - for key, cur_value in player.__dict__.items(): - if key != 'settings': - new_value = getattr(player_details, key) - if new_value != cur_value: - player_changed = True - setattr(player, key, new_value) - LOGGER.debug('key changed: %s for player %s - new value: %s' % (key, player.name, new_value)) - if player_changed: - # player is added or updated! - self.mass.signal_event('player updated', player) - if player_details.is_group: - # is groupplayer, trigger update of its childs - player_childs = [item for item in self._players.values() if item.group_parent == player_id] - for child in player_childs: - asyncio.create_task(self.trigger_update(child.player_id)) - elif player.group_parent: - # if child player in a group, trigger update of parent - asyncio.create_task(self.trigger_update(player.group_parent)) - - async def __update_player_hass_settings(self, player_details, player_settings): - ''' handle home assistant integration on a player ''' - if not self.mass.hass: - return - player_id = player_details.player_id - player_settings = self.mass.config['player_settings'][player_id] - if player_settings.get('hass_power_entity') and player_settings.get('hass_power_entity_source'): - hass_state = await self.mass.hass.get_state( - player_settings['hass_power_entity'], - attribute='source', - register_listener=functools.partial(self.trigger_update, player_id)) - player_details.powered = hass_state == player_settings['hass_power_entity_source'] - elif player_settings.get('hass_power_entity'): - hass_state = await self.mass.hass.get_state( - player_settings['hass_power_entity'], - attribute='state', - register_listener=functools.partial(self.trigger_update, player_id)) - player_details.powered = hass_state != 'off' - if player_settings.get('hass_volume_entity'): - hass_state = await self.mass.hass.get_state( - player_settings['hass_volume_entity'], - attribute='volume_level', - register_listener=functools.partial(self.trigger_update, player_id)) - player_details.volume_level = int(try_parse_float(hass_state)*100) - - async def __get_group_volume(self, player_childs): - ''' handle group volume ''' - group_volume = 0 - active_players = 0 - for child_player in player_childs: - if child_player.enabled and child_player.powered: - group_volume += child_player.volume_level - active_players += 1 - group_volume = group_volume / active_players if active_players else 0 - return group_volume - - async def __get_group_power(self, player_childs): - ''' handle group volume ''' - group_power = False - for child_player in player_childs: - print(child_player.name) - print(child_player.powered) - if child_player.enabled and child_player.powered: - group_power = True - break - return group_power - - async def __get_player_settings(self, player_details): - ''' get (or create) player config ''' - player_id = player_details.player_id - config_entries = [ # default config entries for a player - ("enabled", False, "player_enabled"), - ("name", "", "player_name"), - ("mute_as_power", False, "player_mute_power"), - ("disable_volume", False, "player_disable_vol"), - ("sox_effects", '', "http_streamer_sox_effects"), - ("max_sample_rate", 96000, "max_sample_rate"), - ("force_http_streamer", False, "force_http_streamer") - ] - # append provider specific player settings - config_entries += await self.mass.player.providers[player_details.player_provider].player_config_entries() - if player_details.is_group: - config_entries += [ # group player settings - ("apply_group_volume", False, "player_group_vol"), - ("apply_group_power", False, "player_group_pow") - ] - if player_details.is_group or not player_details.group_parent: - config_entries += [ # play on power on setting - ("play_power_on", False, "player_power_play"), - ] - if self.mass.config['base'].get('homeassistant',{}).get("enabled"): - # append hass specific config entries - config_entries += [("hass_power_entity", "", "hass_player_power"), - ("hass_power_entity_source", "", "hass_player_source"), - ("hass_volume_entity", "", "hass_player_volume")] - player_settings = self.mass.config['player_settings'].get(player_id,{}) - for key, def_value, desc in config_entries: - if not key in player_settings: - if (isinstance(def_value, str) and def_value.startswith('<')): - player_settings[key] = None - else: - player_settings[key] = def_value - self.mass.config['player_settings'][player_id] = player_settings - self.mass.config['player_settings'][player_id]['__desc__'] = config_entries - return player_settings - - async def play_media(self, player_id, media_item, queue_opt='play'): - ''' - play media on a player - player_id: id of the player - media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio) - queue_opt: play, replace, next or add - ''' - if not player_id in self._players: - LOGGER.warning('Player %s not found' % player_id) - return False - player_prov = self.providers[self._players[player_id].player_provider] - # a single item or list of items may be provided - media_items = media_item if isinstance(media_item, list) else [media_item] - playable_tracks = [] - for media_item in media_items: - # collect tracks to play - if media_item.media_type == MediaType.Artist: - tracks = await self.mass.music.artist_toptracks(media_item.item_id, provider=media_item.provider) - elif media_item.media_type == MediaType.Album: - tracks = await self.mass.music.album_tracks(media_item.item_id, provider=media_item.provider) - elif media_item.media_type == MediaType.Playlist: - tracks = await self.mass.music.playlist_tracks(media_item.item_id, provider=media_item.provider, offset=0, limit=0) - else: - tracks = [media_item] # single track - # check supported music providers by this player and work out how to handle playback... - for track in tracks: - # sort by quality - match_found = False - is_radio = track.media_type == MediaType.Radio - for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True): - media_provider = prov_media['provider'] - media_item_id = prov_media['item_id'] - player_supported_provs = player_prov.supported_musicproviders - if media_provider in player_supported_provs and not self.mass.config['player_settings'][player_id]['force_http_streamer']: - # the provider can handle this media_type directly ! - track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, is_radio=is_radio) - playable_tracks.append(track) - match_found = True - elif 'http' in player_prov.supported_musicproviders: - # fallback to http streaming if supported - track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, True, is_radio=is_radio) - playable_tracks.append(track) - match_found = True - if match_found: - break - if playable_tracks: - if self._players[player_id].shuffle_enabled: - random.shuffle(playable_tracks) - if queue_opt in ['next', 'play'] and len(playable_tracks) > 1: - queue_opt = 'replace' # always assume playback of multiple items as new queue - return await player_prov.play_media(player_id, playable_tracks, queue_opt) - else: - raise Exception("Musicprovider and/or media not supported by player %s !" % (player_id) ) - - async def get_track_uri(self, item_id, provider, player_id, http_stream=False, is_radio=False): - ''' generate the URL/URI for a media item ''' - uri = "" - if http_stream: - if is_radio: - params = {"provider": provider, "radio_id": str(item_id), "player_id": str(player_id)} - params_str = urllib.parse.urlencode(params) - uri = 'http://%s:%s/stream_radio?%s'% (self.local_ip, self.mass.config['base']['web']['http_port'], params_str) - else: - params = {"provider": provider, "track_id": str(item_id), "player_id": str(player_id)} - params_str = urllib.parse.urlencode(params) - uri = 'http://%s:%s/stream_track?%s'% (self.local_ip, self.mass.config['base']['web']['http_port'], params_str) - elif provider == "spotify": - uri = 'spotify://spotify:track:%s' % item_id - elif provider == "qobuz": - uri = 'qobuz://%s.flac' % item_id - elif provider == "file": - uri = item_id - else: - uri = "%s://%s" %(provider, item_id) - return uri - - async def player_queue(self, player_id, offset=0, limit=50): - ''' return the items in the player's queue ''' - player = self._players[player_id] - player_prov = self.providers[player.player_provider] - return await player_prov.player_queue(player_id, offset=offset, limit=limit) - - async def player_queue_index(self, player_id): - ''' get current index of the player's queue ''' - player = self._players[player_id] - player_prov = self.providers[player.player_provider] - return await player_prov.player_queue_index(player_id) - - async def player_queue_stream_update(self, player_id, cur_index, is_start=False): - ''' called by our queue streamer when it started playing the queue from position x ''' - player = self._players[player_id] - return await self.providers[player.player_provider].player_queue_stream_update(player_id, cur_index, is_start) - - def load_providers(self): - ''' dynamically load providers ''' - for item in os.listdir(MODULES_PATH): - if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and - item.endswith('.py') and not item.startswith('.')): - module_name = item.replace(".py","") - LOGGER.debug("Loading playerprovider module %s" % module_name) - try: - mod = __import__("modules.playerproviders." + module_name, fromlist=['']) - if not self.mass.config['playerproviders'].get(module_name): - self.mass.config['playerproviders'][module_name] = {} - self.mass.config['playerproviders'][module_name]['__desc__'] = mod.config_entries() - for key, def_value, desc in mod.config_entries(): - if not key in self.mass.config['playerproviders'][module_name]: - self.mass.config['playerproviders'][module_name][key] = def_value - mod = mod.setup(self.mass) - if mod: - self.providers[mod.prov_id] = mod - cls_name = mod.__class__.__name__ - LOGGER.info("Successfully initialized module %s" % cls_name) - except Exception as exc: - LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) diff --git a/music_assistant/modules/player_manager.py b/music_assistant/modules/player_manager.py new file mode 100755 index 00000000..438ce7b2 --- /dev/null +++ b/music_assistant/modules/player_manager.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from enum import Enum +from ..utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task +from ..models.media_types import MediaType, TrackQuality +from ..models.player_queue import QueueItem +from ..models.player import PlayerState +import operator +import random +import functools +import urllib + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" ) + + + +class PlayerManager(): + ''' several helpers to handle playback through player providers ''' + + def __init__(self, mass): + self.mass = mass + self.providers = {} + self._players = {} + self.local_ip = get_ip() + # dynamically load provider modules + self.load_providers() + + @property + def players(self): + ''' all players as property ''' + return self.mass.event_loop.run_until_complete(self.get_players()) + + async def get_players(self): + ''' return all players as a list ''' + items = list(self._players.values()) + items.sort(key=lambda x: x.name, reverse=False) + return items + + async def get_player(self, player_id): + ''' return player by id ''' + return self._players.get(player_id, None) + + async def get_provider_players(self, player_provider): + ''' return all players for given provider_id ''' + return [item for item in self._players.values() if item.player_provider == player_provider] + + async def add_player(self, player): + ''' register a new player ''' + self._players[player.player_id] = player + self.mass.signal_event('player added', player) + # TODO: turn on player if it was previously turned on ? + return player + + async def remove_player(self, player_id): + ''' handle a player remove ''' + self._players.pop(player_id, None) + self.mass.signal_event('player removed', player_id) + + async def trigger_update(self, player_id): + ''' manually trigger update for a player ''' + if player_id in self._players: + await self._players[player_id].update() + + def load_providers(self): + ''' dynamically load providers ''' + for item in os.listdir(MODULES_PATH): + if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and + item.endswith('.py') and not item.startswith('.')): + module_name = item.replace(".py","") + LOGGER.debug("Loading playerprovider module %s" % module_name) + try: + mod = __import__("modules.playerproviders." + module_name, fromlist=['']) + if not self.mass.config['playerproviders'].get(module_name): + self.mass.config['playerproviders'][module_name] = {} + self.mass.config['playerproviders'][module_name]['__desc__'] = mod.config_entries() + for key, def_value, desc in mod.config_entries(): + if not key in self.mass.config['playerproviders'][module_name]: + self.mass.config['playerproviders'][module_name][key] = def_value + mod = mod.setup(self.mass) + if mod: + self.providers[mod.prov_id] = mod + cls_name = mod.__class__.__name__ + LOGGER.info("Successfully initialized module %s" % cls_name) + except Exception as exc: + LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) diff --git a/music_assistant/modules/playerproviders/chromecast.py b/music_assistant/modules/playerproviders/chromecast.py index cc33f639..736528d4 100644 --- a/music_assistant/modules/playerproviders/chromecast.py +++ b/music_assistant/modules/playerproviders/chromecast.py @@ -2,25 +2,26 @@ # -*- coding:utf-8 -*- import asyncio -import os -from typing import List -import random -import sys -from utils import run_periodic, run_background_task, LOGGER, parse_track_title, try_parse_int -from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT -import json +# import os +# from typing import List +# import random +# import sys +# import json import aiohttp -import time -import datetime -import hashlib +# import time +# import datetime +# import hashlib import pychromecast from pychromecast.controllers.multizone import MultizoneController from pychromecast.controllers import BaseController from pychromecast.controllers.media import MediaController import types -import urllib -import select +# import urllib +# import select +from ...utils import run_periodic, LOGGER, try_parse_int +from ...models.playerprovider import PlayerProvider +from ...models.player import Player, PlayerState +from ...constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT def setup(mass): ''' setup the provider''' @@ -36,166 +37,52 @@ def config_entries(): (CONF_ENABLED, True, CONF_ENABLED), ] +class ChromecastPlayer(Player): + ''' Chromecast player object ''' + cc = None + + async def __stop(self): + ''' send stop command to player ''' + self.cc.media_controller.stop() + + async def __play(self): + ''' send play command to player ''' + self.cc.media_controller.play() + + async def __pause(self): + ''' send pause command to player ''' + self.cc.media_controller.pause() + + async def __power_on(self): + ''' send power ON command to player ''' + self.powered = True + + async def __power_off(self): + ''' send power OFF command to player ''' + self.powered = False + # power is not supported so send quit_app instead + if not self.group_parent: + self.cc.quit_app() + + async def __volume_set(self, volume_level): + ''' send new volume level command to player ''' + self.cc.set_volume(volume_level/100) + self.volume_level = volume_level + + async def __volume_mute(self, is_muted=False): + ''' send mute command to player ''' + self.cc.set_volume_muted(is_muted) + + class ChromecastProvider(PlayerProvider): ''' support for ChromeCast Audio ''' - + def __init__(self, mass): self.prov_id = 'chromecast' self.name = 'Chromecast' - self.icon = '' self.mass = mass - self._players = {} - self._chromecasts = {} - self._player_queue = {} - self._player_queue_index = {} - self._player_queue_stream_startindex = {} self._discovery_running = False - self.supported_musicproviders = ['http'] self.mass.event_loop.create_task(self.__periodic_chromecast_discovery()) - - ### Provider specific implementation ##### - - async def player_config_entries(self): - ''' - get the player config entries for this provider - (list with key/value pairs) - ''' - return [ - ("crossfade_duration", 0, "crossfade_duration"), - ] - - async def player_command(self, player_id, cmd:str, cmd_args=None): - ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' - if (not player_id in self._chromecasts or - not self._chromecasts[player_id].socket_client or - not self._chromecasts[player_id].socket_client.is_connected): - LOGGER.warning("command %s failed - %s is disconnected, rescan triggered" %(cmd, self._players[player_id].name)) - self.mass.event_loop.create_task(self.__chromecast_discovery()) - return - if cmd == 'play': - self._players[player_id].powered = True - if self._chromecasts[player_id].media_controller.status.player_is_playing: - pass - elif self._chromecasts[player_id].media_controller.status.player_is_paused: - self._chromecasts[player_id].media_controller.play() - else: - await self.__resume_queue(player_id) - await self.mass.player.update_player(self._players[player_id]) - elif cmd == 'pause': - self._chromecasts[player_id].media_controller.pause() - elif cmd == 'stop': - self._chromecasts[player_id].media_controller.stop() - elif cmd == 'next': - enable_crossfade = self.mass.config['player_settings'][player_id]["crossfade_duration"] > 0 - if enable_crossfade: - await self.__play_stream_queue(player_id, self._player_queue_index[player_id]+1) - else: - self._chromecasts[player_id].media_controller.queue_next() - elif cmd == 'previous': - enable_crossfade = self.mass.config['player_settings'][player_id]["crossfade_duration"] > 0 - if enable_crossfade: - await self.__play_stream_queue(player_id, self._player_queue_index[player_id]-1) - else: - self._chromecasts[player_id].media_controller.queue_prev() - elif cmd == 'power' and cmd_args == 'off': - self._players[player_id].powered = False - if not self._players[player_id].group_parent: - self._chromecasts[player_id].quit_app() # power is not supported so send quit_app instead - await self.mass.player.update_player(self._players[player_id]) - elif cmd == 'power': - self._players[player_id].powered = True - await self.mass.player.update_player(self._players[player_id]) - elif cmd == 'volume': - new_volume = try_parse_int(cmd_args) - self._chromecasts[player_id].set_volume(new_volume/100) - self._players[player_id].volume_level = new_volume - await self.mass.player.update_player(self._players[player_id]) - elif cmd == 'mute' and cmd_args == 'off': - self._chromecasts[player_id].set_volume_muted(False) - elif cmd == 'mute': - self._chromecasts[player_id].set_volume_muted(True) - - async def player_queue(self, player_id, offset=0, limit=50): - ''' return the current items in the player's queue ''' - return self._player_queue[player_id][offset:limit] - - async def player_queue_index(self, player_id): - ''' get current index of the player's queue ''' - return self._player_queue_index[player_id] - - async def play_media(self, player_id, media_items, queue_opt='play'): - ''' - play media on a player - ''' - if (not player_id in self._chromecasts or - not self._chromecasts[player_id].socket_client or - not self._chromecasts[player_id].socket_client.is_connected): - LOGGER.warning("play_media failed - %s is disconnected, rescan triggered" %(self._players[player_id].name)) - self.mass.event_loop.create_task(self.__chromecast_discovery()) - return - - castplayer = self._chromecasts[player_id] - cur_queue_index = self._player_queue_index.get(player_id, 0) - enable_crossfade = self.mass.config['player_settings'][player_id]["crossfade_duration"] > 0 - is_radio = media_items and media_items[0].media_type == MediaType.Radio - - if queue_opt == 'replace' or not self._player_queue[player_id]: - # overwrite queue with new items - self._player_queue[player_id] = media_items - if enable_crossfade and not is_radio: - await self.__play_stream_queue(player_id, 0) - else: - await self.__queue_load(player_id, self._player_queue[player_id], 0) - elif queue_opt == 'play': - # replace current item with new item(s) - self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index] + media_items + self._player_queue[player_id][cur_queue_index+1:] - if enable_crossfade and not is_radio: - await self.__play_stream_queue(player_id, cur_queue_index) - else: - await self.__queue_load(player_id, self._player_queue[player_id], cur_queue_index) - elif queue_opt == 'next': - # insert new items at current index +1 - if len(self._player_queue[player_id]) > cur_queue_index+1: - old_next_uri = self._player_queue[player_id][cur_queue_index+1].uri - else: - old_next_uri = None - self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index+1] + media_items + self._player_queue[player_id][cur_queue_index+1:] - if not enable_crossfade or is_radio: - # find out the itemID of the next item in CC queue - insert_at_item_id = None - if old_next_uri: - for item in castplayer.media_controller.queue_items: - if item['media']['contentId'] == old_next_uri: - insert_at_item_id = item['itemId'] - await self.__queue_insert(player_id, media_items, insert_at_item_id) - elif queue_opt == 'add': - # add new items at end of queue - self._player_queue[player_id] = self._player_queue[player_id] + media_items - if not enable_crossfade or is_radio: - await self.__queue_insert(player_id, media_items) - - async def player_queue_stream_update(self, player_id, cur_index, is_start=False): - ''' called by our queue streamer when it started playing a track in the queue at index X ''' - if is_start: - self._player_queue_stream_startindex[player_id] = cur_index - self._player_queue_index[player_id] = cur_index - # schedule update a few times as we don't know how much time is prebuffered - for i in range(0, 20): - castplayer = self._chromecasts[player_id] - status = castplayer.media_controller.status - await self.__handle_player_state(castplayer, mediastatus=status) - await asyncio.sleep(2) - - ### Provider specific (helper) methods ##### - - async def __get_cur_queue_index(self, player_id, current_uri): - ''' retrieve index of current item in the player queue ''' - cur_index = 0 - for index, track in enumerate(self._player_queue[player_id]): - if track.uri == current_uri: - cur_index = index - break - return cur_index async def __queue_load(self, player_id, new_tracks, startindex=None): ''' load queue on player with given queue items ''' @@ -310,7 +197,7 @@ class ChromecastProvider(PlayerProvider): async def __handle_player_state(self, chromecast, caststatus=None, mediastatus=None): ''' handle a player state message from the socket ''' player_id = str(chromecast.uuid) - player = self._players[player_id] + player = self.get_player(player_id) # always update player details that may change player.name = chromecast.name if caststatus: @@ -351,56 +238,26 @@ class ChromecastProvider(PlayerProvider): player.cur_item = queue_track player.cur_item_time = track_time self._player_queue_index[player_id] = queue_index - await self.mass.player.update_player(player) - - async def __parse_track(self, mediastatus): - ''' parse track in CC to our internal format ''' - track = await self.__track_from_uri(mediastatus.content_id) - if not track: - # TODO: match this info manually in the DB!! - track = Track() - artist = mediastatus.artist - album = mediastatus.album_name - title = mediastatus.title - track.name = "%s - %s" %(artist, title) - track.duration = try_parse_int(mediastatus.duration) - if mediastatus.media_metadata and mediastatus.media_metadata.get('images'): - track.metadata.image = mediastatus.media_metadata['images'][-1]['url'] - return track - - async def __track_from_uri(self, uri): - ''' try to parse uri loaded in CC to a track we understand ''' - track = None - if uri.startswith('spotify://track:') and 'spotify' in self.mass.music.providers: - track_id = uri.replace('spotify:track:','') - track = await self.mass.music.providers['spotify'].track(track_id) - elif uri.startswith('qobuz://') and 'qobuz' in self.mass.music.providers: - track_id = uri.replace('qobuz://','').replace('.flac','') - track = await self.mass.music.providers['qobuz'].track(track_id) - elif uri.startswith('http') and '/stream_track' in uri: - params = urllib.parse.parse_qs(uri.split('?')[1]) - track_id = params['track_id'][0] - provider = params['provider'][0] - track = await self.mass.music.providers[provider].track(track_id) - return track async def __handle_group_members_update(self, mz, added_player=None, removed_player=None): ''' callback when cast group members update ''' if added_player: - if added_player in self._players: - self._players[added_player].group_parent = str(mz._uuid) - LOGGER.debug("player %s added to group %s" %(self._players[added_player].name, self._players[str(mz._uuid)].name)) - self.mass.event_loop.create_task(self.mass.player.update_player(self._players[added_player])) + player = self.get_player(added_player) + group_player = self.get_player(str(mz._uuid)) + if player and group_player: + player.group_parent = str(mz._uuid) + LOGGER.debug("player %s added to group %s" %(player.name, group_player.name)) elif removed_player: - if removed_player in self._players: - self._players[removed_player].group_parent = None - LOGGER.debug("player %s removed from group %s" %(self._players[removed_player].name, self._players[str(mz._uuid)].name)) - self.mass.event_loop.create_task(self.mass.player.update_player(self._players[removed_player])) + player = self.get_player(added_player) + group_player = self.get_player(str(mz._uuid)) + if player and group_player: + player.group_parent = None + LOGGER.debug("player %s removed from group %s" %(player.name, group_player.name)) else: for member in mz.members: - if member in self._players: - self._players[member].group_parent = str(mz._uuid) - self.mass.event_loop.create_task(self.mass.player.update_player(self._players[member])) + player = self.get_player(member) + if player: + player.group_parent = str(mz._uuid) @run_periodic(1800) async def __periodic_chromecast_discovery(self): @@ -415,17 +272,15 @@ class ChromecastProvider(PlayerProvider): LOGGER.info("Chromecast discovery started...") # remove any disconnected players... removed_players = [] - for player_id, cast in self._chromecasts.items(): - if not cast.socket_client or not cast.socket_client.is_connected: - LOGGER.info("%s is disconnected" % cast.name) - removed_players.append(player_id) + for player in self.players: + if not player.cc.socket_client or not player.cc.socket_client.is_connected: + LOGGER.info("%s is disconnected" % player.name) + # cleanup cast object + del player.cc + removed_players.append(player.player_id) + # signal removed players for player_id in removed_players: - try: - self._chromecasts[player_id].disconnect() - except Exception: - pass - del self._chromecasts[player_id] - await self.mass.player.remove_player(player_id) + await self.remove_player(player_id) # search for available chromecasts from pychromecast.discovery import start_discovery, stop_discovery def discovered_callback(name): @@ -433,7 +288,7 @@ class ChromecastProvider(PlayerProvider): discovery_info = listener.services[name] ip_address, port, uuid, model_name, friendly_name = discovery_info player_id = str(uuid) - if not player_id in self._chromecasts: + if not self.get_player(player_id): LOGGER.info("discovered chromecast: %s - %s:%s" % (friendly_name, ip_address, port)) asyncio.run_coroutine_threadsafe( self.__chromecast_discovered(player_id, discovery_info), self.mass.event_loop) @@ -451,12 +306,6 @@ class ChromecastProvider(PlayerProvider): except ChromecastConnectionError: LOGGER.warning("Could not connect to device %s" % player_id) return - if not player_id in self._players: - player = MusicPlayer() - player.player_id = player_id - player.name = chromecast.name - player.player_provider = self.prov_id - self._players[player_id] = player # patch the receive message method for handling queue status updates chromecast.media_controller.queue_items = [] chromecast.media_controller.queue_cur_id = None @@ -465,29 +314,24 @@ class ChromecastProvider(PlayerProvider): chromecast.register_status_listener(listenerCast) listenerMedia = StatusMediaListener(chromecast, self.__handle_player_state, self.mass.event_loop) chromecast.media_controller.register_status_listener(listenerMedia) + player = ChromecastPlayer(self.mass, player_id, self.prov_id) if chromecast.cast_type == 'group': - self._players[player_id].is_group = True + player.is_group = True mz = MultizoneController(chromecast.uuid) mz.register_listener(MZListener(mz, self.__handle_group_members_update, self.mass.event_loop)) chromecast.register_handler(mz) chromecast.register_connection_listener(MZConnListener(mz)) chromecast.mz = mz - chromecast.wait() - self._chromecasts[player_id] = chromecast - if not player_id in self._player_queue: - # TODO: persistant storage of player queue ? - self._player_queue[player_id] = [] - self._player_queue_index[player_id] = 0 + player.cc = chromecast + player.cc.wait() + self.add_player(player) self.update_all_group_members() - # turn on player if it was previously turned on - if self._players[player_id].powered: - self.mass.event_loop.create_task(self.mass.player.player_command(player_id, "power", "on")) def update_all_group_members(self): ''' force member update of all cast groups ''' - for cast in list(self._chromecasts.values()): - if cast.cast_type == 'group': - cast.mz.update_members() + for player in self.players: + if player.cc.cast_type == 'group': + player.cc.mz.update_members() def chunks(l, n): """Yield successive n-sized chunks from l.""" diff --git a/music_assistant/modules/playerproviders/lms.py b/music_assistant/modules/playerproviders/lms.py index 5eb166ad..13db3f78 100644 --- a/music_assistant/modules/playerproviders/lms.py +++ b/music_assistant/modules/playerproviders/lms.py @@ -126,10 +126,6 @@ class LMSProvider(PlayerProvider): items.append(track) return items - async def player_queue_index(self, player_id): - ''' get current index of the player's queue ''' - raise NotImplementedError() - ### Provider specific (helper) methods ##### async def __get_players(self): diff --git a/music_assistant/modules/playerproviders/pylms.py b/music_assistant/modules/playerproviders/pylms.py index 15c06088..8c67b9b9 100644 --- a/music_assistant/modules/playerproviders/pylms.py +++ b/music_assistant/modules/playerproviders/pylms.py @@ -37,12 +37,8 @@ class PyLMSServer(PlayerProvider): def __init__(self, mass): self.prov_id = 'pylms' self.name = 'Logitech Media Server Emulation' - self.icon = '' self.mass = mass - self._players = {} self._lmsplayers = {} - self._player_queue = {} - self._player_queue_index = {} self.buffer = b'' self.last_msg_received = 0 self.supported_musicproviders = ['http'] @@ -96,59 +92,52 @@ class PyLMSServer(PlayerProvider): self._lmsplayers[player_id].unmute() elif cmd == 'mute': self._lmsplayers[player_id].mute() - - async def player_queue(self, player_id, offset=0, limit=50): - ''' return the current items in the player's queue ''' - return self._player_queue[player_id][offset:limit] - - async def player_queue_index(self, player_id): - ''' get current index of the player's queue ''' - return self._player_queue_index.get(player_id, 0) async def play_media(self, player_id, media_items, queue_opt='play'): ''' play media on a player ''' - cur_queue_index = self._player_queue_index.get(player_id, 0) + player = self.get_player(player_id) + cur_index = player.cur_queue_index - if queue_opt == 'replace' or not self._player_queue[player_id]: + if queue_opt == 'replace' or not player.queue: # overwrite queue with new items - self._player_queue[player_id] = media_items + player.queue = media_items await self.__queue_play(player_id, 0, send_flush=True) elif queue_opt == 'play': # replace current item with new item(s) - self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index] + media_items + self._player_queue[player_id][cur_queue_index+1:] - await self.__queue_play(player_id, cur_queue_index, send_flush=True) + player.queue = player.queue[player_id][:cur_index] + media_items + player.queue[player_id][cur_index+1:] + await self.__queue_play(player_id, cur_index, send_flush=True) elif queue_opt == 'next': # insert new items at current index +1 - self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index+1] + media_items + self._player_queue[player_id][cur_queue_index+1:] + player.queue[player_id] = player.queue[player_id][:cur_index+1] + media_items + player.queue[player_id][cur_index+1:] elif queue_opt == 'add': # add new items at end of queue - self._player_queue[player_id] = self._player_queue[player_id] + media_items + player.queue[player_id] = player.queue[player_id] + media_items ### Provider specific (helper) methods ##### async def __queue_play(self, player_id, index, send_flush=False): ''' send play command to player ''' - if not player_id in self._player_queue or not player_id in self._player_queue_index: + if not player_id in player.queue or not player_id in player.queue_index: return - if not self._player_queue[player_id]: + if not player.queue[player_id]: return if index == None: - index = self._player_queue_index[player_id] - if len(self._player_queue[player_id]) >= index: - track = self._player_queue[player_id][index] + index = player.queue_index[player_id] + if len(player.queue[player_id]) >= index: + track = player.queue[player_id][index] if send_flush: self._lmsplayers[player_id].flush() self._lmsplayers[player_id].play(track.uri) - self._player_queue_index[player_id] = index + player.queue_index[player_id] = index async def __queue_next(self, player_id): ''' request next track from queue ''' - if not player_id in self._player_queue or not player_id in self._player_queue: + if not player_id in player.queue or not player_id in player.queue: return - cur_queue_index = self._player_queue_index[player_id] - if len(self._player_queue[player_id]) > cur_queue_index: + cur_queue_index = player.queue_index[player_id] + if len(player.queue[player_id]) > cur_queue_index: new_queue_index = cur_queue_index + 1 elif self._players[player_id].repeat_enabled: new_queue_index = 0 @@ -159,16 +148,16 @@ class PyLMSServer(PlayerProvider): async def __queue_previous(self, player_id): ''' request previous track from queue ''' - if not player_id in self._player_queue: + if not player_id in player.queue: return - cur_queue_index = self._player_queue_index[player_id] - if cur_queue_index == 0 and len(self._player_queue[player_id]) > 1: - new_queue_index = len(self._player_queue[player_id]) -1 + cur_queue_index = player.queue_index[player_id] + if cur_queue_index == 0 and len(player.queue[player_id]) > 1: + new_queue_index = len(player.queue[player_id]) -1 elif cur_queue_index == 0: new_queue_index = cur_queue_index else: new_queue_index -= 1 - self._player_queue_index[player_id] = new_queue_index + player.queue_index[player_id] = new_queue_index return await self.__queue_play(player_id, new_queue_index) async def __handle_player_event(self, player_id, event, event_data=None): @@ -179,15 +168,16 @@ class PyLMSServer(PlayerProvider): lms_player = self._lmsplayers[player_id] if event == "next_track": return await self.__queue_next(player_id) + player if not player_id in self._players: player = MusicPlayer() player.player_id = player_id player.player_provider = self.prov_id self._players[player_id] = player - if not player_id in self._player_queue: - self._player_queue[player_id] = [] - if not player_id in self._player_queue_index: - self._player_queue_index[player_id] = 0 + if not player_id in player.queue: + player.queue[player_id] = [] + if not player_id in player.queue_index: + player.queue_index[player_id] = 0 else: player = self._players[player_id] # update player properties @@ -200,9 +190,9 @@ class PyLMSServer(PlayerProvider): player.powered = event_data elif event == "state": player.state = event_data - if self._player_queue[player_id]: - cur_queue_index = self._player_queue_index[player_id] - player.cur_item = self._player_queue[player_id][cur_queue_index] + if player.queue[player_id]: + cur_queue_index = player.queue_index[player_id] + player.cur_item = player.queue[player_id][cur_queue_index] # update player details await self.mass.player.update_player(player) diff --git a/music_assistant/modules/web.py b/music_assistant/modules/web.py index 7701d7f1..6761d6cd 100755 --- a/music_assistant/modules/web.py +++ b/music_assistant/modules/web.py @@ -69,9 +69,9 @@ class Web(): app.add_routes([web.get('/jsonrpc.js', self.json_rpc)]) app.add_routes([web.post('/jsonrpc.js', self.json_rpc)]) app.add_routes([web.get('/ws', self.websocket_handler)]) - app.add_routes([web.get('/stream_track', self.mass.http_streamer.stream_track)]) - app.add_routes([web.get('/stream_radio', self.mass.http_streamer.stream_radio)]) - app.add_routes([web.get('/stream_queue', self.mass.http_streamer.stream_queue)]) + # app.add_routes([web.get('/stream_track', self.mass.http_streamer.stream_track)]) + # app.add_routes([web.get('/stream_radio', self.mass.http_streamer.stream_radio)]) + app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.stream_queue)]) app.add_routes([web.get('/api/search', self.search)]) app.add_routes([web.get('/api/config', self.get_config)]) app.add_routes([web.post('/api/config', self.save_config)]) @@ -186,10 +186,21 @@ class Web(): async def player_command(self, request): ''' issue player command''' + result = False player_id = request.match_info.get('player_id') - cmd = request.match_info.get('cmd') - cmd_args = request.match_info.get('cmd_args') - result = await self.mass.player.player_command(player_id, cmd, cmd_args) + player = await self.mass.player.get_player(player_id) + if player: + cmd = request.match_info.get('cmd') + cmd_args = request.match_info.get('cmd_args') + player_cmd = getattr(player, cmd, None) + if player_cmd and cmd_args: + result = await player_cmd(player_id, cmd, cmd_args) + elif player_cmd and cmd_args: + result = await player_cmd(player_id, cmd, cmd_args) + else: + LOGGER.error("Received non-existing command %s for player %s" %(cmd, player.name)) + else: + LOGGER.error("Received command dor non-existing player %s" %(player_id)) return web.json_response(result, dumps=json_serializer) async def play_media(self, request): diff --git a/music_assistant/utils.py b/music_assistant/utils.py index 971037fa..e40de73d 100755 --- a/music_assistant/utils.py +++ b/music_assistant/utils.py @@ -68,7 +68,13 @@ def try_parse_float(possible_float): try: return float(possible_float) except: - return 0 + return 0.0 + +def try_parse_bool(possible_bool): + if isinstance(possible_bool, bool): + return possible_bool + else: + return possible_bool in ['true', 'True', '1', 'on', 'ON', 1] def parse_track_title(track_title): ''' try to parse clean track title and version from the title ''' -- 2.34.1