From b3a3207be393657b05bba4783e320b490cba281f Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 17 Sep 2020 17:47:45 +0200 Subject: [PATCH] bugfixes various small bugfixes --- music_assistant/__main__.py | 2 +- music_assistant/config.py | 19 ++-- music_assistant/http_streamer.py | 66 ++++++----- music_assistant/mass.py | 6 +- music_assistant/models/player_queue.py | 104 +++++++++++------- music_assistant/music_manager.py | 20 ++-- music_assistant/player_manager.py | 7 -- .../providers/demo/demo_playerprovider.py | 17 +-- music_assistant/providers/qobuz/__init__.py | 7 +- music_assistant/utils.py | 15 +++ music_assistant/web.py | 7 +- 11 files changed, 159 insertions(+), 111 deletions(-) diff --git a/music_assistant/__main__.py b/music_assistant/__main__.py index 3e7f1637..b8be5fd7 100755 --- a/music_assistant/__main__.py +++ b/music_assistant/__main__.py @@ -64,7 +64,7 @@ def main(): mass.async_start(), use_uvloop=True, shutdown_callback=on_shutdown, - executor_workers=32, + executor_workers=64, ) diff --git a/music_assistant/config.py b/music_assistant/config.py index fb2072cb..4362fc81 100755 --- a/music_assistant/config.py +++ b/music_assistant/config.py @@ -7,8 +7,6 @@ from collections import OrderedDict from enum import Enum from typing import List -from cryptography.fernet import Fernet, InvalidToken -from music_assistant.app_vars import get_app_var # noqa # pylint: disable=all from music_assistant.constants import ( CONF_CROSSFADE_DURATION, CONF_ENABLED, @@ -20,7 +18,13 @@ from music_assistant.constants import ( EVENT_CONFIG_CHANGED, ) from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType -from music_assistant.utils import get_external_ip, json, try_load_json_file +from music_assistant.utils import ( + decrypt_string, + encrypt_string, + get_external_ip, + json, + try_load_json_file, +) from passlib.hash import pbkdf2_sha256 LOGGER = logging.getLogger("mass") @@ -200,10 +204,9 @@ class ConfigItem: entry = self.get_entry(key) if entry.entry_type == ConfigEntryType.PASSWORD: # decrypted password is only returned if explicitly asked for this key - try: - return Fernet(get_app_var(3)).decrypt(entry.value.encode()).decode() - except InvalidToken: - pass + decrypted_value = decrypt_string(entry.value) + if decrypted_value: + return decrypted_value return entry.value def __setitem__(self, key, value): @@ -237,7 +240,7 @@ class ConfigItem: if entry.store_hashed: value = pbkdf2_sha256.hash(value) if entry.entry_type == ConfigEntryType.PASSWORD: - value = Fernet(get_app_var(3)).encrypt(value.encode()).decode() + value = encrypt_string(value) self.stored_config[key] = value self.mass.signal_event( EVENT_CONFIG_CHANGED, (self._base_type, self._parent_item_key) diff --git a/music_assistant/http_streamer.py b/music_assistant/http_streamer.py index e972fbb6..f7b6bf79 100755 --- a/music_assistant/http_streamer.py +++ b/music_assistant/http_streamer.py @@ -8,9 +8,7 @@ import asyncio import gc import io import logging -import os import shlex -import signal import subprocess import threading import urllib @@ -24,7 +22,7 @@ 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.utils import create_tempfile, decrypt_string, get_ip, try_parse_int from music_assistant.web import require_local_subnet LOGGER = logging.getLogger("mass") @@ -121,6 +119,7 @@ class HTTPStreamer: asyncio.CancelledError, aiohttp.ClientConnectionError, asyncio.TimeoutError, + Exception, ) as exc: cancelled.set() raise exc # re-raise @@ -172,7 +171,7 @@ class HTTPStreamer: player_queue = self.mass.player_manager.get_player_queue(player_id) sample_rate = try_parse_int(player_conf["max_sample_rate"]) fade_length = try_parse_int(player_conf["crossfade_duration"]) - if not sample_rate or sample_rate < 44100 or sample_rate > 384000: + if not sample_rate or sample_rate < 44100: sample_rate = 96000 if fade_length: fade_bytes = int(sample_rate * 4 * 2 * fade_length) @@ -362,10 +361,9 @@ class HTTPStreamer: sox_proc.stdin.write(last_fadeout_data) del last_fadeout_data # END OF QUEUE STREAM - sox_proc.stdin.close() sox_proc.terminate() + sox_proc.communicate() fill_buffer_thread.join() - del sox_proc # run garbage collect manually to avoid too much memory fragmentation gc.collect() if cancelled.is_set(): @@ -377,7 +375,6 @@ class HTTPStreamer: self, player_id, queue_item, cancelled, chunksize=128000, resample=None ): """Get audio stream from provider and apply additional effects/processing if needed.""" - # pylint: disable=subprocess-popen-preexec-fn streamdetails = self.mass.add_job( self.mass.music_manager.async_get_stream_details(queue_item, player_id) ).result() @@ -396,52 +393,39 @@ class HTTPStreamer: if streamdetails.content_type == ContentType.AAC: # support for AAC created with ffmpeg in between args = 'ffmpeg -v quiet -i "%s" -f flac - | sox -t flac - -t %s - %s' % ( - streamdetails.path, + decrypt_string(streamdetails.path), outputfmt, sox_options, ) process = subprocess.Popen( - args, - shell=True, - stdout=subprocess.PIPE, - bufsize=chunksize, - preexec_fn=os.setsid, + args, shell=True, stdout=subprocess.PIPE, bufsize=chunksize ) elif streamdetails.type in [StreamType.URL, StreamType.FILE]: args = 'sox -t %s "%s" -t %s - %s' % ( streamdetails.content_type.name, - streamdetails.path, + decrypt_string(streamdetails.path), outputfmt, sox_options, ) args = shlex.split(args) process = subprocess.Popen( - args, - shell=False, - stdout=subprocess.PIPE, - bufsize=chunksize, - preexec_fn=os.setsid, + args, shell=False, stdout=subprocess.PIPE, bufsize=chunksize ) elif streamdetails.type == StreamType.EXECUTABLE: args = "%s | sox -t %s - -t %s - %s" % ( - streamdetails.path, + decrypt_string(streamdetails.path), streamdetails.content_type.name, outputfmt, sox_options, ) process = subprocess.Popen( - args, - shell=True, - stdout=subprocess.PIPE, - bufsize=chunksize, - preexec_fn=os.setsid, + args, shell=True, stdout=subprocess.PIPE, bufsize=chunksize ) else: LOGGER.warning("no streaming options for %s", queue_item.name) yield (True, b"") return # fire event that streaming has started for this track - streamdetails.path = "" # invalidate self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails) # yield chunks from stdout # we keep 1 chunk behind to detect end of stream properly @@ -450,11 +434,16 @@ class HTTPStreamer: if cancelled.is_set(): # http session ended # send terminate and pick up left over bytes - # process.terminate() - os.killpg(os.getpgid(process.pid), signal.SIGHUP) - os.killpg(os.getpgid(process.pid), signal.SIGTERM) - # read exactly chunksize of data - chunk = process.stdout.read(chunksize) + process.terminate() + chunk, _ = process.communicate() + LOGGER.warning( + "__get_audio_stream cancelled for track %s on player %s", + queue_item.name, + player_id, + ) + else: + # read exactly chunksize of data + chunk = process.stdout.read(chunksize) if len(chunk) < chunksize: # last chunk yield (True, prev_chunk + chunk) @@ -469,6 +458,11 @@ class HTTPStreamer: # send task to background to analyse the audio if queue_item.media_type == MediaType.Track: self.mass.add_job(self.__analyze_audio, streamdetails) + LOGGER.warning( + "__get_audio_stream complete for track %s on player %s", + queue_item.name, + player_id, + ) def __get_player_sox_options( self, player_id: str, streamdetails: StreamDetails @@ -508,11 +502,15 @@ class HTTPStreamer: # only when needed we do the analyze stuff LOGGER.debug("Start analyzing track %s", item_key) if streamdetails.type == StreamType.URL: - audio_data = urllib.request.urlopen(streamdetails.path).read() + audio_data = urllib.request.urlopen( + decrypt_string(streamdetails.path) + ).read() elif streamdetails.type == StreamType.EXECUTABLE: - audio_data = subprocess.check_output(streamdetails.path, shell=True) + audio_data = subprocess.check_output( + decrypt_string(streamdetails.path), shell=True + ) elif streamdetails.type == StreamType.FILE: - with open(streamdetails.path, "rb") as _file: + with open(decrypt_string(streamdetails.path), "rb") as _file: audio_data = _file.read() # calculate BS.1770 R128 integrated loudness with io.BytesIO(audio_data) as tmpfile: diff --git a/music_assistant/mass.py b/music_assistant/mass.py index bebe017b..c512da58 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -104,10 +104,10 @@ class MusicAssistant: if self.config.providers[provider.id][CONF_ENABLED]: if await provider.async_on_start(): provider.available = True - LOGGER.debug("New provider registered: %s", provider.name) + LOGGER.debug("Provider registered: %s", provider.name) self.signal_event(EVENT_PROVIDER_REGISTERED, provider.id) else: - LOGGER.debug("Not loading provider %s as it is disabled:", provider.name) + LOGGER.debug("Not loading provider %s as it is disabled", provider.name) async def register_provider(self, provider: Provider): """Register a new Provider/Plugin.""" @@ -161,7 +161,7 @@ class MusicAssistant: except Exception as exc: LOGGER.exception("Error preloading module %s: %s", module_name, exc) else: - LOGGER.info("Successfully preloaded module %s", module_name) + LOGGER.debug("Successfully preloaded module %s", module_name) @callback def signal_event(self, event_msg: str, event_details: Any = None): diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index 5bfbb2e3..bf36b371 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -63,10 +63,10 @@ class PlayerQueue: self._repeat_enabled = False self._cur_index = 0 self._cur_item_time = 0 - self._last_item_time = 0 - self._last_queue_startindex = 0 + self._last_item = None self._next_queue_startindex = 0 - self._last_track = None + self._last_queue_startindex = 0 + self._last_player_state = PlayerState.Stopped # load previous queue settings from disk self.mass.add_job(self.__async_restore_saved_state()) @@ -206,7 +206,7 @@ class PlayerQueue: Indicate that we need to use the queue stream. For example if crossfading is requested but a player doesn't natively support it - it will send a constant stream of audio to the player with all tracks. + we will send a constant stream of audio to the player with all tracks. """ supports_crossfade = PlayerFeature.CROSSFADE in self.player.features supports_queue = PlayerFeature.QUEUE in self.player.features @@ -281,9 +281,13 @@ class PlayerQueue: return if self.use_queue_stream: self._next_queue_startindex = index - queue_stream_uri = "%s/stream/%s" % ( + self.player.elapsed_time = 0 # set just in case of a race condition + queue_stream_uri = "%s/stream/%s?id=%s" % ( self.mass.web.internal_url, self.player.player_id, + self.items[ + index + ].queue_item_id, # just set to invalidate any cache stuff ) return await player_prov.async_cmd_play_uri( self.player_id, queue_stream_uri @@ -299,7 +303,9 @@ class PlayerQueue: "cmd_queue_insert not supported by player, fallback to cmd_queue_load " ) self._items = self._items[index:] - await player_prov.async_cmd_queue_load(self.player_id, self._items) + return await player_prov.async_cmd_queue_load( + self.player_id, self._items + ) else: return await player_prov.async_cmd_play_uri( self.player_id, self._items[index].uri @@ -369,9 +375,19 @@ class PlayerQueue: item.sort_index = insert_at_index + index if self.shuffle_enabled: queue_items = self.__shuffle_items(queue_items) - self._items = ( - self._items[:insert_at_index] + queue_items + self._items[insert_at_index:] - ) + if offset == 0: + # replace current item with new + self._items = ( + self._items[:insert_at_index] + + queue_items + + self._items[insert_at_index + 1 :] + ) + else: + self._items = ( + self._items[:insert_at_index] + + queue_items + + self._items[insert_at_index:] + ) if self.use_queue_stream or not supports_queue: if offset == 0: await self.async_play_index(insert_at_index) @@ -388,7 +404,9 @@ class PlayerQueue: "cmd_queue_insert not supported by player, fallback to cmd_queue_load " ) self._items = self._items[self.cur_index :] - await player_prov.async_cmd_queue_load(self.player_id, self._items) + return await player_prov.async_cmd_queue_load( + self.player_id, self._items + ) self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self.to_dict()) self.mass.add_job(self.__async_save_state()) @@ -399,9 +417,9 @@ class PlayerQueue: item.sort_index = len(self.items) + index if self.shuffle_enabled: played_items = self.items[: self.cur_index] - next_items = self.items[self.cur_index :] + queue_items + next_items = self.items[self.cur_index + 1 :] + queue_items next_items = self.__shuffle_items(next_items) - items = played_items + next_items + items = played_items + [self.cur_item] + next_items return await self.async_update(items) self._items = self._items + queue_items if supports_queue and not self.use_queue_stream: @@ -415,7 +433,9 @@ class PlayerQueue: "cmd_queue_append not supported by player, fallback to cmd_queue_load " ) self._items = self._items[self.cur_index :] - await player_prov.async_cmd_queue_load(self.player_id, self._items) + return await player_prov.async_cmd_queue_load( + self.player_id, self._items + ) self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self.to_dict()) self.mass.add_job(self.__async_save_state()) @@ -434,7 +454,9 @@ class PlayerQueue: "cmd_queue_update not supported by player, fallback to cmd_queue_load " ) self._items = self._items[self.cur_index :] - await player_prov.async_cmd_queue_load(self.player_id, self._items) + return await player_prov.async_cmd_queue_load( + self.player_id, self._items + ) self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self.to_dict()) self.mass.add_job(self.__async_save_state()) @@ -459,24 +481,48 @@ class PlayerQueue: async def async_update_state(self): """Update queue details, called when player updates.""" - cur_index = self._cur_index + new_index = self._cur_index track_time = self._cur_item_time # handle queue stream - if self.use_queue_stream and self.player.state == PlayerState.Playing: - cur_index, track_time = self.__get_queue_stream_index() + if ( + self.use_queue_stream + and self.player.state == PlayerState.Playing + and self.player.elapsed_time > 1 + ): + new_index, track_time = self.__get_queue_stream_index() # normal queue based approach elif not self.use_queue_stream: track_time = self.player.elapsed_time for index, queue_item in enumerate(self.items): if queue_item.uri == self.player.current_uri: - cur_index = index + new_index = index break # process new index - await self.async_process_queue_update(cur_index, track_time) + if self._cur_index != new_index: + # queue track updated + self._next_queue_startindex = self.next_index + self._cur_index = new_index + # check if a new track is loaded, wait for the streamdetails + if self._last_item != self.cur_item and self.cur_item.streamdetails: + # new active item in queue + self.mass.signal_event(EVENT_QUEUE_UPDATED, self.to_dict()) + # invalidate previous streamdetails + if self._last_item: + self._last_item.streamdetails = None + self._last_item = self.cur_item + # update vars + if self._cur_item_time != track_time: + self._cur_item_time = track_time + self.mass.signal_event( + EVENT_QUEUE_TIME_UPDATED, + {"player_id": self.player_id, "cur_item_time": track_time}, + ) async def async_start_queue_stream(self): """Call when queue_streamer starts playing the queue stream.""" self._last_queue_startindex = self._next_queue_startindex + + self._cur_item_time = 0 return self.get_item(self._next_queue_startindex) def to_dict(self): @@ -517,28 +563,8 @@ class PlayerQueue: else: track_time = elapsed_time_queue - total_time break - self._next_queue_startindex = queue_index + 1 return queue_index, track_time - async def async_process_queue_update(self, new_index, track_time): - """Compare the queue index to determine if playback changed.""" - new_track = self.get_item(new_index) - self._cur_item_time = track_time - self._cur_index = new_index - if self._last_track != new_track: - # queue track updated - self._last_track = new_track - self.mass.signal_event(EVENT_QUEUE_UPDATED, self.to_dict()) - if self._last_track: - self._last_track.streamdetails = None # invalidate streamdetails - # update vars - if self._last_item_time != track_time: - self._last_item_time = track_time - self.mass.signal_event( - EVENT_QUEUE_TIME_UPDATED, - {"player_id": self.player_id, "cur_item_time": track_time}, - ) - @staticmethod def __shuffle_items(queue_items): """Shuffle a list of tracks.""" diff --git a/music_assistant/music_manager.py b/music_assistant/music_manager.py index ab450255..6f773db5 100755 --- a/music_assistant/music_manager.py +++ b/music_assistant/music_manager.py @@ -25,7 +25,7 @@ from music_assistant.models.media_types import ( from music_assistant.models.musicprovider import MusicProvider from music_assistant.models.provider import ProviderType from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType -from music_assistant.utils import compare_strings, run_periodic +from music_assistant.utils import compare_strings, encrypt_string, run_periodic from PIL import Image LOGGER = logging.getLogger("mass") @@ -1095,7 +1095,7 @@ class MusicManager: full_track = media_item else: full_track = await self.async_get_track( - media_item.item_id, media_item.provider, lazy=True, refresh=True + media_item.item_id, media_item.provider, lazy=True, refresh=False ) # sort by quality and check track availability for prov_media in sorted( @@ -1109,12 +1109,18 @@ class MusicManager: streamdetails = await music_prov.async_get_stream_details( prov_media.item_id ) + if streamdetails: + break - if streamdetails: - streamdetails.player_id = player_id - # set streamdetails as attribute on the media_item - media_item.streamdetails = streamdetails - return streamdetails + if streamdetails: + # set player_id on the streamdetails so we know what players stream + streamdetails.player_id = player_id + # store the path encrypted as we do not want it to be visible in the api + streamdetails.path = encrypt_string(streamdetails.path) + # set streamdetails as attribute on the media_item + # this way the app knows what content is playing + media_item.streamdetails = streamdetails + return streamdetails return None ################ Library synchronization logic ################ diff --git a/music_assistant/player_manager.py b/music_assistant/player_manager.py index 938e41f5..dba38674 100755 --- a/music_assistant/player_manager.py +++ b/music_assistant/player_manager.py @@ -511,13 +511,6 @@ class PlayerManager: else: gain_correct = target_gain - track_loudness gain_correct = round(gain_correct, 2) - LOGGER.debug( - "Loudness level for track %s/%s is %s - calculated replayGain is %s", - provider_id, - item_id, - track_loudness, - gain_correct, - ) return gain_correct async def __async_create_player_state(self, player: Player): diff --git a/music_assistant/providers/demo/demo_playerprovider.py b/music_assistant/providers/demo/demo_playerprovider.py index 082de6e0..33fe60f8 100644 --- a/music_assistant/providers/demo/demo_playerprovider.py +++ b/music_assistant/providers/demo/demo_playerprovider.py @@ -1,5 +1,6 @@ """Demo/test providers.""" import asyncio +import logging import signal import subprocess from typing import List @@ -10,6 +11,7 @@ from music_assistant.models.playerprovider import PlayerProvider PROV_ID = "demo_player" PROV_NAME = "Demo/Test players" +LOGGER = logging.getLogger(PROV_ID) class DemoPlayerProvider(PlayerProvider): @@ -18,6 +20,7 @@ class DemoPlayerProvider(PlayerProvider): def __init__(self, *args, **kwargs): """Initialize.""" self._players = {} + self._progress_tasks = {} super().__init__(*args, **kwargs) @property @@ -102,27 +105,27 @@ class DemoPlayerProvider(PlayerProvider): if player.sox: await self.async_cmd_stop(player_id) player.current_uri = uri - player.sox = subprocess.Popen(["play", uri]) + player.sox = subprocess.Popen(["play", "-q", uri]) player.state = PlayerState.Playing player.powered = True self.mass.add_job(self.mass.player_manager.async_update_player(player)) async def report_progress(): """Report fake progress while sox is playing.""" + LOGGER.info("Playback started on player %s", player_id) player.elapsed_time = 0 - while ( - player.state == PlayerState.Playing - and player.sox - and not player.sox.poll() - ): + while player.sox and not player.sox.poll(): await asyncio.sleep(1) player.elapsed_time += 1 self.mass.add_job(self.mass.player_manager.async_update_player(player)) + LOGGER.info("Playback stopped on player %s", player_id) player.elapsed_time = 0 player.state = PlayerState.Stopped self.mass.add_job(self.mass.player_manager.async_update_player(player)) - self.mass.add_job(report_progress) + if self._progress_tasks.get(player_id): + self._progress_tasks[player_id].cancel() + self._progress_tasks[player_id] = self.mass.add_job(report_progress) async def async_cmd_stop(self, player_id: str) -> None: """ diff --git a/music_assistant/providers/qobuz/__init__.py b/music_assistant/providers/qobuz/__init__.py index 3699477f..9e803225 100644 --- a/music_assistant/providers/qobuz/__init__.py +++ b/music_assistant/providers/qobuz/__init__.py @@ -94,8 +94,9 @@ class QobuzProvider(MusicProvider): self.__user_auth_info = None self.__logged_in = False self._throttler = Throttler(rate_limit=4, period=1) - self.mass.add_event_listener(self.async_mass_event, EVENT_STREAM_STARTED) - self.mass.add_event_listener(self.async_mass_event, EVENT_STREAM_ENDED) + self.mass.add_event_listener( + self.async_mass_event, [EVENT_STREAM_STARTED, EVENT_STREAM_ENDED] + ) return True async def async_search( @@ -369,7 +370,7 @@ class QobuzProvider(MusicProvider): if not self.__user_auth_info: return # TODO: need to figure out if the streamed track is purchased by user - if msg == EVENT_STREAM_STARTED and msg_details["provider"] == PROV_ID: + if msg == EVENT_STREAM_STARTED and msg_details.provider == PROV_ID: # report streaming started to qobuz device_id = self.__user_auth_info["user"]["device"]["id"] credential_id = self.__user_auth_info["user"]["credential"]["id"] diff --git a/music_assistant/utils.py b/music_assistant/utils.py index 691fb205..5f3fdd7b 100755 --- a/music_assistant/utils.py +++ b/music_assistant/utils.py @@ -14,6 +14,8 @@ from typing import Any, Callable, TypeVar import memory_tempfile import unidecode +from cryptography.fernet import Fernet, InvalidToken +from music_assistant.app_vars import get_app_var # noqa # pylint: disable=all try: import simplejson as json @@ -298,6 +300,19 @@ def create_tempfile(): return tempfile.NamedTemporaryFile(buffering=0) +def encrypt_string(str_value): + """Encrypt a string with Fernet.""" + return Fernet(get_app_var(3)).encrypt(str_value.encode()).decode() + + +def decrypt_string(str_value): + """Decrypt a string with Fernet.""" + try: + return Fernet(get_app_var(3)).decrypt(str_value.encode()).decode() + except InvalidToken: + return None + + class CustomIntEnum(int, Enum): """Base for IntEnum with some helpers.""" diff --git a/music_assistant/web.py b/music_assistant/web.py index 7bdb162b..04cfb03c 100755 --- a/music_assistant/web.py +++ b/music_assistant/web.py @@ -612,7 +612,10 @@ class Web: player_id = request.match_info.get("player_id") player_queue = self.mass.player_manager.get_player_queue(player_id) cmd = request.match_info.get("cmd") - cmd_args = await request.json() + try: + cmd_args = await request.json() + except json.decoder.JSONDecodeError: + cmd_args = None if cmd == "repeat_enabled": player_queue.repeat_enabled = cmd_args elif cmd == "shuffle_enabled": @@ -627,7 +630,7 @@ class Web: await player_queue.async_move_item(cmd_args, 1) elif cmd == "next": await player_queue.async_move_item(cmd_args, 0) - return web.json_response(player_queue, dumps=json_serializer) + return web.json_response(player_queue.to_dict(), dumps=json_serializer) @login_required @routes.get("/api/players/{player_id}") -- 2.34.1