From: Marcel van der Veldt Date: Fri, 9 Feb 2024 08:36:02 +0000 (+0100) Subject: Reconfigure linting,testing and formatting (#1070) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=421c77ba5dd608c07a4fb080463d2f439abac8e1;p=music-assistant-server.git Reconfigure linting,testing and formatting (#1070) --- diff --git a/.github/workflows/pre-commit-updater.yml b/.github/workflows/pre-commit-updater.yml deleted file mode 100644 index e1a1838d..00000000 --- a/.github/workflows/pre-commit-updater.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Pre-commit auto-update -on: - schedule: - - cron: '0 0 * * *' -jobs: - auto-update: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5.0.0 - with: - python-version: '3.10' - - name: Install pre-commit - run: pip install pre-commit - - name: Run pre-commit autoupdate - run: pre-commit autoupdate - - name: Create Pull Request - uses: peter-evans/create-pull-request@v6.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - branch: update/pre-commit-autoupdate - title: Auto-update pre-commit hooks - commit-message: Auto-update pre-commit hooks - body: | - Update versions of tools in pre-commit - configs to latest version - labels: dependencies diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 763cd50a..2002eb64 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,57 +1,107 @@ repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + - repo: local hooks: - - id: check-yaml + - id: ruff-check + name: 🐶 Ruff Linter + language: system + types: [python] + entry: scripts/run-in-env.sh ruff check --fix + require_serial: true + stages: [commit, push, manual] + - id: ruff-format + name: 🐶 Ruff Formatter + language: system + types: [python] + entry: scripts/run-in-env.sh ruff format + require_serial: true + stages: [commit, push, manual] + - id: check-ast + name: 🐍 Check Python AST + language: system + types: [python] + entry: scripts/run-in-env.sh check-ast + - id: check-case-conflict + name: 🔠 Check for case conflicts + language: system + entry: scripts/run-in-env.sh check-case-conflict + - id: check-docstring-first + name: ℹ️ Check docstring is first + language: system + types: [python] + entry: scripts/run-in-env.sh check-docstring-first + - id: check-executables-have-shebangs + name: 🧐 Check that executables have shebangs + language: system + types: [text, executable] + entry: scripts/run-in-env.sh check-executables-have-shebangs + stages: [commit, push, manual] + - id: check-json + name: { Check JSON files + language: system + types: [json] + entry: scripts/run-in-env.sh check-json + files: ^(music_assistant/.+/manifest\.json)$ + - id: check-merge-conflict + name: 💥 Check for merge conflicts + language: system + types: [text] + entry: scripts/run-in-env.sh check-merge-conflict + - id: check-symlinks + name: 🔗 Check for broken symlinks + language: system + types: [symlink] + entry: scripts/run-in-env.sh check-symlinks + - id: check-toml + name: ✅ Check TOML files + language: system + types: [toml] + entry: scripts/run-in-env.sh check-toml + - id: codespell + name: ✅ Check code for common misspellings + language: system + types: [text] + entry: scripts/run-in-env.sh codespell + - id: detect-private-key + name: 🕵️ Detect Private Keys + language: system + types: [text] + entry: scripts/run-in-env.sh detect-private-key - id: end-of-file-fixer - - id: trailing-whitespace + name: ⮐ Fix End of Files + language: system + types: [text] + entry: scripts/run-in-env.sh end-of-file-fixer + stages: [commit, push, manual] - id: no-commit-to-branch + name: 🛑 Don't commit to main branch + language: system + entry: scripts/run-in-env.sh no-commit-to-branch + pass_filenames: false + always_run: true args: - --branch=main - - id: debug-statements - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: 'v0.2.1' - hooks: - - id: ruff - - repo: https://github.com/psf/black - rev: 24.1.1 - hooks: - - id: black - args: - - --safe - - --quiet - - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 - hooks: - - id: codespell - args: [] - exclude_types: [csv, json] - exclude: ^tests/fixtures/ - additional_dependencies: - - tomli - - # - repo: local - # hooks: - # - id: pylint - # name: pylint - # entry: script/run-in-env.sh pylint -j 0 - # language: script - # types: [python] - # files: ^music_assistant/.+\.py$ - - # - id: mypy - # name: mypy - # entry: script/run-in-env.sh mypy - # language: script - # types: [python] - # files: ^music_assistant/.+\.py$ - - - repo: local - hooks: + # - id: pylint + # name: 🌟 Starring code with pylint + # language: system + # types: [python] + # entry: scripts/run-in-env.sh pylint + # - id: trailing-whitespace + # name: ✄ Trim Trailing Whitespace + # language: system + # types: [text] + # entry: scripts/run-in-env.sh trailing-whitespace-fixer + # stages: [commit, push, manual] + # - id: mypy + # name: mypy + # entry: scripts/run-in-env.sh mypy + # language: script + # types: [python] + # require_serial: true + # files: ^(music_assistant|pylint)/.+\.py$ - id: gen_requirements_all name: gen_requirements_all - entry: script/run-in-env.sh python3 -m script.gen_requirements_all + entry: scripts/run-in-env.sh python3 -m scripts.gen_requirements_all pass_filenames: false language: script types: [text] - files: ^(music_assistant/.+/manifest\.json|pyproject\.toml|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$ + files: ^(music_assistant/.+/manifest\.json|pyproject\.toml|\.pre-commit-config\.yaml|scripts/gen_requirements_all\.py)$ diff --git a/music_assistant/__main__.py b/music_assistant/__main__.py index 6ba281a7..3a4b0f49 100644 --- a/music_assistant/__main__.py +++ b/music_assistant/__main__.py @@ -50,8 +50,7 @@ def get_arguments(): "default=info, possible=(critical, error, warning, info, debug)", ) parser.add_argument("-u", "--enable-uvloop", action="store_true") - arguments = parser.parse_args() - return arguments + return parser.parse_args() def setup_logger(data_path: str, level: str = "DEBUG"): @@ -109,7 +108,8 @@ def setup_logger(data_path: str, level: str = "DEBUG"): logging.getLogger("charset_normalizer").setLevel(logging.WARNING) sys.excepthook = lambda *args: logging.getLogger(None).exception( - "Uncaught exception", exc_info=args # type: ignore[arg-type] + "Uncaught exception", + exc_info=args, # type: ignore[arg-type] ) threading.excepthook = lambda args: logging.getLogger(None).exception( "Uncaught thread exception", @@ -136,7 +136,7 @@ def _enable_posix_spawn() -> None: subprocess._USE_POSIX_SPAWN = os.path.exists(ALPINE_RELEASE_FILE) -def main(): +def main() -> None: """Start MusicAssistant.""" # parse arguments args = get_arguments() @@ -163,11 +163,11 @@ def main(): # enable alpine subprocess workaround _enable_posix_spawn() - def on_shutdown(loop): + def on_shutdown(loop) -> None: logger.info("shutdown requested!") loop.run_until_complete(mass.stop()) - async def start_mass(): + async def start_mass() -> None: loop = asyncio.get_running_loop() activate_log_queue_handler() if dev_mode or log_level == "DEBUG": diff --git a/music_assistant/client/client.py b/music_assistant/client/client.py index af79e886..d3daa74d 100644 --- a/music_assistant/client/client.py +++ b/music_assistant/client/client.py @@ -7,10 +7,13 @@ import logging import urllib.parse import uuid from collections.abc import Callable -from types import TracebackType from typing import TYPE_CHECKING, Any -from music_assistant.client.exceptions import ConnectionClosed, InvalidServerVersion, InvalidState +from music_assistant.client.exceptions import ( + ConnectionClosed, + InvalidServerVersion, + InvalidState, +) from music_assistant.common.models.api import ( ChunkedResultMessage, CommandMessage, @@ -24,7 +27,6 @@ from music_assistant.common.models.api import ( from music_assistant.common.models.enums import EventType from music_assistant.common.models.errors import ERROR_MAP from music_assistant.common.models.event import MassEvent -from music_assistant.common.models.media_items import MediaItemImage from music_assistant.constants import API_SCHEMA_VERSION from .connection import WebsocketsConnection @@ -32,8 +34,12 @@ from .music import Music from .players import Players if TYPE_CHECKING: + from types import TracebackType + from aiohttp import ClientSession + from music_assistant.common.models.media_items import MediaItemImage + EventCallBackType = Callable[[MassEvent], None] EventSubscriptionType = tuple[ EventCallBackType, tuple[EventType, ...] | None, tuple[str, ...] | None @@ -49,7 +55,7 @@ class MusicAssistantClient: self.connection = WebsocketsConnection(server_url, aiohttp_session) self.logger = logging.getLogger(__package__) self._result_futures: dict[str, asyncio.Future] = {} - self._subscribers: list[EventSubscriptionType] = list() + self._subscribers: list[EventSubscriptionType] = [] self._stop_called: bool = False self._loop: asyncio.AbstractEventLoop | None = None self._players = Players(self) @@ -101,7 +107,7 @@ class MusicAssistantClient: listener = (cb_func, event_filter, id_filter) self._subscribers.append(listener) - def remove_listener(): + def remove_listener() -> None: self._subscribers.remove(listener) return remove_listener @@ -120,12 +126,13 @@ class MusicAssistantClient: if info.min_supported_schema_version > API_SCHEMA_VERSION: # our schema version is too low and can't be handled by the server anymore. await self.connection.disconnect() - raise InvalidServerVersion( + msg = ( f"Schema version is incompatible: {info.schema_version}, " f"the server requires at least {info.min_supported_schema_version} " " - update the Music Assistant client to a more " "recent version or downgrade the server." ) + raise InvalidServerVersion(msg) self._server_info = info @@ -145,17 +152,19 @@ class MusicAssistantClient: ) -> Any: """Send a command and get a response.""" if not self.connection.connected or not self._loop: - raise InvalidState("Not connected") + msg = "Not connected" + raise InvalidState(msg) if ( require_schema is not None and self.server_info is not None and require_schema > self.server_info.schema_version ): - raise InvalidServerVersion( + msg = ( "Command not available due to incompatible server version. Update the Music " f"Assistant Server to a version that supports at least api schema {require_schema}." ) + raise InvalidServerVersion(msg) command_message = CommandMessage( message_id=uuid.uuid4().hex, @@ -178,13 +187,15 @@ class MusicAssistantClient: ) -> None: """Send a command without waiting for the response.""" if not self.server_info: - raise InvalidState("Not connected") + msg = "Not connected" + raise InvalidState(msg) if require_schema is not None and require_schema > self.server_info.schema_version: - raise InvalidServerVersion( + msg = ( "Command not available due to incompatible server version. Update the Music " f"Assistant Server to a version that supports at least api schema {require_schema}." ) + raise InvalidServerVersion(msg) command_message = CommandMessage( message_id=uuid.uuid4().hex, command=command, @@ -198,7 +209,7 @@ class MusicAssistantClient: # fetch initial state # we do this in a separate task to not block reading messages - async def fetch_initial_state(): + async def fetch_initial_state() -> None: await self._players.fetch_state() if init_ready is not None: diff --git a/music_assistant/client/connection.py b/music_assistant/client/connection.py index 0fb1c49b..4dfc9651 100644 --- a/music_assistant/client/connection.py +++ b/music_assistant/client/connection.py @@ -24,7 +24,8 @@ LOGGER = logging.getLogger(f"{__package__}.connection") def get_websocket_url(url: str) -> str: """Extract Websocket URL from (base) Music Assistant URL.""" if not url or "://" not in url: - raise RuntimeError(f"{url} is not a valid url") + msg = f"{url} is not a valid url" + raise RuntimeError(msg) ws_url = url.replace("http", "ws") if not ws_url.endswith("/ws"): ws_url += "/ws" @@ -51,7 +52,8 @@ class WebsocketsConnection: if self._aiohttp_session is None: self._aiohttp_session = ClientSession() if self._ws_client is not None: - raise InvalidState("Already connected") + msg = "Already connected" + raise InvalidState(msg) LOGGER.debug("Trying to connect") try: @@ -85,20 +87,24 @@ class WebsocketsConnection: ws_msg = await self._ws_client.receive() if ws_msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): - raise ConnectionClosed("Connection was closed.") + msg = "Connection was closed." + raise ConnectionClosed(msg) if ws_msg.type == WSMsgType.ERROR: - raise ConnectionFailed() + raise ConnectionFailed if ws_msg.type != WSMsgType.TEXT: - raise InvalidMessage(f"Received non-Text message: {ws_msg.type}") + msg = f"Received non-Text message: {ws_msg.type}" + raise InvalidMessage(msg) try: msg = json_loads(ws_msg.data) except TypeError as err: - raise InvalidMessage(f"Received unsupported JSON: {err}") from err + msg = f"Received unsupported JSON: {err}" + raise InvalidMessage(msg) from err except ValueError as err: - raise InvalidMessage("Received invalid JSON.") from err + msg = "Received invalid JSON." + raise InvalidMessage(msg) from err if LOGGER.isEnabledFor(logging.DEBUG): LOGGER.debug("Received message:\n%s\n", pprint.pformat(ws_msg)) diff --git a/music_assistant/client/music.py b/music_assistant/client/music.py index 1840d27c..006867bf 100644 --- a/music_assistant/client/music.py +++ b/music_assistant/client/music.py @@ -425,7 +425,9 @@ class Music: Destructive! Will remove the item and all dependants. """ await self.client.send_command( - "music/library/remove", media_type=media_type, library_item_id=library_item_id + "music/library/remove", + media_type=media_type, + library_item_id=library_item_id, ) async def add_item_to_favorites( @@ -465,17 +467,21 @@ class Music: ] async def search( - self, search_query: str, media_types: tuple[MediaType] = MediaType.ALL, limit: int = 25 + self, + search_query: str, + media_types: tuple[MediaType] = MediaType.ALL, + limit: int = 25, ) -> SearchResults: """Perform global search for media items on all providers.""" return SearchResults.from_dict( await self.client.send_command( - "music/search", search_query=search_query, media_types=media_types, limit=limit + "music/search", + search_query=search_query, + media_types=media_types, + limit=limit, ), ) async def get_sync_tasks(self) -> list[SyncTask]: """Return any/all sync tasks that are in progress on the server.""" - return [ - SyncTask.from_dict(item) for item in await self.client.send_command("music/synctasks") - ] + return [SyncTask(**item) for item in await self.client.send_command("music/synctasks")] diff --git a/music_assistant/client/players.py b/music_assistant/client/players.py index 1cf7cb0a..119081df 100644 --- a/music_assistant/client/players.py +++ b/music_assistant/client/players.py @@ -2,17 +2,19 @@ from __future__ import annotations -from collections.abc import Iterator from typing import TYPE_CHECKING from music_assistant.common.models.enums import EventType, QueueOption, RepeatMode -from music_assistant.common.models.event import MassEvent -from music_assistant.common.models.media_items import MediaItemType from music_assistant.common.models.player import Player from music_assistant.common.models.player_queue import PlayerQueue from music_assistant.common.models.queue_item import QueueItem if TYPE_CHECKING: + from collections.abc import Iterator + + from music_assistant.common.models.event import MassEvent + from music_assistant.common.models.media_items import MediaItemType + from .client import MusicAssistantClient diff --git a/music_assistant/common/helpers/json.py b/music_assistant/common/helpers/json.py index 917c4574..839a19df 100644 --- a/music_assistant/common/helpers/json.py +++ b/music_assistant/common/helpers/json.py @@ -31,7 +31,7 @@ def get_serializable_value(obj: Any, raise_unhandled: bool = False) -> Any: if isinstance(obj, DO_NOT_SERIALIZE_TYPES): return None if raise_unhandled: - raise TypeError() + raise TypeError return obj diff --git a/music_assistant/common/helpers/uri.py b/music_assistant/common/helpers/uri.py index be2fa4af..5db630da 100644 --- a/music_assistant/common/helpers/uri.py +++ b/music_assistant/common/helpers/uri.py @@ -19,7 +19,7 @@ def parse_uri(uri: str) -> tuple[MediaType, str, str]: media_type_str = uri.split("/")[3] media_type = MediaType(media_type_str) item_id = uri.split("/")[4].split("?")[0] - elif uri.startswith("http://") or uri.startswith("https://"): + elif uri.startswith(("http://", "https://")): # Translate a plain URL to the URL provider provider_instance_id_or_domain = "url" media_type = MediaType.UNKNOWN @@ -43,7 +43,8 @@ def parse_uri(uri: str) -> tuple[MediaType, str, str]: else: raise KeyError except (TypeError, AttributeError, ValueError, KeyError) as err: - raise MusicAssistantError(f"Not a valid Music Assistant uri: {uri}") from err + msg = f"Not a valid Music Assistant uri: {uri}" + raise MusicAssistantError(msg) from err return (media_type, provider_instance_id_or_domain, item_id) diff --git a/music_assistant/common/helpers/util.py b/music_assistant/common/helpers/util.py old mode 100755 new mode 100644 index 2bd40eca..a06cb8a0 --- a/music_assistant/common/helpers/util.py +++ b/music_assistant/common/helpers/util.py @@ -55,7 +55,7 @@ def create_sort_name(input_str: str) -> str: return input_str.strip() -def parse_title_and_version(title: str, track_version: str = None): +def parse_title_and_version(title: str, track_version: str | None = None): """Try to parse clean track title and version from the title.""" version = "" for splitter in [" (", " [", " - ", " (", " [", "-"]: @@ -161,12 +161,14 @@ async def select_free_port(range_start: int, range_end: int) -> int: _sock.bind(("127.0.0.1", port)) except OSError: return True + return False def _select_free_port(): for port in range(range_start, range_end): if not is_port_in_use(port): return port - raise OSError("No free port available") + msg = "No free port available" + raise OSError(msg) return await asyncio.to_thread(_select_free_port) @@ -199,13 +201,12 @@ def get_folder_size(folderpath): """Return folder size in gb.""" total_size = 0 # pylint: disable=unused-variable - for dirpath, dirnames, filenames in os.walk(folderpath): + for dirpath, _dirnames, filenames in os.walk(folderpath): for _file in filenames: _fp = os.path.join(dirpath, _file) total_size += os.path.getsize(_fp) # pylint: enable=unused-variable - total_size_gb = total_size / float(1 << 30) - return total_size_gb + return total_size / float(1 << 30) def merge_dict(base_dict: dict, new_dict: dict, allow_overwite=False): @@ -230,7 +231,7 @@ def merge_tuples(base: tuple, new: tuple) -> tuple: def merge_lists(base: list, new: list) -> list: """Merge 2 lists.""" - return list(x for x in base if x not in new) + list(new) + return [x for x in base if x not in new] + list(new) def get_changed_keys( diff --git a/music_assistant/common/models/api.py b/music_assistant/common/models/api.py index 1894d201..f5e45d57 100644 --- a/music_assistant/common/models/api.py +++ b/music_assistant/common/models/api.py @@ -10,6 +10,8 @@ from mashumaro.mixins.orjson import DataClassORJSONMixin from music_assistant.common.helpers.json import get_serializable_value from music_assistant.common.models.event import MassEvent +# pylint: disable=unnecessary-lambda + @dataclass class CommandMessage(DataClassORJSONMixin): diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index f853f6f1..f348cb86 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -3,14 +3,12 @@ from __future__ import annotations import logging -from collections.abc import Iterable from dataclasses import dataclass from types import NoneType -from typing import Any +from typing import TYPE_CHECKING, Any from mashumaro import DataClassDictMixin -from music_assistant.common.models.enums import ProviderType from music_assistant.constants import ( CONF_AUTO_PLAY, CONF_CROSSFADE, @@ -29,6 +27,11 @@ from music_assistant.constants import ( from .enums import ConfigEntryType +if TYPE_CHECKING: + from collections.abc import Iterable + + from music_assistant.common.models.enums import ProviderType + LOGGER = logging.getLogger(__name__) ENCRYPT_CALLBACK: callable[[str], str] | None = None @@ -138,7 +141,8 @@ class ConfigEntry(DataClassDictMixin): ) self.value = self.default_value return self.value - raise ValueError(f"{self.key} has unexpected type: {type(value)}") + msg = f"{self.key} has unexpected type: {type(value)}" + raise ValueError(msg) self.value = value return self.value diff --git a/music_assistant/common/models/event.py b/music_assistant/common/models/event.py index d61a25a4..74b40dc7 100644 --- a/music_assistant/common/models/event.py +++ b/music_assistant/common/models/event.py @@ -15,4 +15,9 @@ class MassEvent(DataClassORJSONMixin): event: EventType object_id: str | None = None # player_id, queue_id or uri - data: Any = field(default=None, metadata={"serialize": lambda v: get_serializable_value(v)}) + data: Any = field( + default=None, + metadata={ + "serialize": lambda v: get_serializable_value(v) # pylint: disable=unnecessary-lambda + }, + ) diff --git a/music_assistant/common/models/media_items.py b/music_assistant/common/models/media_items.py old mode 100755 new mode 100644 index 53df3831..5085916f --- a/music_assistant/common/models/media_items.py +++ b/music_assistant/common/models/media_items.py @@ -9,7 +9,11 @@ from typing import Any, Self from mashumaro import DataClassDictMixin from music_assistant.common.helpers.uri import create_uri -from music_assistant.common.helpers.util import create_sort_name, is_valid_uuid, merge_lists +from music_assistant.common.helpers.util import ( + create_sort_name, + is_valid_uuid, + merge_lists, +) from music_assistant.common.models.enums import ( AlbumType, ContentType, @@ -184,11 +188,11 @@ class MediaItemMetadata(DataClassDictMixin): elif isinstance(cur_val, set) and isinstance(new_val, list): new_val = cur_val.update(new_val) setattr(self, fld.name, new_val) - elif new_val and fld.name in ("checksum", "popularity", "last_refresh"): # noqa: SIM114 + elif new_val and fld.name in ("checksum", "popularity", "last_refresh"): # some fields are always allowed to be overwritten # (such as checksum and last_refresh) setattr(self, fld.name, new_val) - elif cur_val is None or (allow_overwrite and new_val): # noqa: SIM114 + elif cur_val is None or (allow_overwrite and new_val): setattr(self, fld.name, new_val) return self @@ -225,7 +229,8 @@ class _MediaItemBase(DataClassDictMixin): if not value: return if not is_valid_uuid(value): - raise InvalidDataError(f"Invalid MusicBrainz identifier: {value}") + msg = f"Invalid MusicBrainz identifier: {value}" + raise InvalidDataError(msg) if existing := next((x for x in self.external_ids if x[0] == ExternalID.MUSICBRAINZ), None): # Musicbrainz ID is unique so remove existing entry self.external_ids.remove(existing) @@ -296,7 +301,7 @@ class ItemMapping(_MediaItemBase): available: bool = True @classmethod - def from_item(cls, item: MediaItem): + def from_item(cls, item: MediaItem) -> ItemMapping: """Create ItemMapping object from regular item.""" return cls.from_dict(item.to_dict()) @@ -514,7 +519,7 @@ class StreamDetails(DataClassDictMixin): d.pop("callback") return d - def __str__(self): + def __str__(self) -> str: """Return pretty printable string of object.""" return self.uri diff --git a/music_assistant/common/models/player.py b/music_assistant/common/models/player.py index 55f9ab58..e2b8ca77 100644 --- a/music_assistant/common/models/player.py +++ b/music_assistant/common/models/player.py @@ -31,7 +31,7 @@ class Player(DataClassDictMixin): available: bool powered: bool device_info: DeviceInfo - supported_features: tuple[PlayerFeature, ...] = field(default=tuple()) + supported_features: tuple[PlayerFeature, ...] = field(default=()) elapsed_time: float = 0 elapsed_time_last_updated: float = time.time() @@ -59,7 +59,7 @@ class Player(DataClassDictMixin): # can_sync_with: return tuple of player_ids that can be synced to/with this player # usually this is just a list of all player_ids within the playerprovider - can_sync_with: tuple[str, ...] = field(default=tuple()) + can_sync_with: tuple[str, ...] = field(default=()) # synced_to: player_id of the player this player is currently synced to # also referred to as "sync master" diff --git a/music_assistant/common/models/player_queue.py b/music_assistant/common/models/player_queue.py index 30b9cae7..48a455de 100644 --- a/music_assistant/common/models/player_queue.py +++ b/music_assistant/common/models/player_queue.py @@ -4,13 +4,16 @@ from __future__ import annotations import time from dataclasses import dataclass, field +from typing import TYPE_CHECKING from mashumaro import DataClassDictMixin -from music_assistant.common.models.media_items import MediaItemType - from .enums import PlayerState, RepeatMode -from .queue_item import QueueItem + +if TYPE_CHECKING: + from music_assistant.common.models.media_items import MediaItemType + + from .queue_item import QueueItem @dataclass diff --git a/music_assistant/constants.py b/music_assistant/constants.py old mode 100755 new mode 100644 index 57155dc6..ab5b8108 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -73,9 +73,9 @@ DB_TABLE_THUMBS: Final[str] = "thumbnails" DB_TABLE_PROVIDER_MAPPINGS: Final[str] = "provider_mappings" # all other -MASS_LOGO_ONLINE: Final[str] = ( - "https://github.com/home-assistant/brands/raw/master/custom_integrations/mass/icon%402x.png" -) +MASS_LOGO_ONLINE: Final[ + str +] = "https://github.com/home-assistant/brands/raw/master/custom_integrations/mass/icon%402x.png" ENCRYPT_SUFFIX = "_encrypted_" SECURE_STRING_SUBSTITUTE = "this_value_is_encrypted" CONFIGURABLE_CORE_CONTROLLERS = ( diff --git a/music_assistant/server/__init__.py b/music_assistant/server/__init__.py index 0f273b47..7fe0caca 100644 --- a/music_assistant/server/__init__.py +++ b/music_assistant/server/__init__.py @@ -1,3 +1,3 @@ """Music Assistant: The music library manager in python.""" -from .server import MusicAssistant # noqa +from .server import MusicAssistant # noqa: F401 diff --git a/music_assistant/server/controllers/cache.py b/music_assistant/server/controllers/cache.py index d6760d63..3523ebaa 100644 --- a/music_assistant/server/controllers/cache.py +++ b/music_assistant/server/controllers/cache.py @@ -48,8 +48,8 @@ class CacheController(CoreController): async def get_config_entries( self, - action: str | None = None, # noqa: ARG002 - values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, ) -> tuple[ConfigEntry, ...]: """Return all Config Entries for this core module (if any).""" if action == CONF_CLEAR_CACHE: @@ -70,7 +70,7 @@ class CacheController(CoreController): ), ) - async def setup(self, config: CoreConfig) -> None: # noqa: ARG002 + async def setup(self, config: CoreConfig) -> None: """Async initialize of cache module.""" self.logger.info("Initializing cache controller...") await self._setup_database() @@ -115,7 +115,7 @@ class CacheController(CoreController): return data return default - async def set(self, cache_key, data, checksum="", expiration=(86400 * 30)): + async def set(self, cache_key, data, checksum="", expiration=(86400 * 30)) -> None: """Set data in cache.""" if not cache_key: return @@ -133,7 +133,7 @@ class CacheController(CoreController): allow_replace=True, ) - async def delete(self, cache_key): + async def delete(self, cache_key) -> None: """Delete data from cache.""" self._mem_cache.pop(cache_key, None) await self.database.delete(DB_TABLE_CACHE, {"key": cache_key}) @@ -147,7 +147,7 @@ class CacheController(CoreController): await self.database.vacuum() self.logger.info("Clearing database DONE") - async def auto_cleanup(self): + async def auto_cleanup(self) -> None: """Sceduled auto cleanup task.""" self.logger.debug("Running automatic cleanup...") # for now we simply reset the memory cache @@ -165,7 +165,7 @@ class CacheController(CoreController): self.logger.debug("Compacting database done") self.logger.debug("Automatic cleanup finished (cleaned up %s records)", cleaned_records) - async def _setup_database(self): + async def _setup_database(self) -> None: """Initialize database.""" db_path = os.path.join(self.mass.storage_path, "cache.db") self.database = DatabaseConnection(db_path) @@ -221,7 +221,7 @@ class CacheController(CoreController): f"CREATE INDEX IF NOT EXISTS {DB_TABLE_CACHE}_key_idx on {DB_TABLE_CACHE}(key);" ) - def __schedule_cleanup_task(self): + def __schedule_cleanup_task(self) -> None: """Schedule the cleanup task.""" self.mass.create_task(self.auto_cleanup()) # reschedule self @@ -265,7 +265,7 @@ def use_cache(expiration=86400 * 30): class MemoryCache(MutableMapping): """Simple limited in-memory cache implementation.""" - def __init__(self, maxlen: int): + def __init__(self, maxlen: int) -> None: """Initialize.""" self._maxlen = maxlen self.d = OrderedDict() diff --git a/music_assistant/server/controllers/config.py b/music_assistant/server/controllers/config.py index 8b21c74b..6a112f80 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import base64 import logging import os @@ -15,7 +14,11 @@ import shortuuid from aiofiles.os import wrap from cryptography.fernet import Fernet, InvalidToken -from music_assistant.common.helpers.json import JSON_DECODE_EXCEPTIONS, json_dumps, json_loads +from music_assistant.common.helpers.json import ( + JSON_DECODE_EXCEPTIONS, + json_dumps, + json_loads, +) from music_assistant.common.models import config_entries from music_assistant.common.models.config_entries import ( DEFAULT_CORE_CONFIG_ENTRIES, @@ -27,7 +30,10 @@ from music_assistant.common.models.config_entries import ( ProviderConfig, ) from music_assistant.common.models.enums import EventType, PlayerState, ProviderType -from music_assistant.common.models.errors import InvalidDataError, PlayerUnavailableError +from music_assistant.common.models.errors import ( + InvalidDataError, + PlayerUnavailableError, +) from music_assistant.constants import ( CONF_CORE, CONF_PLAYERS, @@ -41,6 +47,8 @@ from music_assistant.server.helpers.util import get_provider_module from music_assistant.server.models.player_provider import PlayerProvider if TYPE_CHECKING: + import asyncio + from music_assistant.server.models.core_controller import CoreController from music_assistant.server.server import MusicAssistant @@ -103,11 +111,10 @@ class ConfigController: # replace None with default return default return value - elif subkey not in parent: + if subkey not in parent: # requesting subkey from a non existing parent return default - else: - parent = parent[subkey] + parent = parent[subkey] return default def set(self, key: str, value: Any) -> None: @@ -178,15 +185,19 @@ class ConfigController: """Return configuration for a single provider.""" if raw_conf := self.get(f"{CONF_PROVIDERS}/{instance_id}", {}): config_entries = await self.get_provider_config_entries( - raw_conf["domain"], instance_id=instance_id, values=raw_conf.get("values") + raw_conf["domain"], + instance_id=instance_id, + values=raw_conf.get("values"), ) for prov in self.mass.get_provider_manifests(): if prov.domain == raw_conf["domain"]: break else: - raise KeyError(f'Unknown provider domain: {raw_conf["domain"]}') + msg = f'Unknown provider domain: {raw_conf["domain"]}' + raise KeyError(msg) return ProviderConfig.parse(config_entries, raw_conf) - raise KeyError(f"No config found for provider id {instance_id}") + msg = f"No config found for provider id {instance_id}" + raise KeyError(msg) @api_command("config/providers/get_value") async def get_provider_config_value(self, instance_id: str, key: str) -> ConfigValueType: @@ -226,7 +237,8 @@ class ConfigController: prov_mod = await get_provider_module(provider_domain) break else: - raise KeyError(f"Unknown provider domain: {provider_domain}") + msg = f"Unknown provider domain: {provider_domain}" + raise KeyError(msg) if values is None: values = self.get(f"{CONF_PROVIDERS}/{instance_id}/values", {}) if instance_id else {} return ( @@ -263,18 +275,21 @@ class ConfigController: conf_key = f"{CONF_PROVIDERS}/{instance_id}" existing = self.get(conf_key) if not existing: - raise KeyError(f"Provider {instance_id} does not exist") + msg = f"Provider {instance_id} does not exist" + raise KeyError(msg) prov_manifest = self.mass.get_provider_manifest(existing["domain"]) if prov_manifest.load_by_default and instance_id == prov_manifest.domain: # Guard for a provider that is loaded by default LOGGER.warning( - "Provider %s can not be removed, disabling instead...", prov_manifest.name + "Provider %s can not be removed, disabling instead...", + prov_manifest.name, ) existing["enabled"] = False await self._update_provider_config(instance_id, existing) return if prov_manifest.builtin: - raise RuntimeError(f"Builtin provider {prov_manifest.name} can not be removed.") + msg = f"Builtin provider {prov_manifest.name} can not be removed." + raise RuntimeError(msg) self.remove(conf_key) await self.mass.unload_provider(instance_id) if existing["type"] == "music": @@ -332,12 +347,13 @@ class ConfigController: if player := self.mass.players.get(player_id, False): raw_conf["default_name"] = player.display_name else: - conf_entries = tuple() + conf_entries = () raw_conf["available"] = False raw_conf["name"] = raw_conf.get("name") raw_conf["default_name"] = raw_conf.get("default_name") or raw_conf["player_id"] return PlayerConfig.parse(conf_entries, raw_conf) - raise KeyError(f"No config found for player id {player_id}") + msg = f"No config found for player id {player_id}" + raise KeyError(msg) @api_command("config/players/get_value") async def get_player_config_value( @@ -347,12 +363,11 @@ class ConfigController: ) -> ConfigValueType: """Return single configentry value for a player.""" conf = await self.get_player_config(player_id) - val = ( + return ( conf.values[key].value if conf.values[key].value is not None else conf.values[key].default_value ) - return val def get_raw_player_config_value( self, player_id: str, key: str, default: ConfigValueType = None @@ -377,7 +392,7 @@ class ConfigController: if not changed_keys: # no changes - return + return None conf_key = f"{CONF_PLAYERS}/{player_id}" self.set(conf_key, config.to_raw()) @@ -412,7 +427,8 @@ class ConfigController: conf_key = f"{CONF_PLAYERS}/{player_id}" existing = self.get(conf_key) if not existing: - raise KeyError(f"Player {player_id} does not exist") + msg = f"Player {player_id} does not exist" + raise KeyError(msg) self.remove(conf_key) if (player := self.mass.players.get(player_id)) and player.available: player.enabled = False @@ -444,7 +460,11 @@ class ConfigController: # config does not yet exist, create a default one conf_key = f"{CONF_PLAYERS}/{player_id}" default_conf = PlayerConfig( - values={}, provider=provider, player_id=player_id, enabled=enabled, default_name=name + values={}, + provider=provider, + player_id=player_id, + enabled=enabled, + default_name=name, ) default_conf_raw = default_conf.to_raw() if values is not None: @@ -461,7 +481,7 @@ class ConfigController: This is meant as helper to create default configs for default enabled providers. Called by the server initialization code which load all providers at startup. """ - for conf in await self.get_provider_configs(provider_domain=provider_domain): + for _conf in await self.get_provider_configs(provider_domain=provider_domain): # return if there is already a config return for prov in self.mass.get_provider_manifests(): @@ -469,7 +489,8 @@ class ConfigController: manifest = prov break else: - raise KeyError(f"Unknown provider domain: {provider_domain}") + msg = f"Unknown provider domain: {provider_domain}" + raise KeyError(msg) config_entries = await self.get_provider_config_entries(provider_domain) instance_id = f"{manifest.domain}--{shortuuid.random(8)}" default_config: ProviderConfig = ProviderConfig.parse( @@ -591,7 +612,8 @@ class ConfigController: """ if not self.get(f"{CONF_PROVIDERS}/{provider_instance}"): # only allow setting raw values if main entry exists - raise KeyError(f"Invalid provider_instance: {provider_instance}") + msg = f"Invalid provider_instance: {provider_instance}" + raise KeyError(msg) self.set(f"{CONF_PROVIDERS}/{provider_instance}/{key}", value) def set_raw_player_config_value(self, player_id: str, key: str, value: ConfigValueType) -> None: @@ -602,7 +624,8 @@ class ConfigController: """ if not self.get(f"{CONF_PLAYERS}/{player_id}"): # only allow setting raw values if main entry exists - raise KeyError(f"Invalid player_id: {player_id}") + msg = f"Invalid player_id: {player_id}" + raise KeyError(msg) self.set(f"{CONF_PLAYERS}/{player_id}/values/{key}", value) def save(self, immediate: bool = False) -> None: @@ -636,7 +659,8 @@ class ConfigController: try: return self._fernet.decrypt(encrypted_str.encode()).decode() except InvalidToken as err: - raise InvalidDataError("Password decryption failed") from err + msg = "Password decryption failed" + raise InvalidDataError(msg) from err async def _load(self) -> None: """Load data from persistent storage.""" @@ -652,10 +676,10 @@ class ConfigController: except FileNotFoundError: pass except JSON_DECODE_EXCEPTIONS: # pylint: disable=catching-non-exception - LOGGER.error("Error while reading persistent storage file %s", filename) + LOGGER.exception("Error while reading persistent storage file %s", filename) LOGGER.debug("Started with empty storage: No persistent storage file found.") - async def _async_save(self): + async def _async_save(self) -> None: """Save persistent data to disk.""" filename_backup = f"{self.filename}.backup" # make backup before we write a new file @@ -687,7 +711,8 @@ class ConfigController: # disable provider prov_manifest = self.mass.get_provider_manifest(config.domain) if prov_manifest.builtin: - raise RuntimeError("Builtin provider can not be disabled.") + msg = "Builtin provider can not be disabled." + raise RuntimeError(msg) # also unload any other providers dependent of this provider for dep_prov in self.mass.providers: if dep_prov.manifest.depends_on == config.domain: @@ -725,14 +750,16 @@ class ConfigController: manifest = prov break else: - raise KeyError(f"Unknown provider domain: {provider_domain}") + msg = f"Unknown provider domain: {provider_domain}" + raise KeyError(msg) # create new provider config with given values existing = { x.instance_id for x in await self.get_provider_configs(provider_domain=provider_domain) } # determine instance id based on previous configs if existing and not manifest.multi_instance: - raise ValueError(f"Provider {manifest.name} does not support multiple instances") + msg = f"Provider {manifest.name} does not support multiple instances" + raise ValueError(msg) instance_id = f"{manifest.domain}--{shortuuid.random(8)}" # all checks passed, create config object config_entries = await self.get_provider_config_entries( diff --git a/music_assistant/server/controllers/media/albums.py b/music_assistant/server/controllers/media/albums.py index 945d490d..33050b50 100644 --- a/music_assistant/server/controllers/media/albums.py +++ b/music_assistant/server/controllers/media/albums.py @@ -42,7 +42,7 @@ class AlbumsController(MediaControllerBase[Album]): media_type = MediaType.ALBUM item_cls = Album - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """Initialize class.""" super().__init__(*args, **kwargs) self._db_add_lock = asyncio.Lock() @@ -97,9 +97,11 @@ class AlbumsController(MediaControllerBase[Album]): ) -> Album: """Add album to library and return the database item.""" if not isinstance(item, Album): - raise InvalidDataError("Not a valid Album object (ItemMapping can not be added to db)") + msg = "Not a valid Album object (ItemMapping can not be added to db)" + raise InvalidDataError(msg) if not item.provider_mappings: - raise InvalidDataError("Album is missing provider mapping(s)") + msg = "Album is missing provider mapping(s)" + raise InvalidDataError(msg) # grab additional metadata if metadata_lookup: await self.mass.metadata.get_album_metadata(item) @@ -107,7 +109,7 @@ class AlbumsController(MediaControllerBase[Album]): library_item = None if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider): # existing item match by provider id - library_item = await self.update_item_in_library(cur_item.item_id, item) # noqa: SIM114 + library_item = await self.update_item_in_library(cur_item.item_id, item) elif cur_item := await self.get_library_item_by_external_ids(item.external_ids): # existing item match by external id library_item = await self.update_item_in_library(cur_item.item_id, item) @@ -340,13 +342,14 @@ class AlbumsController(MediaControllerBase[Album]): return sorted(dynamic_playlist, key=lambda n: random()) async def _get_dynamic_tracks( - self, media_item: Album, limit: int = 25 # noqa: ARG002 + self, + media_item: Album, + limit: int = 25, ) -> list[Track]: """Get dynamic list of tracks for given item, fallback/default implementation.""" # TODO: query metadata provider(s) to get similar tracks (or tracks from similar artists) - raise UnsupportedFeaturedException( - "No Music Provider found that supports requesting similar tracks." - ) + msg = "No Music Provider found that supports requesting similar tracks." + raise UnsupportedFeaturedException(msg) async def _get_db_album_tracks( self, diff --git a/music_assistant/server/controllers/media/artists.py b/music_assistant/server/controllers/media/artists.py index 55794d94..e4e8d506 100644 --- a/music_assistant/server/controllers/media/artists.py +++ b/music_assistant/server/controllers/media/artists.py @@ -10,7 +10,10 @@ from typing import TYPE_CHECKING, Any from music_assistant.common.helpers.datetime import utc_timestamp from music_assistant.common.helpers.json import serialize_to_json from music_assistant.common.models.enums import EventType, ProviderFeature -from music_assistant.common.models.errors import MediaNotFoundError, UnsupportedFeaturedException +from music_assistant.common.models.errors import ( + MediaNotFoundError, + UnsupportedFeaturedException, +) from music_assistant.common.models.media_items import ( Album, AlbumType, @@ -20,13 +23,14 @@ from music_assistant.common.models.media_items import ( PagedItems, Track, ) -from music_assistant.constants import VARIOUS_ARTISTS_ID_MBID, VARIOUS_ARTISTS_NAME -from music_assistant.server.controllers.media.base import MediaControllerBase -from music_assistant.server.controllers.music import ( +from music_assistant.constants import ( DB_TABLE_ALBUMS, DB_TABLE_ARTISTS, DB_TABLE_TRACKS, + VARIOUS_ARTISTS_ID_MBID, + VARIOUS_ARTISTS_NAME, ) +from music_assistant.server.controllers.media.base import MediaControllerBase from music_assistant.server.helpers.compare import compare_artist, compare_strings if TYPE_CHECKING: @@ -40,7 +44,7 @@ class ArtistsController(MediaControllerBase[Artist]): media_type = MediaType.ARTIST item_cls = Artist - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """Initialize class.""" super().__init__(*args, **kwargs) self._db_add_lock = asyncio.Lock() @@ -72,7 +76,7 @@ class ArtistsController(MediaControllerBase[Artist]): library_item = None if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider): # existing item match by provider id - library_item = await self.update_item_in_library(cur_item.item_id, item) # noqa: SIM114 + library_item = await self.update_item_in_library(cur_item.item_id, item) elif cur_item := await self.get_library_item_by_external_ids(item.external_ids): # existing item match by external id library_item = await self.update_item_in_library(cur_item.item_id, item) @@ -223,7 +227,7 @@ class ArtistsController(MediaControllerBase[Artist]): # delete the artist itself from db await super().remove_item_from_library(db_id) - async def match_artist(self, db_artist: Artist): + async def match_artist(self, db_artist: Artist) -> None: """Try to find matching artists on all providers for the provided (database) item_id. This is used to link objects of different providers together. @@ -407,19 +411,20 @@ class ArtistsController(MediaControllerBase[Artist]): ) # Merge album content with similar tracks dynamic_playlist = [ - *sorted(top_tracks, key=lambda n: random())[:no_of_artist_tracks], # noqa: ARG005 - *sorted(similar_tracks, key=lambda n: random())[:no_of_similar_tracks], # noqa: ARG005 + *sorted(top_tracks, key=lambda _: random())[:no_of_artist_tracks], + *sorted(similar_tracks, key=lambda _: random())[:no_of_similar_tracks], ] return sorted(dynamic_playlist, key=lambda n: random()) # noqa: ARG005 async def _get_dynamic_tracks( - self, media_item: Artist, limit: int = 25 # noqa: ARG002 + self, + media_item: Artist, + limit: int = 25, ) -> list[Track]: """Get dynamic list of tracks for given item, fallback/default implementation.""" # TODO: query metadata provider(s) to get similar tracks (or tracks from similar artists) - raise UnsupportedFeaturedException( - "No Music Provider found that supports requesting similar tracks." - ) + msg = "No Music Provider found that supports requesting similar tracks." + raise UnsupportedFeaturedException(msg) async def _match(self, db_artist: Artist, provider: MusicProvider) -> bool: """Try to find matching artists on given provider for the provided (database) artist.""" @@ -436,20 +441,21 @@ class ArtistsController(MediaControllerBase[Artist]): # make sure we have a full track if isinstance(ref_track.album, ItemMapping): try: - ref_track = await self.mass.music.tracks.get_provider_item( # noqa: PLW2901 + maybe_ref_track = await self.mass.music.tracks.get_provider_item( ref_track.item_id, ref_track.provider ) except MediaNotFoundError: continue + provider_ref_track = maybe_ref_track or ref_track for search_str in ( - f"{db_artist.name} - {ref_track.name}", - f"{db_artist.name} {ref_track.name}", - f"{db_artist.sort_name} {ref_track.sort_name}", - ref_track.name, + f"{db_artist.name} - {provider_ref_track.name}", + f"{db_artist.name} {provider_ref_track.name}", + f"{db_artist.sort_name} {provider_ref_track.sort_name}", + provider_ref_track.name, ): search_results = await self.mass.music.tracks.search(search_str, provider.domain) for search_result_item in search_results: - if search_result_item.sort_name != ref_track.sort_name: + if search_result_item.sort_name != provider_ref_track.sort_name: continue # get matching artist from track for search_item_artist in search_result_item.artists: diff --git a/music_assistant/server/controllers/media/base.py b/music_assistant/server/controllers/media/base.py index 0833f4c4..66c112ef 100644 --- a/music_assistant/server/controllers/media/base.py +++ b/music_assistant/server/controllers/media/base.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging from abc import ABCMeta, abstractmethod -from collections.abc import AsyncGenerator, Iterable, Mapping from contextlib import suppress from time import time from typing import TYPE_CHECKING, Any, Generic, TypeVar @@ -25,6 +24,8 @@ from music_assistant.common.models.media_items import ( from music_assistant.constants import DB_TABLE_PROVIDER_MAPPINGS, ROOT_LOGGER_NAME if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Iterable, Mapping + from music_assistant.server import MusicAssistant ItemCls = TypeVar("ItemCls", bound="MediaItemType") @@ -40,7 +41,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): item_cls: MediaItemType db_table: str - def __init__(self, mass: MusicAssistant): + def __init__(self, mass: MusicAssistant) -> None: """Initialize class.""" self.mass = mass self.base_query = f"SELECT * FROM {self.db_table}" @@ -193,7 +194,8 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): ) if not details: # we couldn't get a match from any of the providers, raise error - raise MediaNotFoundError(f"Item not found: {provider_instance_id_or_domain}/{item_id}") + msg = f"Item not found: {provider_instance_id_or_domain}/{item_id}" + raise MediaNotFoundError(msg) if not add_to_library: # return the provider item as-is return details @@ -282,7 +284,8 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): match = {"item_id": db_id} if db_row := await self.mass.music.database.get_row(self.db_table, match): return self.item_cls.from_dict(self._parse_db_row(db_row)) - raise MediaNotFoundError(f"{self.media_type.value} not found in library: {db_id}") + msg = f"{self.media_type.value} not found in library: {db_id}" + raise MediaNotFoundError(msg) async def get_library_item_by_prov_id( self, @@ -452,10 +455,11 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): # so not for tracks and albums (which rely on other objects) return fallback # all options exhausted, we really can not find this item - raise MediaNotFoundError( + msg = ( f"{self.media_type.value}://{item_id} not " f"found on provider {provider_instance_id_or_domain}" ) + raise MediaNotFoundError(msg) async def add_provider_mapping( self, item_id: str | int, provider_mapping: ProviderMapping diff --git a/music_assistant/server/controllers/media/playlists.py b/music_assistant/server/controllers/media/playlists.py index 2659aa15..18adbf86 100644 --- a/music_assistant/server/controllers/media/playlists.py +++ b/music_assistant/server/controllers/media/playlists.py @@ -5,8 +5,7 @@ from __future__ import annotations import asyncio import random import time -from collections.abc import AsyncGenerator -from typing import Any +from typing import TYPE_CHECKING, Any from music_assistant.common.helpers.datetime import utc_timestamp from music_assistant.common.helpers.json import serialize_to_json @@ -24,6 +23,9 @@ from music_assistant.server.helpers.compare import compare_strings from .base import MediaControllerBase +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + class PlaylistController(MediaControllerBase[Playlist]): """Controller managing MediaItems of type Playlist.""" @@ -32,7 +34,7 @@ class PlaylistController(MediaControllerBase[Playlist]): media_type = MediaType.PLAYLIST item_cls = Playlist - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """Initialize class.""" super().__init__(*args, **kwargs) self._db_add_lock = asyncio.Lock() @@ -61,16 +63,16 @@ class PlaylistController(MediaControllerBase[Playlist]): metadata_lookup = False item = Playlist.from_item_mapping(item) if not isinstance(item, Playlist): - raise InvalidDataError( - "Not a valid Playlist object (ItemMapping can not be added to db)" - ) + msg = "Not a valid Playlist object (ItemMapping can not be added to db)" + raise InvalidDataError(msg) if not item.provider_mappings: - raise InvalidDataError("Playlist is missing provider mapping(s)") + msg = "Playlist is missing provider mapping(s)" + raise InvalidDataError(msg) # check for existing item first library_item = None if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider): # existing item match by provider id - library_item = await self.update_item_in_library(cur_item.item_id, item) # noqa: SIM114 + library_item = await self.update_item_in_library(cur_item.item_id, item) elif cur_item := await self.get_library_item_by_external_ids(item.external_ids): # existing item match by external id library_item = await self.update_item_in_library(cur_item.item_id, item) @@ -171,7 +173,8 @@ class PlaylistController(MediaControllerBase[Playlist]): None, ) if provider is None: - raise ProviderUnavailableError("No provider available which allows playlists creation.") + msg = "No provider available which allows playlists creation." + raise ProviderUnavailableError(msg) # create playlist on the provider playlist = await provider.create_playlist(name) @@ -183,9 +186,11 @@ class PlaylistController(MediaControllerBase[Playlist]): db_id = int(db_playlist_id) # ensure integer playlist = await self.get_library_item(db_id) if not playlist: - raise MediaNotFoundError(f"Playlist with id {db_id} not found") + msg = f"Playlist with id {db_id} not found" + raise MediaNotFoundError(msg) if not playlist.is_editable: - raise InvalidDataError(f"Playlist {playlist.name} is not editable") + msg = f"Playlist {playlist.name} is not editable" + raise InvalidDataError(msg) for uri in uris: self.mass.create_task(self.add_playlist_track(db_id, uri)) @@ -195,9 +200,11 @@ class PlaylistController(MediaControllerBase[Playlist]): # we can only edit playlists that are in the database (marked as editable) playlist = await self.get_library_item(db_id) if not playlist: - raise MediaNotFoundError(f"Playlist with id {db_id} not found") + msg = f"Playlist with id {db_id} not found" + raise MediaNotFoundError(msg) if not playlist.is_editable: - raise InvalidDataError(f"Playlist {playlist.name} is not editable") + msg = f"Playlist {playlist.name} is not editable" + raise InvalidDataError(msg) # make sure we have recent full track details track = await self.mass.music.get_item_by_uri(track_uri) assert track.media_type == MediaType.TRACK @@ -221,7 +228,8 @@ class PlaylistController(MediaControllerBase[Playlist]): track_prov.provider_domain == playlist_prov.provider_domain and track_prov.item_id in cur_playlist_track_ids ): - raise InvalidDataError("Track already exists in playlist {playlist.name}") + msg = "Track already exists in playlist {playlist.name}" + raise InvalidDataError(msg) # add track to playlist # we can only add a track to a provider playlist if track is available on that provider # a track can contain multiple versions on the same provider @@ -242,9 +250,8 @@ class PlaylistController(MediaControllerBase[Playlist]): track_id_to_add = track_version.item_id break if not track_id_to_add: - raise MediaNotFoundError( - f"Track is not available on provider {playlist_prov.provider_domain}" - ) + msg = f"Track is not available on provider {playlist_prov.provider_domain}" + raise MediaNotFoundError(msg) # actually add the tracks to the playlist on the provider provider = self.mass.get_provider(playlist_prov.provider_instance) await provider.add_playlist_tracks(playlist_prov.item_id, [track_id_to_add]) @@ -258,9 +265,11 @@ class PlaylistController(MediaControllerBase[Playlist]): db_id = int(db_playlist_id) # ensure integer playlist = await self.get_library_item(db_id) if not playlist: - raise MediaNotFoundError(f"Playlist with id {db_id} not found") + msg = f"Playlist with id {db_id} not found" + raise MediaNotFoundError(msg) if not playlist.is_editable: - raise InvalidDataError(f"Playlist {playlist.name} is not editable") + msg = f"Playlist {playlist.name} is not editable" + raise InvalidDataError(msg) for prov_mapping in playlist.provider_mappings: provider = self.mass.get_provider(prov_mapping.provider_instance) if ProviderFeature.PLAYLIST_TRACKS_EDIT not in provider.supported_features: @@ -373,10 +382,11 @@ class PlaylistController(MediaControllerBase[Playlist]): return random.sample(list(radio_items), len(radio_items)) async def _get_dynamic_tracks( - self, media_item: Playlist, limit: int = 25 # noqa: ARG002 + self, + media_item: Playlist, + limit: int = 25, ) -> list[Track]: """Get dynamic list of tracks for given item, fallback/default implementation.""" # TODO: query metadata provider(s) to get similar tracks (or tracks from similar artists) - raise UnsupportedFeaturedException( - "No Music Provider found that supports requesting similar tracks." - ) + msg = "No Music Provider found that supports requesting similar tracks." + raise UnsupportedFeaturedException(msg) diff --git a/music_assistant/server/controllers/media/radio.py b/music_assistant/server/controllers/media/radio.py index ccbf9271..c4b1c539 100644 --- a/music_assistant/server/controllers/media/radio.py +++ b/music_assistant/server/controllers/media/radio.py @@ -22,7 +22,7 @@ class RadioController(MediaControllerBase[Radio]): media_type = MediaType.RADIO item_cls = Radio - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """Initialize class.""" super().__init__(*args, **kwargs) self._db_add_lock = asyncio.Lock() @@ -69,16 +69,18 @@ class RadioController(MediaControllerBase[Radio]): metadata_lookup = False item = Radio.from_item_mapping(item) if not isinstance(item, Radio): - raise InvalidDataError("Not a valid Radio object") + msg = "Not a valid Radio object" + raise InvalidDataError(msg) if not item.provider_mappings: - raise InvalidDataError("Radio is missing provider mapping(s)") + msg = "Radio is missing provider mapping(s)" + raise InvalidDataError(msg) if metadata_lookup: await self.mass.metadata.get_radio_metadata(item) # check for existing item first library_item = None if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider): # existing item match by provider id - library_item = await self.update_item_in_library(cur_item.item_id, item) # noqa: SIM114 + library_item = await self.update_item_in_library(cur_item.item_id, item) elif cur_item := await self.get_library_item_by_external_ids(item.external_ids): # existing item match by external id library_item = await self.update_item_in_library(cur_item.item_id, item) @@ -170,8 +172,10 @@ class RadioController(MediaControllerBase[Radio]): limit: int = 25, ) -> list[Track]: """Generate a dynamic list of tracks based on the item's content.""" - raise NotImplementedError("Dynamic tracks not supported for Radio MediaItem") + msg = "Dynamic tracks not supported for Radio MediaItem" + raise NotImplementedError(msg) async def _get_dynamic_tracks(self, media_item: Radio, limit: int = 25) -> list[Track]: """Get dynamic list of tracks for given item, fallback/default implementation.""" - raise NotImplementedError("Dynamic tracks not supported for Radio MediaItem") + msg = "Dynamic tracks not supported for Radio MediaItem" + raise NotImplementedError(msg) diff --git a/music_assistant/server/controllers/media/tracks.py b/music_assistant/server/controllers/media/tracks.py index 4f00d267..b3068511 100644 --- a/music_assistant/server/controllers/media/tracks.py +++ b/music_assistant/server/controllers/media/tracks.py @@ -32,7 +32,7 @@ class TracksController(MediaControllerBase[Track]): media_type = MediaType.TRACK item_cls = Track - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """Initialize class.""" super().__init__(*args, **kwargs) self.base_query = ( @@ -123,11 +123,14 @@ class TracksController(MediaControllerBase[Track]): async def add_item_to_library(self, item: Track, metadata_lookup: bool = True) -> Track: """Add track to library and return the new database item.""" if not isinstance(item, Track): - raise InvalidDataError("Not a valid Track object (ItemMapping can not be added to db)") + msg = "Not a valid Track object (ItemMapping can not be added to db)" + raise InvalidDataError(msg) if not item.artists: - raise InvalidDataError("Track is missing artist(s)") + msg = "Track is missing artist(s)" + raise InvalidDataError(msg) if not item.provider_mappings: - raise InvalidDataError("Track is missing provider mapping(s)") + msg = "Track is missing provider mapping(s)" + raise InvalidDataError(msg) # grab additional metadata if metadata_lookup: await self.mass.metadata.get_track_metadata(item) @@ -147,7 +150,7 @@ class TracksController(MediaControllerBase[Track]): library_item = None if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider): # existing item match by provider id - library_item = await self.update_item_in_library(cur_item.item_id, item) # noqa: SIM114 + library_item = await self.update_item_in_library(cur_item.item_id, item) elif cur_item := await self.get_library_item_by_external_ids(item.external_ids): # existing item match by external id library_item = await self.update_item_in_library(cur_item.item_id, item) @@ -363,17 +366,17 @@ class TracksController(MediaControllerBase[Track]): if ProviderFeature.SIMILAR_TRACKS not in prov.supported_features: return [] # Grab similar tracks from the music provider - similar_tracks = await prov.get_similar_tracks(prov_track_id=item_id, limit=limit) - return similar_tracks + return await prov.get_similar_tracks(prov_track_id=item_id, limit=limit) async def _get_dynamic_tracks( - self, media_item: Track, limit: int = 25 # noqa: ARG002 + self, + media_item: Track, + limit: int = 25, ) -> list[Track]: """Get dynamic list of tracks for given item, fallback/default implementation.""" # TODO: query metadata provider(s) to get similar tracks (or tracks from similar artists) - raise UnsupportedFeaturedException( - "No Music Provider found that supports requesting similar tracks." - ) + msg = "No Music Provider found that supports requesting similar tracks." + raise UnsupportedFeaturedException(msg) async def _add_library_item(self, item: Track) -> Track: """Add a new item record to the database.""" @@ -411,7 +414,9 @@ class TracksController(MediaControllerBase[Track]): # return the full item we just added return await self.get_library_item(db_id) - async def _set_track_album(self, db_id: int, album: Album, disc_number: int, track_number: int): + async def _set_track_album( + self, db_id: int, album: Album, disc_number: int, track_number: int + ) -> None: """Store AlbumTrack info.""" db_album = None if album.provider == "library": diff --git a/music_assistant/server/controllers/metadata.py b/music_assistant/server/controllers/metadata.py old mode 100755 new mode 100644 index f8e15bc1..072bfb51 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -35,11 +35,11 @@ from music_assistant.constants import ( from music_assistant.server.helpers.compare import compare_strings from music_assistant.server.helpers.images import create_collage, get_image_thumb from music_assistant.server.models.core_controller import CoreController -from music_assistant.server.providers.musicbrainz import MusicbrainzProvider if TYPE_CHECKING: from music_assistant.common.models.config_entries import CoreConfig from music_assistant.server.models.metadata_provider import MetadataProvider + from music_assistant.server.providers.musicbrainz import MusicbrainzProvider LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.metadata") @@ -61,7 +61,7 @@ class MetaDataController(CoreController): ) self.manifest.icon = "book-information-variant" - async def setup(self, config: CoreConfig) -> None: # noqa: ARG002 + async def setup(self, config: CoreConfig) -> None: """Async initialize of module.""" self.mass.streams.register_dynamic_route("/imageproxy", self.handle_imageproxy) @@ -94,7 +94,7 @@ class MetaDataController(CoreController): def start_scan(self) -> None: """Start background scan for missing metadata.""" - async def scan_artist_metadata(): + async def scan_artist_metadata() -> None: """Background task that scans for artists missing metadata on filesystem providers.""" if self.scan_busy: return diff --git a/music_assistant/server/controllers/music.py b/music_assistant/server/controllers/music.py old mode 100755 new mode 100644 index 8af01392..b4db9e12 --- a/music_assistant/server/controllers/music.py +++ b/music_assistant/server/controllers/music.py @@ -6,7 +6,6 @@ import asyncio import os import shutil import statistics -from collections.abc import AsyncGenerator from contextlib import suppress from itertools import zip_longest from typing import TYPE_CHECKING @@ -42,7 +41,6 @@ from music_assistant.constants import ( from music_assistant.server.helpers.api import api_command from music_assistant.server.helpers.database import DatabaseConnection from music_assistant.server.models.core_controller import CoreController -from music_assistant.server.models.music_provider import MusicProvider from .media.albums import AlbumsController from .media.artists import ArtistsController @@ -51,7 +49,10 @@ from .media.radio import RadioController from .media.tracks import TracksController if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from music_assistant.common.models.config_entries import CoreConfig + from music_assistant.server.models.music_provider import MusicProvider DEFAULT_SYNC_INTERVAL = 3 * 60 # default sync interval in minutes CONF_SYNC_INTERVAL = "sync_interval" @@ -84,8 +85,8 @@ class MusicController(CoreController): async def get_config_entries( self, - action: str | None = None, # noqa: ARG002 - values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, ) -> tuple[ConfigEntry, ...]: """Return all Config Entries for this core module (if any).""" return ( @@ -457,7 +458,7 @@ class MusicController(CoreController): async def set_track_loudness( self, item_id: str, provider_instance_id_or_domain: str, loudness: int - ): + ) -> None: """List integrated loudness for a track in db.""" await self.database.insert( DB_TABLE_TRACK_LOUDNESS, @@ -498,7 +499,7 @@ class MusicController(CoreController): async def mark_item_played( self, media_type: MediaType, item_id: str, provider_instance_id_or_domain: str - ): + ) -> None: """Mark item as played in playlog.""" timestamp = utc_timestamp() await self.database.insert( @@ -520,7 +521,7 @@ class MusicController(CoreController): | TracksController | RadioController | PlaylistController - ): # noqa: E501 + ): """Return controller for MediaType.""" if media_type == MediaType.ARTIST: return self.artists @@ -549,7 +550,9 @@ class MusicController(CoreController): domains.add(provider.domain) return instances - def _start_provider_sync(self, provider_instance: str, media_types: tuple[MediaType, ...]): + def _start_provider_sync( + self, provider_instance: str, media_types: tuple[MediaType, ...] + ) -> None: """Start sync task on provider and track progress.""" # check if we're not already running a sync task for this provider/mediatype for sync_task in self.in_progress_syncs: @@ -583,7 +586,7 @@ class MusicController(CoreController): self.mass.signal_event(EventType.SYNC_TASKS_UPDATED, data=self.in_progress_syncs) - def on_sync_task_done(task: asyncio.Task): # noqa: ARG001 + def on_sync_task_done(task: asyncio.Task) -> None: self.in_progress_syncs.remove(sync_spec) if task_err := task.exception(): self.logger.warning( @@ -624,7 +627,7 @@ class MusicController(CoreController): # NOTE: sync_interval is stored in minutes, we need seconds self.mass.loop.call_later(sync_interval * 60, self._schedule_sync) - async def _setup_database(self): + async def _setup_database(self) -> None: """Initialize database.""" db_path = os.path.join(self.mass.storage_path, "library.db") self.database = DatabaseConnection(db_path) diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py old mode 100755 new mode 100644 index 94daafff..99ce19a4 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -5,7 +5,6 @@ from __future__ import annotations import logging import random import time -from collections.abc import AsyncGenerator from contextlib import suppress from typing import TYPE_CHECKING, Any @@ -39,7 +38,7 @@ from music_assistant.server.helpers.audio import set_stream_details from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import AsyncGenerator, Iterator from music_assistant.common.models.media_items import Album, Artist, Track from music_assistant.common.models.player import Player @@ -86,8 +85,8 @@ class PlayerQueuesController(CoreController): async def get_config_entries( self, - action: str | None = None, # noqa: ARG002 - values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, ) -> tuple[ConfigEntry, ...]: """Return all Config Entries for this core module (if any).""" enqueue_options = tuple(ConfigValueOption(x.name, x.value) for x in QueueOption) @@ -199,7 +198,7 @@ class PlayerQueuesController(CoreController): @api_command("players/queue/get_active_queue") def get_active_queue(self, player_id: str) -> PlayerQueue: """Return the current active/synced queue for a player.""" - if player := self.mass.players.get(player_id): # noqa: SIM102 + if player := self.mass.players.get(player_id): # account for player that is synced (sync child) if player.synced_to: return self.get_active_queue(player.synced_to) @@ -289,7 +288,8 @@ class PlayerQueuesController(CoreController): media_item = await self.mass.music.get_item_by_uri(item) except MusicAssistantError as err: # invalid MA uri or item not found error - raise MediaNotFoundError(f"Invalid uri: {item}") from err + msg = f"Invalid uri: {item}" + raise MediaNotFoundError(msg) from err elif isinstance(item, dict): media_item = media_from_dict(item) else: @@ -442,7 +442,8 @@ class PlayerQueuesController(CoreController): queue = self._queues[queue_id] item_index = self.index_by_id(queue_id, queue_item_id) if item_index <= queue.index_in_buffer: - raise IndexError(f"{item_index} is already played/buffered") + msg = f"{item_index} is already played/buffered" + raise IndexError(msg) queue_items = self._queue_items[queue_id] queue_items = queue_items.copy() @@ -628,7 +629,8 @@ class PlayerQueuesController(CoreController): resume_pos = 0 await self.play_index(queue_id, resume_item.queue_item_id, resume_pos, fade_in) else: - raise QueueEmpty(f"Resume queue requested but queue {queue_id} is empty") + msg = f"Resume queue requested but queue {queue_id} is empty" + raise QueueEmpty(msg) @api_command("players/queue/play_index") async def play_index( @@ -647,7 +649,8 @@ class PlayerQueuesController(CoreController): index = self.index_by_id(queue_id, index) queue_item = self.get_item(queue_id, index) if queue_item is None: - raise FileNotFoundError(f"Unknown index/id: {index}") + msg = f"Unknown index/id: {index}" + raise FileNotFoundError(msg) queue.current_index = index queue.index_in_buffer = index queue.flow_mode_start_index = index @@ -691,7 +694,9 @@ class PlayerQueuesController(CoreController): self.on_player_update(player, {}) def on_player_update( - self, player: Player, changed_values: dict[str, tuple[Any, Any]] # noqa: ARG002 + self, + player: Player, + changed_values: dict[str, tuple[Any, Any]], ) -> None: """ Call when a PlayerQueue needs to be updated (e.g. when player updates). @@ -800,7 +805,8 @@ class PlayerQueuesController(CoreController): """ queue = self.get(queue_id) if not queue: - raise PlayerUnavailableError(f"PlayerQueue {queue_id} is not available") + msg = f"PlayerQueue {queue_id} is not available" + raise PlayerUnavailableError(msg) if current_item_id_or_index is None: cur_index = queue.index_in_buffer elif isinstance(current_item_id_or_index, str): @@ -811,7 +817,8 @@ class PlayerQueuesController(CoreController): while True: next_index = self._get_next_index(queue_id, cur_index + idx) if next_index is None: - raise QueueEmpty("No more tracks left in the queue.") + msg = "No more tracks left in the queue." + raise QueueEmpty(msg) next_item = self.get_item(queue_id, next_index) try: # Check if the QueueItem is playable. For example, YT Music returns Radio Items @@ -826,7 +833,8 @@ class PlayerQueuesController(CoreController): next_item = None idx += 1 if next_item is None: - raise QueueEmpty("No more (playable) tracks left in the queue.") + msg = "No more (playable) tracks left in the queue." + raise QueueEmpty(msg) return next_item # Main queue manipulation methods @@ -975,7 +983,7 @@ class PlayerQueuesController(CoreController): duration = current_item.duration seconds_remaining = int(duration - player.corrected_elapsed_time) - async def _enqueue_next(index: int, supports_enqueue: bool = False): + async def _enqueue_next(index: int, supports_enqueue: bool = False) -> None: with suppress(QueueEmpty): next_item = await self.preload_next_item(queue.queue_id, index) if supports_enqueue: diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py old mode 100755 new mode 100644 index 7feb2088..671f06bc --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import functools import logging -from collections.abc import Awaitable, Callable, Coroutine, Iterable, Iterator from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, cast import shortuuid @@ -26,7 +25,6 @@ from music_assistant.common.models.errors import ( UnsupportedFeaturedException, ) from music_assistant.common.models.player import DeviceInfo, Player -from music_assistant.common.models.queue_item import QueueItem from music_assistant.constants import ( CONF_AUTO_PLAY, CONF_GROUP_MEMBERS, @@ -40,7 +38,10 @@ from music_assistant.server.models.core_controller import CoreController from music_assistant.server.models.player_provider import PlayerProvider if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Coroutine, Iterable, Iterator + from music_assistant.common.models.config_entries import CoreConfig + from music_assistant.common.models.queue_item import QueueItem LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.players") @@ -50,7 +51,7 @@ _P = ParamSpec("_P") def log_player_command( - func: Callable[Concatenate[_PlayerControllerT, _P], Awaitable[_R]] + func: Callable[Concatenate[_PlayerControllerT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_PlayerControllerT, _P], Coroutine[Any, Any, _R | None]]: """Check and log commands to players.""" @@ -93,7 +94,7 @@ class PlayerController(CoreController): self.manifest.icon = "speaker-multiple" self._poll_task: asyncio.Task | None = None - async def setup(self, config: CoreConfig) -> None: # noqa: ARG002 + async def setup(self, config: CoreConfig) -> None: """Async initialize of module.""" self._poll_task = self.mass.create_task(self._poll_players()) @@ -133,10 +134,12 @@ class PlayerController(CoreController): """Return Player by player_id.""" if player := self._players.get(player_id): if (not player.available or not player.enabled) and raise_unavailable: - raise PlayerUnavailableError(f"Player {player_id} is not available") + msg = f"Player {player_id} is not available" + raise PlayerUnavailableError(msg) return player if raise_unavailable: - raise PlayerUnavailableError(f"Player {player_id} is not available") + msg = f"Player {player_id} is not available" + raise PlayerUnavailableError(msg) return None @api_command("players/get_by_name") @@ -162,7 +165,8 @@ class PlayerController(CoreController): player_id = player.player_id if player_id in self._players: - raise AlreadyRegisteredError(f"Player {player_id} is already registered") + msg = f"Player {player_id} is already registered" + raise AlreadyRegisteredError(msg) # make sure a default config exists self.mass.config.create_default_player_config( @@ -456,9 +460,8 @@ class PlayerController(CoreController): await self.cmd_group_volume(player_id, volume_level) return if PlayerFeature.VOLUME_SET not in player.supported_features: - raise UnsupportedFeaturedException( - f"Player {player.display_name} does not support volume_set" - ) + msg = f"Player {player.display_name} does not support volume_set" + raise UnsupportedFeaturedException(msg) player_provider = self.get_player_provider(player_id) await player_provider.cmd_volume_set(player_id, volume_level) @@ -546,9 +549,8 @@ class PlayerController(CoreController): player = self.get(player_id, True) assert player if PlayerFeature.VOLUME_MUTE not in player.supported_features: - raise UnsupportedFeaturedException( - f"Player {player.display_name} does not support muting" - ) + msg = f"Player {player.display_name} does not support muting" + raise UnsupportedFeaturedException(msg) player_provider = self.get_player_provider(player_id) await player_provider.cmd_volume_mute(player_id, muted) @@ -563,9 +565,8 @@ class PlayerController(CoreController): player = self.get(player_id, True) if PlayerFeature.SEEK not in player.supported_features: - raise UnsupportedFeaturedException( - f"Player {player.display_name} does not support seeking" - ) + msg = f"Player {player.display_name} does not support seeking" + raise UnsupportedFeaturedException(msg) player_prov = self.mass.players.get_player_provider(player_id) await player_prov.cmd_seek(player_id, position) @@ -607,7 +608,7 @@ class PlayerController(CoreController): fade_in=fade_in, ) - async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem): + async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem) -> None: """ Handle enqueuing of the next queue item on the player. @@ -651,13 +652,11 @@ class PlayerController(CoreController): assert child_player assert parent_player if PlayerFeature.SYNC not in child_player.supported_features: - raise UnsupportedFeaturedException( - f"Player {child_player.name} does not support (un)sync commands" - ) + msg = f"Player {child_player.name} does not support (un)sync commands" + raise UnsupportedFeaturedException(msg) if PlayerFeature.SYNC not in parent_player.supported_features: - raise UnsupportedFeaturedException( - f"Player {parent_player.name} does not support (un)sync commands" - ) + msg = f"Player {parent_player.name} does not support (un)sync commands" + raise UnsupportedFeaturedException(msg) if child_player.synced_to: if child_player.synced_to == parent_player.player_id: # nothing to do: already synced to this parent @@ -684,7 +683,8 @@ class PlayerController(CoreController): """ player = self.get(player_id, True) if PlayerFeature.SYNC not in player.supported_features: - raise UnsupportedFeaturedException(f"Player {player.name} does not support syncing") + msg = f"Player {player.name} does not support syncing" + raise UnsupportedFeaturedException(msg) if not player.synced_to: LOGGER.info( "Ignoring command to unsync player %s " @@ -711,7 +711,8 @@ class PlayerController(CoreController): """ # perform basic checks if (player_prov := self.mass.get_provider(provider)) is None: - raise ProviderUnavailableError(f"Provider {provider} is not available!") + msg = f"Provider {provider} is not available!" + raise ProviderUnavailableError(msg) if ProviderFeature.PLAYER_GROUP_CREATE in player_prov.supported_features: # provider supports group create feature: forward request to provider # the provider is itself responsible for @@ -720,9 +721,8 @@ class PlayerController(CoreController): if ProviderFeature.SYNC_PLAYERS in player_prov.supported_features: # default syncgroup implementation return await self._create_syncgroup(player_prov.instance_id, name, members) - raise UnsupportedFeaturedException( - f"Provider {player_prov.name} does not support creating groups" - ) + msg = f"Provider {player_prov.name} does not support creating groups" + raise UnsupportedFeaturedException(msg) def _check_redirect(self, player_id: str) -> str: """Check if playback related command should be redirected.""" @@ -880,10 +880,9 @@ class PlayerController(CoreController): enabled=True, values={CONF_GROUP_MEMBERS: members}, ) - player = self._register_syncgroup( + return self._register_syncgroup( group_player_id=new_group_id, provider=provider, name=name, members=members ) - return player def get_sync_leader(self, group_player: Player) -> Player | None: """Get the active sync leader player for a syncgroup or synced player.""" @@ -941,7 +940,7 @@ class PlayerController(CoreController): break else: # edge case: no child player is (yet) available; postpone register - return + return None player = Player( player_id=group_player_id, provider=provider, @@ -972,7 +971,7 @@ class PlayerController(CoreController): # guard, this should be caught in the player controller but just in case... return - powered_childs = [x for x in self.iter_group_members(group_player, True)] + powered_childs = list(self.iter_group_members(group_player, True)) if not new_power and child_player in powered_childs: powered_childs.remove(child_player) if new_power and child_player not in powered_childs: @@ -1002,7 +1001,7 @@ class PlayerController(CoreController): group_player.display_name, ) - async def forced_resync(): + async def forced_resync() -> None: # we need to wait a bit here to not run into massive race conditions await asyncio.sleep(5) await self._sync_syncgroup(group_player.player_id) diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index 33080999..d85be427 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -12,7 +12,6 @@ import asyncio import logging import time import urllib.parse -from collections.abc import AsyncGenerator from contextlib import suppress from typing import TYPE_CHECKING @@ -28,8 +27,6 @@ from music_assistant.common.models.config_entries import ( from music_assistant.common.models.enums import ConfigEntryType, ContentType from music_assistant.common.models.errors import MediaNotFoundError, QueueEmpty from music_assistant.common.models.media_items import AudioFormat -from music_assistant.common.models.player_queue import PlayerQueue -from music_assistant.common.models.queue_item import QueueItem from music_assistant.constants import ( CONF_BIND_IP, CONF_BIND_PORT, @@ -54,8 +51,12 @@ from music_assistant.server.helpers.webserver import Webserver from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from music_assistant.common.models.config_entries import CoreConfig from music_assistant.common.models.player import Player + from music_assistant.common.models.player_queue import PlayerQueue + from music_assistant.common.models.queue_item import QueueItem DEFAULT_STREAM_HEADERS = { @@ -70,6 +71,8 @@ DEFAULT_STREAM_HEADERS = { FLOW_MAX_SAMPLE_RATE = 192000 FLOW_MAX_BIT_DEPTH = 24 +# pylint:disable=too-many-locals + class MultiClientStreamJob: """Representation of a (multiclient) Audio Queue stream job/task. @@ -173,9 +176,8 @@ class MultiClientStreamJob: if self._all_clients_connected.is_set(): # client subscribes while we're already started - we dont support that (for now?) - raise RuntimeError( - f"Client {player_id} is joining while the stream is already started" - ) + msg = f"Client {player_id} is joining while the stream is already started" + raise RuntimeError(msg) self.logger.debug("Subscribed client %s", player_id) if len(self.subscribed_players) == len(self.expected_players): @@ -211,7 +213,11 @@ class MultiClientStreamJob: """Feed audio chunks to StreamJob subscribers.""" chunk_num = 0 async for chunk in self.stream_controller.get_flow_stream( - self.queue, self.start_queue_item, self.pcm_format, self.seek_position, self.fade_in + self.queue, + self.start_queue_item, + self.pcm_format, + self.seek_position, + self.fade_in, ): chunk_num += 1 if chunk_num == 1: @@ -221,7 +227,7 @@ class MultiClientStreamJob: await self._all_clients_connected.wait() except TimeoutError: if len(self.subscribed_players) == 0: - self.stream_controller.logger.error( + self.stream_controller.logger.exception( "Abort multi client stream job for queue %s: " "clients did not connect within timeout", self.queue.display_name, @@ -261,7 +267,7 @@ class StreamsController(CoreController): domain: str = "streams" - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """Initialize instance.""" super().__init__(*args, **kwargs) self._server = Webserver(self.logger, enable_dynamic_routes=True) @@ -284,8 +290,8 @@ class StreamsController(CoreController): async def get_config_entries( self, - action: str | None = None, # noqa: ARG002 - values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, ) -> tuple[ConfigEntry, ...]: """Return all Config Entries for this core module (if any).""" default_ip = await get_ip() @@ -390,7 +396,8 @@ class StreamsController(CoreController): fmt = output_codec.value # handle raw pcm if output_codec.is_pcm(): - raise RuntimeError("PCM is not possible as output format") + msg = "PCM is not possible as output format" + raise RuntimeError(msg) query_params = {} base_path = "flow" if flow_mode else "single" url = f"{self._server.base_url}/{queue_item.queue_id}/{base_path}/{queue_item.queue_item_id}.{fmt}" # noqa: E501 @@ -419,7 +426,7 @@ class StreamsController(CoreController): This is called by player/sync group implementations to start streaming the queue audio to multiple players at once. """ - if existing_job := self.multi_client_jobs.pop(queue_id, None): # noqa: SIM102 + if existing_job := self.multi_client_jobs.pop(queue_id, None): # cleanup existing job first if not existing_job.finished: self.logger.warning("Detected existing (running) stream job for queue %s", queue_id) @@ -486,7 +493,9 @@ class StreamsController(CoreController): # all checks passed, start streaming! self.logger.debug( - "Start serving audio stream for QueueItem %s to %s", queue_item.uri, queue.display_name + "Start serving audio stream for QueueItem %s to %s", + queue_item.uri, + queue.display_name, ) queue.index_in_buffer = self.mass.player_queues.index_by_id(queue_id, queue_item_id) # collect player specific ffmpeg args to re-encode the source PCM stream @@ -505,7 +514,7 @@ class StreamsController(CoreController): async with AsyncProcess(ffmpeg_args, True) as ffmpeg_proc: # feed stdin with pcm audio chunks from origin - async def read_audio(): + async def read_audio() -> None: try: async for chunk in get_media_stream( self.mass, @@ -593,7 +602,7 @@ class StreamsController(CoreController): async with AsyncProcess(ffmpeg_args, True) as ffmpeg_proc: # feed stdin with pcm audio chunks from origin - async def read_audio(): + async def read_audio() -> None: try: async for chunk in self.get_flow_stream( queue=queue, @@ -716,7 +725,7 @@ class StreamsController(CoreController): async with AsyncProcess(ffmpeg_args, True) as ffmpeg_proc: # feed stdin with pcm audio chunks from origin - async def read_audio(): + async def read_audio() -> None: try: async for chunk in streamjob.subscribe(child_player_id): try: diff --git a/music_assistant/server/controllers/webserver.py b/music_assistant/server/controllers/webserver.py index 6d82c47f..0917c969 100644 --- a/music_assistant/server/controllers/webserver.py +++ b/music_assistant/server/controllers/webserver.py @@ -12,7 +12,6 @@ import inspect import logging import os import urllib.parse -from collections.abc import Awaitable from concurrent import futures from contextlib import suppress from functools import partial @@ -32,7 +31,6 @@ from music_assistant.common.models.api import ( from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueOption from music_assistant.common.models.enums import ConfigEntryType from music_assistant.common.models.errors import InvalidCommand -from music_assistant.common.models.event import MassEvent from music_assistant.constants import CONF_BIND_IP, CONF_BIND_PORT from music_assistant.server.helpers.api import APICommandHandler, parse_arguments from music_assistant.server.helpers.audio import get_preview_stream @@ -41,7 +39,10 @@ from music_assistant.server.helpers.webserver import Webserver from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: + from collections.abc import Awaitable + from music_assistant.common.models.config_entries import ConfigValueType, CoreConfig + from music_assistant.common.models.event import MassEvent DEFAULT_SERVER_PORT = 8095 CONF_BASE_URL = "base_url" @@ -56,7 +57,7 @@ class WebserverController(CoreController): domain: str = "webserver" - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """Initialize instance.""" super().__init__(*args, **kwargs) self._server = Webserver(self.logger, enable_dynamic_routes=False) @@ -74,8 +75,8 @@ class WebserverController(CoreController): async def get_config_entries( self, - action: str | None = None, # noqa: ARG002 - values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, ) -> tuple[ConfigEntry, ...]: """Return all Config Entries for this core module (if any).""" default_publish_ip = await get_ip() @@ -210,7 +211,7 @@ class WebserverController(CoreController): await resp.write(chunk) return resp - async def _handle_server_info(self, request: web.Request) -> web.Response: # noqa: ARG002 + async def _handle_server_info(self, request: web.Request) -> web.Response: """Handle request for server info.""" return web.json_response(self.mass.get_server_info().to_dict()) @@ -222,7 +223,7 @@ class WebserverController(CoreController): finally: self.clients.remove(connection) - async def _handle_application_log(self, request: web.Request) -> web.Response: # noqa: ARG002 + async def _handle_application_log(self, request: web.Request) -> web.Response: """Handle request to get the application log.""" log_data = await self.mass.get_application_log() return web.Response(text=log_data, content_type="text/text") @@ -263,7 +264,7 @@ class WebsocketClientHandler: try: async with asyncio.timeout(10): await wsock.prepare(request) - except asyncio.TimeoutError: + except TimeoutError: self._logger.warning("Timeout preparing request from %s", request.remote) return wsock @@ -406,7 +407,7 @@ class WebsocketClientHandler: try: self._to_write.put_nowait(_message) except asyncio.QueueFull: - self._logger.error("Client exceeded max pending messages: %s", MAX_PENDING_MSG) + self._logger.exception("Client exceeded max pending messages: %s", MAX_PENDING_MSG) self._cancel() diff --git a/music_assistant/server/helpers/api.py b/music_assistant/server/helpers/api.py index 95af94bd..c0bf9345 100644 --- a/music_assistant/server/helpers/api.py +++ b/music_assistant/server/helpers/api.py @@ -9,11 +9,7 @@ from dataclasses import MISSING, dataclass from datetime import datetime from enum import Enum from types import NoneType, UnionType -from typing import TYPE_CHECKING, Any, TypeVar, Union, get_args, get_origin, get_type_hints - -if TYPE_CHECKING: - pass - +from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints LOGGER = logging.getLogger(__name__) @@ -84,7 +80,8 @@ def parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) """Try to parse a value from raw (json) data and type annotations.""" if isinstance(value, dict) and hasattr(value_type, "from_dict"): if "media_type" in value and value["media_type"] != value_type.media_type: - raise ValueError("Invalid MediaType") + msg = "Invalid MediaType" + raise ValueError(msg) return value_type.from_dict(value) if value is None and not isinstance(default, type(MISSING)): @@ -98,7 +95,7 @@ def parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) for subvalue in value if subvalue is not None ) - elif origin is dict: + if origin is dict: subkey_type = get_args(value_type)[0] subvalue_type = get_args(value_type)[1] return { @@ -107,7 +104,7 @@ def parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) ) for subkey, subvalue in value.items() } - elif origin is Union or origin is UnionType: + if origin is Union or origin is UnionType: # try all possible types sub_value_types = get_args(value_type) for sub_arg_type in sub_value_types: @@ -130,12 +127,13 @@ def parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) # failed to parse the (sub) value but None allowed, log only logging.getLogger(__name__).warn(err) return None - elif origin is type: - return eval(value) + if origin is type: + return eval(value) # pylint: disable=eval-used if value_type is Any: return value if value is None and value_type is not NoneType: - raise KeyError(f"`{name}` of type `{value_type}` is required.") + msg = f"`{name}` of type `{value_type}` is required." + raise KeyError(msg) try: if issubclass(value_type, Enum): # type: ignore[arg-type] @@ -151,8 +149,9 @@ def parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) if value_type is int and isinstance(value, str) and value.isnumeric(): return int(value) if not isinstance(value, value_type): # type: ignore[arg-type] - raise TypeError( + msg = ( f"Value {value} of type {type(value)} is invalid for {name}, " f"expected value of type {value_type}" ) + raise TypeError(msg) return value diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py index 0b8a9764..a072ce7f 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -7,7 +7,6 @@ import logging import os import re import struct -from collections.abc import AsyncGenerator from contextlib import suppress from io import BytesIO from time import time @@ -39,12 +38,14 @@ from .process import AsyncProcess, check_output from .util import create_tempfile if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from music_assistant.common.models.player_queue import QueueItem from music_assistant.server import MusicAssistant LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.audio") -# pylint:disable=consider-using-f-string +# pylint:disable=consider-using-f-string,too-many-locals,too-many-statements async def crossfade_pcm_parts( @@ -203,7 +204,7 @@ async def analyze_audio(mass: MusicAssistant, streamdetails: StreamDetails) -> N enable_stderr=True, ) as ffmpeg_proc: - async def writer(): + async def writer() -> None: """Task that grabs the source audio and feeds it to ffmpeg.""" music_prov = mass.get_provider(streamdetails.provider) chunk_count = 0 @@ -284,7 +285,8 @@ async def set_stream_details(mass: MusicAssistant, queue_item: QueueItem) -> Non break if not streamdetails: - raise MediaNotFoundError(f"Unable to retrieve streamdetails for {queue_item}") + msg = f"Unable to retrieve streamdetails for {queue_item}" + raise MediaNotFoundError(msg) # set queue_id on the streamdetails so we know what is being streamed streamdetails.queue_id = queue_item.queue_id @@ -426,7 +428,7 @@ async def get_media_stream( # noqa: PLR0915 async with AsyncProcess(args, enable_stdin=streamdetails.direct is None) as ffmpeg_proc: LOGGER.debug("start media stream for: %s", streamdetails.uri) - async def writer(): + async def writer() -> None: """Task that grabs the source audio and feeds it to ffmpeg.""" LOGGER.debug("writer started for %s", streamdetails.uri) music_prov = mass.get_provider(streamdetails.provider) @@ -495,9 +497,9 @@ async def get_media_stream( # noqa: PLR0915 streamdetails.seconds_streamed = bytes_sent / pcm_sample_size streamdetails.duration = seek_position + streamdetails.seconds_streamed - except (asyncio.CancelledError, GeneratorExit) as err: + except (asyncio.CancelledError, GeneratorExit): LOGGER.debug("media stream aborted for: %s", streamdetails.uri) - raise err + raise else: LOGGER.debug("finished media stream for: %s", streamdetails.uri) finally: @@ -709,7 +711,7 @@ async def get_preview_stream( args = input_args + output_args async with AsyncProcess(args, True) as ffmpeg_proc: - async def writer(): + async def writer() -> None: """Task that grabs the source audio and feeds it to ffmpeg.""" music_prov = mass.get_provider(streamdetails.provider) async for audio_chunk in music_prov.get_audio_stream(streamdetails, 30): @@ -732,7 +734,7 @@ async def get_silence( """Create stream of silence, encoded to format of choice.""" if output_format.content_type.is_pcm(): # pcm = just zeros - for _ in range(0, duration): + for _ in range(duration): yield b"\0" * int(output_format.sample_rate * (output_format.bit_depth / 8) * 2) return if output_format.content_type == ContentType.WAV: @@ -743,7 +745,7 @@ async def get_silence( bitspersample=output_format.bit_depth, duration=duration, ) - for _ in range(0, duration): + for _ in range(duration): yield b"\0" * int(output_format.sample_rate * (output_format.bit_depth / 8) * 2) return # use ffmpeg for all other encodings @@ -796,9 +798,12 @@ async def _get_ffmpeg_args( ffmpeg_present, libsoxr_support, version = await check_audio_support() if not ffmpeg_present: - raise AudioError( + msg = ( "FFmpeg binary is missing from system." - "Please install ffmpeg on your OS to enable playback.", + "Please install ffmpeg on your OS to enable playback." + ) + raise AudioError( + msg, ) major_version = int("".join(char for char in version.split(".")[0] if not char.isalpha())) diff --git a/music_assistant/server/helpers/auth.py b/music_assistant/server/helpers/auth.py index c609943a..a057abc2 100644 --- a/music_assistant/server/helpers/auth.py +++ b/music_assistant/server/helpers/auth.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: class AuthenticationHelper: """Context manager helper class for authentication with a forward and redirect URL.""" - def __init__(self, mass: MusicAssistant, session_id: str): + def __init__(self, mass: MusicAssistant, session_id: str) -> None: """ Initialize the Authentication Helper. diff --git a/music_assistant/server/helpers/compare.py b/music_assistant/server/helpers/compare.py index 259a5f21..9b3521c2 100644 --- a/music_assistant/server/helpers/compare.py +++ b/music_assistant/server/helpers/compare.py @@ -99,7 +99,8 @@ def compare_track( """Compare two track items and return True if they match.""" if base_item is None or compare_item is None: return False - assert isinstance(base_item, Track) and isinstance(compare_item, Track) + assert isinstance(base_item, Track) + assert isinstance(compare_item, Track) # return early on exact item_id match if compare_item_ids(base_item, compare_item): return True diff --git a/music_assistant/server/helpers/database.py b/music_assistant/server/helpers/database.py old mode 100755 new mode 100644 index 2d9cfb78..a15148d4 --- a/music_assistant/server/helpers/database.py +++ b/music_assistant/server/helpers/database.py @@ -2,18 +2,20 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Mapping -from typing import Any +from typing import TYPE_CHECKING, Any import aiosqlite +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Mapping + class DatabaseConnection: """Class that holds the (connection to the) database with some convenience helper functions.""" _db: aiosqlite.Connection - def __init__(self, db_path: str): + def __init__(self, db_path: str) -> None: """Initialize class.""" self.db_path = db_path @@ -29,8 +31,8 @@ class DatabaseConnection: async def get_rows( self, table: str, - match: dict = None, - order_by: str = None, + match: dict | None = None, + order_by: str | None = None, limit: int = 500, offset: int = 0, ) -> list[Mapping]: @@ -147,14 +149,14 @@ class DatabaseConnection: await self.execute(sql_query) await self._db.commit() - async def execute(self, query: str | str, values: dict = None) -> Any: + async def execute(self, query: str, values: dict | None = None) -> Any: """Execute command on the database.""" return await self._db.execute(query, values) async def iter_items( self, table: str, - match: dict = None, + match: dict | None = None, ) -> AsyncGenerator[Mapping, None]: """Iterate all items within a table.""" limit: int = 500 diff --git a/music_assistant/server/helpers/didl_lite.py b/music_assistant/server/helpers/didl_lite.py index dc7e84ca..159be910 100644 --- a/music_assistant/server/helpers/didl_lite.py +++ b/music_assistant/server/helpers/didl_lite.py @@ -81,5 +81,4 @@ def escape_string(data: str) -> str: data = data.replace("&", "&") # data = data.replace("?", "?") data = data.replace(">", ">") - data = data.replace("<", "<") - return data + return data.replace("<", "<") diff --git a/music_assistant/server/helpers/images.py b/music_assistant/server/helpers/images.py index 9f85eec1..4e54a1a5 100644 --- a/music_assistant/server/helpers/images.py +++ b/music_assistant/server/helpers/images.py @@ -10,10 +10,10 @@ from typing import TYPE_CHECKING import aiofiles from PIL import Image -from music_assistant.common.models.media_items import MediaItemImage from music_assistant.server.helpers.tags import get_embedded_image if TYPE_CHECKING: + from music_assistant.common.models.media_items import MediaItemImage from music_assistant.server import MusicAssistant from music_assistant.server.models.music_provider import MusicProvider @@ -30,7 +30,8 @@ async def get_image_data(mass: MusicAssistant, path_or_url: str, provider: str = # both online and offline image files as well as embedded images in media files if img_data := await get_embedded_image(path_or_url): return img_data - raise FileNotFoundError(f"Image not found: {path_or_url}") + msg = f"Image not found: {path_or_url}" + raise FileNotFoundError(msg) async def get_image_thumb( @@ -43,7 +44,7 @@ async def get_image_thumb( data = BytesIO() img = Image.open(BytesIO(img_data)) if size: - img.thumbnail((size, size), Image.LANCZOS) + img.thumbnail((size, size), Image.LANCZOS) # pylint: disable=no-member img.convert("RGB").save(data, "PNG", optimize=True) return data.getvalue() @@ -58,7 +59,7 @@ async def create_collage(mass: MusicAssistant, images: list[MediaItemImage]) -> collage = await asyncio.to_thread(_new_collage) - def _add_to_collage(img_data: bytes, coord_x: int, coord_y: int): + def _add_to_collage(img_data: bytes, coord_x: int, coord_y: int) -> None: data = BytesIO(img_data) photo = Image.open(data).convert("RGBA") photo = photo.resize((500, 500)) @@ -84,5 +85,4 @@ async def get_icon_string(icon_path: str) -> str: assert ext == "svg" async with aiofiles.open(icon_path, "r") as _file: xml_data = await _file.read() - xml_data = xml_data.replace("\n", "").strip() - return xml_data + return xml_data.replace("\n", "").strip() diff --git a/music_assistant/server/helpers/logging.py b/music_assistant/server/helpers/logging.py index 3034c80c..fee87a63 100644 --- a/music_assistant/server/helpers/logging.py +++ b/music_assistant/server/helpers/logging.py @@ -109,13 +109,15 @@ def log_exception(format_err: Callable[..., Any], *args: Any) -> None: @overload def catch_log_exception( func: Callable[..., Coroutine[Any, Any, Any]], format_err: Callable[..., Any] -) -> Callable[..., Coroutine[Any, Any, None]]: ... +) -> Callable[..., Coroutine[Any, Any, None]]: + ... @overload def catch_log_exception( func: Callable[..., Any], format_err: Callable[..., Any] -) -> Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]: ... +) -> Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]: + ... def catch_log_exception( @@ -184,12 +186,10 @@ def async_create_catching_coro(target: Coroutine[Any, Any, _T]) -> Coroutine[Any target: target coroutine. """ trace = traceback.extract_stack() - wrapped_target = catch_log_coro_exception( + return catch_log_coro_exception( target, lambda: "Exception in {} called from\n {}".format( target.__name__, "".join(traceback.format_list(trace[:-1])), ), ) - - return wrapped_target diff --git a/music_assistant/server/helpers/playlists.py b/music_assistant/server/helpers/playlists.py index efbf2e84..a8c2009c 100644 --- a/music_assistant/server/helpers/playlists.py +++ b/music_assistant/server/helpers/playlists.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging from typing import TYPE_CHECKING @@ -57,18 +56,22 @@ async def fetch_playlist(mass: MusicAssistant, url: str) -> list[str]: try: playlist_data = (await resp.content.read(64 * 1024)).decode(charset) except ValueError as err: - raise InvalidDataError(f"Could not decode playlist {url}") from err - except asyncio.TimeoutError as err: - raise InvalidDataError(f"Timeout while fetching playlist {url}") from err + msg = f"Could not decode playlist {url}" + raise InvalidDataError(msg) from err + except TimeoutError as err: + msg = f"Timeout while fetching playlist {url}" + raise InvalidDataError(msg) from err except aiohttp.client_exceptions.ClientError as err: - raise InvalidDataError(f"Error while fetching playlist {url}") from err + msg = f"Error while fetching playlist {url}" + raise InvalidDataError(msg) from err - if url.endswith(".m3u") or url.endswith(".m3u8"): + if url.endswith((".m3u", ".m3u8")): playlist = await parse_m3u(playlist_data) else: playlist = await parse_pls(playlist_data) if not playlist: - raise InvalidDataError(f"Empty playlist {url}") + msg = f"Empty playlist {url}" + raise InvalidDataError(msg) return playlist diff --git a/music_assistant/server/helpers/process.py b/music_assistant/server/helpers/process.py index fa9ad501..3c725296 100644 --- a/music_assistant/server/helpers/process.py +++ b/music_assistant/server/helpers/process.py @@ -8,8 +8,11 @@ from __future__ import annotations import asyncio import logging -from collections.abc import AsyncGenerator, Coroutine from contextlib import suppress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Coroutine LOGGER = logging.getLogger(__name__) @@ -27,7 +30,7 @@ class AsyncProcess: enable_stdin: bool = False, enable_stdout: bool = True, enable_stderr: bool = False, - ): + ) -> None: """Initialize.""" self._proc = None self._args = args diff --git a/music_assistant/server/helpers/tags.py b/music_assistant/server/helpers/tags.py index 155e8bc8..8f5830a0 100644 --- a/music_assistant/server/helpers/tags.py +++ b/music_assistant/server/helpers/tags.py @@ -5,10 +5,9 @@ from __future__ import annotations import json import logging import os -from collections.abc import AsyncGenerator from dataclasses import dataclass from json import JSONDecodeError -from typing import Any +from typing import TYPE_CHECKING, Any from music_assistant.common.helpers.util import try_parse_int from music_assistant.common.models.enums import AlbumType @@ -17,6 +16,9 @@ from music_assistant.common.models.media_items import MediaItemChapter from music_assistant.constants import ROOT_LOGGER_NAME, UNKNOWN_ARTIST from music_assistant.server.helpers.process import AsyncProcess +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + LOGGER = logging.getLogger(ROOT_LOGGER_NAME).getChild("tags") # the only multi-item splitter we accept is the semicolon, @@ -29,7 +31,7 @@ TAG_SPLITTER = ";" def split_items(org_str: str, split_slash: bool = False) -> tuple[str, ...]: """Split up a tags string by common splitter.""" if org_str is None: - return tuple() + return () if isinstance(org_str, list): return (x.strip() for x in org_str) org_str = org_str.strip() @@ -132,7 +134,7 @@ class AudioTags: if TAG_SPLITTER in tag: return split_items(tag) return split_artists(tag) - return tuple() + return () @property def genres(self) -> tuple[str, ...]: @@ -262,7 +264,7 @@ class AudioTags: if tag := self.tags.get(tag_name): # sometimes the field contains multiple values return split_items(tag, True) - return tuple() + return () @property def barcode(self) -> str | None: @@ -307,15 +309,14 @@ class AudioTags: """Parse instance from raw ffmpeg info output.""" audio_stream = next((x for x in raw["streams"] if x["codec_type"] == "audio"), None) if audio_stream is None: - raise InvalidDataError("No audio stream found") + msg = "No audio stream found" + raise InvalidDataError(msg) has_cover_image = any(x for x in raw["streams"] if x["codec_name"] in ("mjpeg", "png")) # convert all tag-keys (gathered from all streams) to lowercase without spaces tags = {} for stream in raw["streams"] + [raw["format"]]: for key, value in stream.get("tags", {}).items(): - alt_key = ( - key.lower().replace(" ", "").replace("_", "").replace("-", "") - ) # noqa: PLW2901 + alt_key = key.lower().replace(" ", "").replace("_", "").replace("-", "") tags[alt_key] = value return AudioTags( @@ -371,7 +372,7 @@ async def parse_tags( if file_path == "-": # feed the file contents to the process - async def chunk_feeder(): + async def chunk_feeder() -> None: bytes_read = 0 try: async for chunk in input_file: @@ -398,7 +399,8 @@ async def parse_tags( if error := data.get("error"): raise InvalidDataError(error["string"]) if not data.get("streams"): - raise InvalidDataError("Not an audio file") + msg = "Not an audio file" + raise InvalidDataError(msg) tags = AudioTags.parse(data) del res del data @@ -407,7 +409,8 @@ async def parse_tags( tags.duration = int((file_size * 8) / tags.bit_rate) return tags except (KeyError, ValueError, JSONDecodeError, InvalidDataError) as err: - raise InvalidDataError(f"Unable to retrieve info for {file_path}: {str(err)}") from err + msg = f"Unable to retrieve info for {file_path}: {err!s}" + raise InvalidDataError(msg) from err async def get_embedded_image(input_file: str | AsyncGenerator[bytes, None]) -> bytes | None: @@ -438,7 +441,7 @@ async def get_embedded_image(input_file: str | AsyncGenerator[bytes, None]) -> b ) as proc: if file_path == "-": # feed the file contents to the process - async def chunk_feeder(): + async def chunk_feeder() -> None: try: async for chunk in input_file: if proc.closed: diff --git a/music_assistant/server/helpers/util.py b/music_assistant/server/helpers/util.py index 57b48417..0d00fb96 100644 --- a/music_assistant/server/helpers/util.py +++ b/music_assistant/server/helpers/util.py @@ -10,7 +10,6 @@ import tempfile import urllib.error import urllib.parse import urllib.request -from collections.abc import Iterator from functools import lru_cache from importlib.metadata import PackageNotFoundError from importlib.metadata import version as pkg_version @@ -20,6 +19,8 @@ import ifaddr import memory_tempfile if TYPE_CHECKING: + from collections.abc import Iterator + from music_assistant.server.models import ProviderModuleType LOGGER = logging.getLogger(__name__) @@ -85,6 +86,7 @@ async def is_hass_supervisor() -> bool: return getattr(err, "code", 999) == 401 except Exception: return False + return False return await asyncio.to_thread(_check) diff --git a/music_assistant/server/helpers/webserver.py b/music_assistant/server/helpers/webserver.py index 2ce47c50..29dcc235 100644 --- a/music_assistant/server/helpers/webserver.py +++ b/music_assistant/server/helpers/webserver.py @@ -2,12 +2,14 @@ from __future__ import annotations -import logging -from collections.abc import Awaitable, Callable -from typing import Final +from typing import TYPE_CHECKING, Final from aiohttp import web +if TYPE_CHECKING: + import logging + from collections.abc import Awaitable, Callable + MAX_CLIENT_SIZE: Final = 1024**2 * 16 MAX_LINE_SIZE: Final = 24570 @@ -19,7 +21,7 @@ class Webserver: self, logger: logging.Logger, enable_dynamic_routes: bool = False, - ): + ) -> None: """Initialize instance.""" self.logger = logger # the below gets initialized in async setup @@ -92,10 +94,12 @@ class Webserver: def register_dynamic_route(self, path: str, handler: Awaitable, method: str = "*") -> Callable: """Register a dynamic route on the webserver, returns handler to unregister.""" if self._dynamic_routes is None: - raise RuntimeError("Dynamic routes are not enabled") + msg = "Dynamic routes are not enabled" + raise RuntimeError(msg) key = f"{method}.{path}" - if key in self._dynamic_routes: - raise RuntimeError(f"Route {path} already registered.") + if key in self._dynamic_routes: # pylint: disable=unsupported-membership-test + msg = f"Route {path} already registered." + raise RuntimeError(msg) self._dynamic_routes[key] = handler def _remove(): @@ -106,7 +110,8 @@ class Webserver: def unregister_dynamic_route(self, path: str, method: str = "*") -> None: """Unregister a dynamic route from the webserver.""" if self._dynamic_routes is None: - raise RuntimeError("Dynamic routes are not enabled") + msg = "Dynamic routes are not enabled" + raise RuntimeError(msg) key = f"{method}.{path}" self._dynamic_routes.pop(key) diff --git a/music_assistant/server/models/__init__.py b/music_assistant/server/models/__init__.py index cdf98962..4e20ea72 100644 --- a/music_assistant/server/models/__init__.py +++ b/music_assistant/server/models/__init__.py @@ -4,15 +4,17 @@ from __future__ import annotations from typing import TYPE_CHECKING, Protocol -from music_assistant.common.models.config_entries import ConfigValueType - from .metadata_provider import MetadataProvider from .music_provider import MusicProvider from .player_provider import PlayerProvider from .plugin import PluginProvider if TYPE_CHECKING: - from music_assistant.common.models.config_entries import ConfigEntry, ProviderConfig + from music_assistant.common.models.config_entries import ( + ConfigEntry, + ConfigValueType, + ProviderConfig, + ) from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant diff --git a/music_assistant/server/models/core_controller.py b/music_assistant/server/models/core_controller.py index 1e150dcb..3f35884b 100644 --- a/music_assistant/server/models/core_controller.py +++ b/music_assistant/server/models/core_controller.py @@ -39,11 +39,11 @@ class CoreController: async def get_config_entries( self, - action: str | None = None, # noqa: ARG002 - values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, ) -> tuple[ConfigEntry, ...]: """Return all Config Entries for this core module (if any).""" - return tuple() + return () async def setup(self, config: CoreConfig) -> None: """Async initialize of module.""" diff --git a/music_assistant/server/models/music_provider.py b/music_assistant/server/models/music_provider.py index 0f96b474..b6d8fadc 100644 --- a/music_assistant/server/models/music_provider.py +++ b/music_assistant/server/models/music_provider.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING from music_assistant.common.models.enums import MediaType, ProviderFeature from music_assistant.common.models.errors import MediaNotFoundError, MusicAssistantError @@ -22,6 +22,9 @@ from music_assistant.common.models.media_items import ( from .provider import Provider +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + # ruff: noqa: ARG001, ARG002 @@ -129,7 +132,8 @@ class MusicProvider(Provider): raise NotImplementedError async def get_album_tracks( - self, prov_album_id: str # type: ignore[return] + self, + prov_album_id: str, # type: ignore[return] ) -> list[AlbumTrack]: """Get album tracks for given album id.""" if ProviderFeature.LIBRARY_ALBUMS in self.supported_features: @@ -300,7 +304,8 @@ class MusicProvider(Provider): return if subpath: # unknown path - raise KeyError("Invalid subpath") + msg = "Invalid subpath" + raise KeyError(msg) # no subpath: return main listing if ProviderFeature.LIBRARY_ARTISTS in self.supported_features: yield BrowseFolder( diff --git a/music_assistant/server/models/player_provider.py b/music_assistant/server/models/player_provider.py index be404abf..02b54bc1 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -15,12 +15,12 @@ from music_assistant.common.models.config_entries import ( PlayerConfig, ) from music_assistant.common.models.enums import ConfigEntryType -from music_assistant.common.models.player import Player from music_assistant.constants import CONF_GROUP_MEMBERS, CONF_GROUP_PLAYERS, SYNCGROUP_PREFIX from .provider import Provider if TYPE_CHECKING: + from music_assistant.common.models.player import Player from music_assistant.common.models.queue_item import QueueItem from music_assistant.server.controllers.streams import MultiClientStreamJob @@ -44,7 +44,8 @@ class PlayerProvider(Provider): ) if player_id.startswith(SYNCGROUP_PREFIX): # add default entries for syncgroups - return entries + ( + return ( + *entries, ConfigEntry( key=CONF_GROUP_MEMBERS, type=ConfigEntryType.STRING, @@ -101,7 +102,7 @@ class PlayerProvider(Provider): - player_id: player_id of the player to handle the command. """ # will only be called for players with Pause feature set. - raise NotImplementedError() + raise NotImplementedError async def play_media( self, @@ -120,16 +121,16 @@ class PlayerProvider(Provider): - seek_position: Optional seek to this position. - fade_in: Optionally fade in the item at playback start. """ - raise NotImplementedError() + raise NotImplementedError async def play_stream(self, player_id: str, stream_job: MultiClientStreamJob) -> None: """Handle PLAY STREAM on given player. This is a special feature from the Universal Group provider. """ - raise NotImplementedError() + raise NotImplementedError - async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem): + async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem) -> None: """ Handle enqueuing of the next queue item on the player. @@ -151,7 +152,7 @@ class PlayerProvider(Provider): - powered: bool if player should be powered on or off. """ # will only be called for players with Power feature set. - raise NotImplementedError() + raise NotImplementedError async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: """Send VOLUME_SET command to given player. @@ -160,7 +161,7 @@ class PlayerProvider(Provider): - volume_level: volume level (0..100) to set on the player. """ # will only be called for players with Volume feature set. - raise NotImplementedError() + raise NotImplementedError async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: """Send VOLUME MUTE command to given player. @@ -169,7 +170,7 @@ class PlayerProvider(Provider): - muted: bool if player should be muted. """ # will only be called for players with Mute feature set. - raise NotImplementedError() + raise NotImplementedError async def cmd_seek(self, player_id: str, position: int) -> None: """Handle SEEK command for given queue. @@ -178,7 +179,7 @@ class PlayerProvider(Provider): - position: position in seconds to seek to in the current playing item. """ # will only be called for players with Seek feature set. - raise NotImplementedError() + raise NotImplementedError async def cmd_sync(self, player_id: str, target_player: str) -> None: """Handle SYNC command for given player. @@ -189,7 +190,7 @@ class PlayerProvider(Provider): - target_player: player_id of the syncgroup master or group player. """ # will only be called for players with SYNC feature set. - raise NotImplementedError() + raise NotImplementedError async def cmd_unsync(self, player_id: str) -> None: """Handle UNSYNC command for given player. @@ -199,7 +200,7 @@ class PlayerProvider(Provider): - player_id: player_id of the player to handle the command. """ # will only be called for players with SYNC feature set. - raise NotImplementedError() + raise NotImplementedError async def create_group(self, name: str, members: list[str]) -> Player: """Create new PlayerGroup on this provider. @@ -210,7 +211,7 @@ class PlayerProvider(Provider): - members: A list of player_id's that should be part of this group. """ # will only be called for players with PLAYER_GROUP_CREATE feature set. - raise NotImplementedError() + raise NotImplementedError async def poll_player(self, player_id: str) -> None: """Poll player for state updates. diff --git a/music_assistant/server/models/plugin.py b/music_assistant/server/models/plugin.py index 6e7207e2..8c0ebc5e 100644 --- a/music_assistant/server/models/plugin.py +++ b/music_assistant/server/models/plugin.py @@ -2,13 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from .provider import Provider -if TYPE_CHECKING: - pass - # ruff: noqa: ARG001, ARG002 diff --git a/music_assistant/server/models/provider.py b/music_assistant/server/models/provider.py index d7063477..c4ae5cfc 100644 --- a/music_assistant/server/models/provider.py +++ b/music_assistant/server/models/provider.py @@ -5,16 +5,14 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING -from music_assistant.common.models.config_entries import ProviderConfig -from music_assistant.common.models.enums import ProviderFeature, ProviderType -from music_assistant.common.models.provider import ProviderInstance, ProviderManifest from music_assistant.constants import CONF_LOG_LEVEL, ROOT_LOGGER_NAME if TYPE_CHECKING: + from music_assistant.common.models.config_entries import ProviderConfig + from music_assistant.common.models.enums import ProviderFeature, ProviderType + from music_assistant.common.models.provider import ProviderInstance, ProviderManifest from music_assistant.server import MusicAssistant -# noqa: ARG001 - class Provider: """Base representation of a Provider implementation within Music Assistant.""" @@ -51,7 +49,7 @@ class Provider: @property def supported_features(self) -> tuple[ProviderFeature, ...]: """Return the features supported by this Provider.""" - return tuple() + return () async def handle_setup(self) -> None: """Handle async initialization of the provider.""" @@ -89,7 +87,7 @@ class Provider: return f"{self.manifest.name}.{postfix}" return self.manifest.name - def to_dict(self, *args, **kwargs) -> ProviderInstance: # noqa: ARG002 + def to_dict(self, *args, **kwargs) -> ProviderInstance: """Return Provider(instance) as serializable dict.""" return { "type": self.type.value, diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index 5d2701f3..2a82348c 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -118,7 +118,7 @@ async def get_config_entries( values: the (intermediate) raw values for config entries sent with the action. """ # ruff: noqa: ARG001 - return tuple() # we do not have any config entries (yet) + return () # we do not have any config entries (yet) class AirplayProvider(PlayerProvider): @@ -138,7 +138,7 @@ class AirplayProvider(PlayerProvider): # for now do not allow creation of airplay groups # in preparation of new airplay provider coming up soon # return (ProviderFeature.SYNC_PLAYERS,) - return tuple() + return () async def handle_setup(self) -> None: """Handle async initialization of the provider.""" @@ -179,7 +179,7 @@ class AirplayProvider(PlayerProvider): slimproto_prov = self.mass.get_provider("slimproto") slimproto_prov.on_player_config_changed(config, changed_keys) - async def update_config(): + async def update_config() -> None: # stop bridge (it will be auto restarted) if changed_keys.intersection(NEED_BRIDGE_RESTART): self.restart_bridge() @@ -239,7 +239,7 @@ class AirplayProvider(PlayerProvider): slimproto_prov = self.mass.get_provider("slimproto") await slimproto_prov.play_stream(player_id, stream_job) - async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem): + async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem) -> None: """Handle enqueuing of the next queue item on the player.""" # simply forward to underlying slimproto player slimproto_prov = self.mass.get_provider("slimproto") @@ -345,7 +345,8 @@ class AirplayProvider(PlayerProvider): ): return bridge_binary - raise RuntimeError(f"Unable to locate RaopBridge for {system}/{architecture}") + msg = f"Unable to locate RaopBridge for {system}/{architecture}" + raise RuntimeError(msg) async def _bridge_process_runner(self, slimproto_prov: SlimprotoProvider) -> None: """Run the bridge binary in the background.""" @@ -381,7 +382,7 @@ class AirplayProvider(PlayerProvider): await self._bridge_proc.wait() except Exception as err: if not start_success: - raise err + raise self.logger.exception("Error in Airplay bridge", exc_info=err) if self._closing: break @@ -426,9 +427,9 @@ class AirplayProvider(PlayerProvider): try: xml_root = ET.XML(xml_data) - except ET.ParseError as err: + except ET.ParseError: if recreate: - raise err + raise await self._check_config_xml(True) return @@ -510,7 +511,7 @@ class AirplayProvider(PlayerProvider): self._timer_handle.cancel() self._timer_handle = None - async def restart_bridge(): + async def restart_bridge() -> None: self.logger.info("Restarting Airplay bridge (due to config changes)") await self._stop_bridge() await self._check_config_xml() diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index 491e8aaa..6b379735 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -13,11 +13,17 @@ from typing import TYPE_CHECKING from uuid import UUID import pychromecast -from pychromecast.controllers.media import STREAM_TYPE_BUFFERED, STREAM_TYPE_LIVE, MediaController +from pychromecast.controllers.media import ( + STREAM_TYPE_BUFFERED, + STREAM_TYPE_LIVE, + MediaController, +) from pychromecast.controllers.multizone import MultizoneController, MultizoneManager from pychromecast.discovery import CastBrowser, SimpleCastListener -from pychromecast.models import CastInfo -from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED +from pychromecast.socket_client import ( + CONNECTION_STATUS_CONNECTED, + CONNECTION_STATUS_DISCONNECTED, +) from music_assistant.common.models.config_entries import ( CONF_ENTRY_CROSSFADE_DURATION, @@ -35,7 +41,6 @@ from music_assistant.common.models.enums import ( ) from music_assistant.common.models.errors import PlayerUnavailableError from music_assistant.common.models.player import DeviceInfo, Player -from music_assistant.common.models.queue_item import QueueItem from music_assistant.constants import ( CONF_CROSSFADE, CONF_FLOW_MODE, @@ -50,10 +55,15 @@ from .helpers import CastStatusListener, ChromecastInfo if TYPE_CHECKING: from pychromecast.controllers.media import MediaStatus from pychromecast.controllers.receiver import CastStatus + from pychromecast.models import CastInfo from pychromecast.socket_client import ConnectionStatus - from music_assistant.common.models.config_entries import PlayerConfig, ProviderConfig + from music_assistant.common.models.config_entries import ( + PlayerConfig, + ProviderConfig, + ) from music_assistant.common.models.provider import ProviderManifest + from music_assistant.common.models.queue_item import QueueItem from music_assistant.server import MusicAssistant from music_assistant.server.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType @@ -80,7 +90,7 @@ PLAYER_CONFIG_ENTRIES = ( _patched_process_media_status_org = MediaController._process_media_status -def _patched_process_media_status(self, data): +def _patched_process_media_status(self, data) -> None: """Process STATUS message(s) of the media controller.""" _patched_process_media_status_org(self, data) for status_msg in data.get("status", []): @@ -115,7 +125,7 @@ async def get_config_entries( values: the (intermediate) raw values for config entries sent with the action. """ # ruff: noqa: ARG001 - return tuple() # we do not have any config entries (yet) + return () # we do not have any config entries (yet) @dataclass @@ -169,7 +179,7 @@ class ChromecastProvider(PlayerProvider): return # stop discovery - def stop_discovery(): + def stop_discovery() -> None: """Stop the chromecast discovery threads.""" if self.browser._zc_browser: with contextlib.suppress(RuntimeError): @@ -189,7 +199,9 @@ class ChromecastProvider(PlayerProvider): return base_entries + PLAYER_CONFIG_ENTRIES def on_player_config_changed( - self, config: PlayerConfig, changed_keys: set[str] # noqa: ARG002 + self, + config: PlayerConfig, + changed_keys: set[str], ) -> None: """Call (by config manager) when the configuration of a player changes.""" super().on_player_config_changed(config, changed_keys) @@ -302,7 +314,7 @@ class ChromecastProvider(PlayerProvider): thumb=MASS_LOGO_ONLINE, ) - async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem): + async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem) -> None: """Handle enqueuing of the next queue item on the player.""" castplayer = self.castplayers[player_id] url = await self.mass.streams.resolve_stream_url( @@ -319,7 +331,7 @@ class ChromecastProvider(PlayerProvider): if item["itemId"] == cast_current_item_id: cur_item_found = True continue - elif not cur_item_found: + if not cur_item_found: continue next_item_id = item["itemId"] # check if the next queue item isn't already queued @@ -370,7 +382,7 @@ class ChromecastProvider(PlayerProvider): ### Discovery callbacks - def _on_chromecast_discovered(self, uuid, _): + def _on_chromecast_discovered(self, uuid, _) -> None: """Handle Chromecast discovered callback.""" if self.mass.closing: return @@ -427,7 +439,7 @@ class ChromecastProvider(PlayerProvider): player=Player( player_id=player_id, provider=self.instance_id, - type=PlayerType.GROUP if cast_info.is_audio_group else PlayerType.PLAYER, + type=(PlayerType.GROUP if cast_info.is_audio_group else PlayerType.PLAYER), name=cast_info.friendly_name, available=False, powered=False, @@ -462,9 +474,8 @@ class ChromecastProvider(PlayerProvider): self.mass.players.register_or_update, castplayer.player ) - def _on_chromecast_removed(self, uuid, service, cast_info): # noqa: ARG002 + def _on_chromecast_removed(self, uuid, service, cast_info) -> None: """Handle zeroconf discovery of a removed Chromecast.""" - # noqa: ARG001 player_id = str(service[1]) friendly_name = service[3] self.logger.debug("Chromecast removed: %s - %s", friendly_name, player_id) @@ -507,7 +518,7 @@ class ChromecastProvider(PlayerProvider): # send update to player manager self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id) - def on_new_media_status(self, castplayer: CastPlayer, status: MediaStatus): + def on_new_media_status(self, castplayer: CastPlayer, status: MediaStatus) -> None: """Handle updated MediaStatus.""" castplayer.logger.debug("Received media status update: %s", status.player_state) # player state @@ -531,9 +542,9 @@ class ChromecastProvider(PlayerProvider): castplayer.player.elapsed_time = status.current_time # active source - if status.content_id and castplayer.player_id in status.content_id: # noqa: SIM114 - castplayer.player.active_source = castplayer.player_id - elif castplayer.cc.app_id == pychromecast.config.APP_MEDIA_RECEIVER: + if ( + status.content_id and castplayer.player_id in status.content_id + ) or castplayer.cc.app_id == pychromecast.config.APP_MEDIA_RECEIVER: castplayer.player.active_source = castplayer.player_id else: castplayer.player.active_source = castplayer.cc.app_display_name @@ -581,10 +592,10 @@ class ChromecastProvider(PlayerProvider): if castplayer.cc.app_id == app_id: return # already active - def launched_callback(): + def launched_callback() -> None: self.mass.loop.call_soon_threadsafe(event.set) - def launch(): + def launch() -> None: # Quit the previous app before starting splash screen or media player if castplayer.cc.app_id is not None: castplayer.cc.quit_app() diff --git a/music_assistant/server/providers/chromecast/helpers.py b/music_assistant/server/providers/chromecast/helpers.py index 7959f5d7..114a3383 100644 --- a/music_assistant/server/providers/chromecast/helpers.py +++ b/music_assistant/server/providers/chromecast/helpers.py @@ -9,7 +9,6 @@ from uuid import UUID from pychromecast import dial from pychromecast.const import CAST_TYPE_GROUP -from zeroconf import ServiceInfo if TYPE_CHECKING: from pychromecast.controllers.media import MediaStatus @@ -17,7 +16,7 @@ if TYPE_CHECKING: from pychromecast.controllers.receiver import CastStatus from pychromecast.models import CastInfo from pychromecast.socket_client import ConnectionStatus - from zeroconf import Zeroconf + from zeroconf import ServiceInfo, Zeroconf from . import CastPlayer, ChromecastProvider @@ -129,7 +128,7 @@ class CastStatusListener: castplayer: CastPlayer, mz_mgr: MultizoneManager, mz_only=False, - ): + ) -> None: """Initialize the status listener.""" self.prov = prov self.castplayer = castplayer @@ -166,14 +165,14 @@ class CastStatusListener: return self.prov.on_new_connection_status(self.castplayer, status) - def added_to_multizone(self, group_uuid): + def added_to_multizone(self, group_uuid) -> None: """Handle the cast added to a group.""" self.prov.logger.debug( "%s is added to multizone: %s", self.castplayer.player.display_name, group_uuid ) self.new_cast_status(self.castplayer.cc.status) - def removed_from_multizone(self, group_uuid): + def removed_from_multizone(self, group_uuid) -> None: """Handle the cast removed from a group.""" if not self._valid: return @@ -184,7 +183,7 @@ class CastStatusListener: ) self.new_cast_status(self.castplayer.cc.status) - def multizone_new_cast_status(self, group_uuid, cast_status): # noqa: ARG002 + def multizone_new_cast_status(self, group_uuid, cast_status) -> None: """Handle reception of a new CastStatus for a group.""" if group_player := self.prov.castplayers.get(group_uuid): if group_player.cc.media_controller.is_active: @@ -198,7 +197,7 @@ class CastStatusListener: ) self.new_cast_status(self.castplayer.cc.status) - def multizone_new_media_status(self, group_uuid, media_status): # noqa: ARG002 + def multizone_new_media_status(self, group_uuid, media_status) -> None: """Handle reception of a new MediaStatus for a group.""" if not self._valid: return @@ -206,11 +205,11 @@ class CastStatusListener: "%s got new media_status for group: %s", self.castplayer.player.display_name, group_uuid ) - def load_media_failed(self, item, error_code): + def load_media_failed(self, item, error_code) -> None: """Call when media failed to load.""" self.prov.logger.warning("Load media failed: %s - error code: %s", item, error_code) - def invalidate(self): + def invalidate(self) -> None: """ Invalidate this status listener. diff --git a/music_assistant/server/providers/deezer/__init__.py b/music_assistant/server/providers/deezer/__init__.py index 65e05191..9937a08d 100644 --- a/music_assistant/server/providers/deezer/__init__.py +++ b/music_assistant/server/providers/deezer/__init__.py @@ -33,7 +33,6 @@ from music_assistant.common.models.media_items import ( AlbumTrack, Artist, AudioFormat, - BrowseFolder, ItemMapping, MediaItemImage, MediaItemMetadata, @@ -45,7 +44,11 @@ from music_assistant.common.models.media_items import ( Track, ) from music_assistant.common.models.provider import ProviderManifest -from music_assistant.server.helpers.app_vars import app_var # pylint: disable=no-name-in-module + +# pylint: disable=no-name-in-module +from music_assistant.server.helpers.app_vars import app_var + +# pylint: enable=no-name-in-module from music_assistant.server.helpers.auth import AuthenticationHelper from music_assistant.server.models import ProviderInstanceType from music_assistant.server.models.music_provider import MusicProvider @@ -105,12 +108,14 @@ async def update_access_token( ssl=False, ) if response.status != 200: - raise ConnectionError(f"HTTP Error {response.status}: {response.reason}") + msg = f"HTTP Error {response.status}: {response.reason}" + raise ConnectionError(msg) response_text = await response.text() try: return response_text.split("=")[1].split("&")[0] except Exception as error: - raise LoginFailed("Invalid auth code") from error + msg = "Invalid auth code" + raise LoginFailed(msg) from error async def setup( @@ -196,7 +201,12 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 :param media_types: A list of media_types to include. All types if None. """ if not media_types: - media_types = [MediaType.ARTIST, MediaType.ALBUM, MediaType.TRACK, MediaType.PLAYLIST] + media_types = [ + MediaType.ARTIST, + MediaType.ALBUM, + MediaType.TRACK, + MediaType.PLAYLIST, + ] tasks = {} @@ -372,22 +382,14 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 raise NotImplementedError return result - async def recommendations(self) -> list[BrowseFolder]: + async def recommendations(self) -> list[Track]: """Get deezer's recommendations.""" - browser_folder = BrowseFolder( - item_id="recommendations", - provider=self.domain, - path="recommendations", - name="Recommendations", - label="recommendations", - items=[ - self.parse_track(track=track, user_country=self.gw_client.user_country) - for track in await self.client.get_recommended_tracks() - ], - ) - return [browser_folder] + return [ + self.parse_track(track=track, user_country=self.gw_client.user_country) + for track in await self.client.get_user_recommended_tracks() + ] - async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]): + async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None: """Add tra ck(s) to playlist.""" playlist = await self.client.get_playlist(int(prov_playlist_id)) await playlist.add_tracks(tracks=[int(i) for i in prov_track_ids]) @@ -468,7 +470,7 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 del buffer[:2048] yield bytes(buffer) - async def log_listen_cb(self, stream_details): + async def log_listen_cb(self, stream_details) -> None: """Log the end of a track playback.""" await self.gw_client.log_listen(last_track=stream_details) @@ -711,7 +713,8 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 if song_data["results"]["FILESIZE_MP3_320"] or song_data["results"]["FILESIZE_MP3_128"]: return ContentType.MP3 - raise NotImplementedError("Unsupported contenttype") + msg = "Unsupported contenttype" + raise NotImplementedError(msg) def track_available(self, track: deezer.Track, user_country: str) -> bool: """Check if a given track is available in the users country.""" @@ -735,6 +738,8 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 def decrypt_chunk(self, chunk, blowfish_key): """Decrypt a given chunk using the blow fish key.""" cipher = Blowfish.new( - blowfish_key.encode("ascii"), Blowfish.MODE_CBC, b"\x00\x01\x02\x03\x04\x05\x06\x07" + blowfish_key.encode("ascii"), + Blowfish.MODE_CBC, + b"\x00\x01\x02\x03\x04\x05\x06\x07", ) return cipher.decrypt(chunk) diff --git a/music_assistant/server/providers/deezer/gw_client.py b/music_assistant/server/providers/deezer/gw_client.py index 606e662a..1d16f119 100644 --- a/music_assistant/server/providers/deezer/gw_client.py +++ b/music_assistant/server/providers/deezer/gw_client.py @@ -37,12 +37,12 @@ class GWClient: ] user_country: str - def __init__(self, session: ClientSession, api_token: str): + def __init__(self, session: ClientSession, api_token: str) -> None: """Provide an aiohttp ClientSession and the deezer api_token.""" self._api_token = api_token self.session = session - async def _get_cookie(self): + async def _get_cookie(self) -> None: await self.session.get( "https://api.deezer.com/platform/generic/track/3135556", headers={"Authorization": f"Bearer {self._api_token}", "User-Agent": USER_AGENT_HEADER}, @@ -59,14 +59,15 @@ class GWClient: self.session.cookie_jar.update_cookies(BaseCookie({"arl": cookie}), URL(GW_LIGHT_URL)) - async def _update_user_data(self): + async def _update_user_data(self) -> None: user_data = await self._gw_api_call("deezer.getUserData", False) if not user_data["results"]["USER"]["USER_ID"]: await self._get_cookie() user_data = await self._gw_api_call("deezer.getUserData", False) if not user_data["results"]["OFFER_ID"]: - raise DeezerGWError("Free subscriptions cannot be used in MA.") + msg = "Free subscriptions cannot be used in MA." + raise DeezerGWError(msg) self._gw_csrf_token = user_data["results"]["checkForm"] self._license = user_data["results"]["USER"]["OPTIONS"]["license_token"] @@ -82,7 +83,7 @@ class GWClient: self.user_country = user_data["results"]["COUNTRY"] - async def setup(self): + async def setup(self) -> None: """Call this to let the client get its cookies, license and tokens.""" await self._get_cookie() await self._update_user_data() @@ -120,7 +121,8 @@ class GWClient: method, use_csrf_token, args, params, http_method, False ) else: - raise DeezerGWError("Failed to call GW-API", result_json["error"]) + msg = "Failed to call GW-API" + raise DeezerGWError(msg, result_json["error"]) return result_json async def get_song_data(self, track_id): @@ -150,16 +152,18 @@ class GWClient: result_json = await url_response.json() if error := result_json["data"][0].get("errors"): - raise DeezerGWError("Received an error from API", error) + msg = "Received an error from API" + raise DeezerGWError(msg, error) return result_json["data"][0]["media"][0], song_data["results"] async def log_listen( self, next_track: str | None = None, last_track: StreamDetails | None = None - ): + ) -> None: """Log the next and/or previous track of the current playback queue.""" if not (next_track or last_track): - raise DeezerGWError("last or current track information must be provided.") + msg = "last or current track information must be provided." + raise DeezerGWError(msg) payload = {} diff --git a/music_assistant/server/providers/dlna/__init__.py b/music_assistant/server/providers/dlna/__init__.py index ced3fc2c..42de72ff 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -11,19 +11,16 @@ from __future__ import annotations import asyncio import functools import time -from collections.abc import Awaitable, Callable, Coroutine, Sequence from contextlib import suppress from dataclasses import dataclass, field from ipaddress import IPv4Address from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar from async_upnp_client.aiohttp import AiohttpSessionRequester -from async_upnp_client.client import UpnpRequester, UpnpService, UpnpStateVariable from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.exceptions import UpnpError, UpnpResponseError from async_upnp_client.profiles.dlna import DmrDevice, TransportState from async_upnp_client.search import async_search -from async_upnp_client.utils import CaseInsensitiveDict from music_assistant.common.models.config_entries import ( CONF_ENTRY_CROSSFADE_DURATION, @@ -40,7 +37,6 @@ from music_assistant.common.models.enums import ( ) from music_assistant.common.models.errors import PlayerUnavailableError from music_assistant.common.models.player import DeviceInfo, Player -from music_assistant.common.models.queue_item import QueueItem from music_assistant.constants import CONF_CROSSFADE, CONF_FLOW_MODE, CONF_PLAYERS from music_assistant.server.helpers.didl_lite import create_didl_metadata from music_assistant.server.models.player_provider import PlayerProvider @@ -48,8 +44,14 @@ from music_assistant.server.models.player_provider import PlayerProvider from .helpers import DLNANotifyServer if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Coroutine, Sequence + + from async_upnp_client.client import UpnpRequester, UpnpService, UpnpStateVariable + from async_upnp_client.utils import CaseInsensitiveDict + from music_assistant.common.models.config_entries import PlayerConfig, ProviderConfig from music_assistant.common.models.provider import ProviderManifest + from music_assistant.common.models.queue_item import QueueItem from music_assistant.server import MusicAssistant from music_assistant.server.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType @@ -141,7 +143,7 @@ async def get_config_entries( def catch_request_errors( - func: Callable[Concatenate[_DLNAPlayerProviderT, _P], Awaitable[_R]] + func: Callable[Concatenate[_DLNAPlayerProviderT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_DLNAPlayerProviderT, _P], Coroutine[Any, Any, _R | None]]: """Catch UpnpError errors.""" @@ -163,7 +165,7 @@ def catch_request_errors( return await func(self, *args, **kwargs) except UpnpError as err: dlna_player.force_poll = True - self.logger.error("Error during call %s: %r", func.__name__, err) + self.logger.exception("Error during call %s: %r", func.__name__, err) return None return wrapper @@ -189,7 +191,7 @@ class DLNAPlayer: last_seen: float = field(default_factory=time.time) last_command: float = field(default_factory=time.time) - def update_attributes(self): + def update_attributes(self) -> None: """Update attributes of the MA Player from DLNA state.""" # generic attributes @@ -295,14 +297,17 @@ class DLNAPlayerProvider(PlayerProvider): tg.create_task(self._device_disconnect(dlna_player)) async def get_player_config_entries( - self, player_id: str # noqa: ARG002 + self, + player_id: str, ) -> tuple[ConfigEntry, ...]: """Return all (provider/player specific) Config Entries for the given player (if any).""" base_entries = await super().get_player_config_entries(player_id) return base_entries + PLAYER_CONFIG_ENTRIES def on_player_config_changed( - self, config: PlayerConfig, changed_keys: set[str] # noqa: ARG002 + self, + config: PlayerConfig, + changed_keys: set[str], ) -> None: """Call (by config manager) when the configuration of a player changes.""" super().on_player_config_changed(config, changed_keys) @@ -409,7 +414,7 @@ class DLNAPlayerProvider(PlayerProvider): await self.poll_player(dlna_player.udn) @catch_request_errors - async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem): + async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem) -> None: """Handle enqueuing of the next queue item on the player.""" dlna_player = self.dlnaplayers[player_id] url = await self.mass.streams.resolve_stream_url( @@ -500,7 +505,7 @@ class DLNAPlayerProvider(PlayerProvider): allow_network_scan = self.config.get_value(CONF_NETWORK_SCAN) discovered_devices: set[str] = set() - async def on_response(discovery_info: CaseInsensitiveDict): + async def on_response(discovery_info: CaseInsensitiveDict) -> None: """Process discovered device from ssdp search.""" ssdp_st: str = discovery_info.get("st", discovery_info.get("nt")) if not ssdp_st: @@ -535,7 +540,7 @@ class DLNAPlayerProvider(PlayerProvider): finally: self._discovery_running = False - def reschedule(): + def reschedule() -> None: self.mass.create_task(self._run_discovery(use_multicast=not use_multicast)) # reschedule self once finished @@ -701,6 +706,7 @@ class DLNAPlayerProvider(PlayerProvider): dlna_player.player.supported_features = BASE_PLAYER_FEATURES player_id = dlna_player.player.player_id if self.mass.config.get_raw_player_config_value(player_id, CONF_ENQUEUE_NEXT, False): - dlna_player.player.supported_features = dlna_player.player.supported_features + ( + dlna_player.player.supported_features = ( + *dlna_player.player.supported_features, PlayerFeature.ENQUEUE_NEXT, ) diff --git a/music_assistant/server/providers/dlna/icon.svg b/music_assistant/server/providers/dlna/icon.svg old mode 100755 new mode 100644 diff --git a/music_assistant/server/providers/fanarttv/__init__.py b/music_assistant/server/providers/fanarttv/__init__.py index 6398812b..24a13b91 100644 --- a/music_assistant/server/providers/fanarttv/__init__.py +++ b/music_assistant/server/providers/fanarttv/__init__.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING import aiohttp.client_exceptions from asyncio_throttle import Throttler -from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType from music_assistant.common.models.enums import ProviderFeature from music_assistant.common.models.media_items import ImageType, MediaItemImage, MediaItemMetadata from music_assistant.server.controllers.cache import use_cache @@ -16,7 +15,11 @@ from music_assistant.server.helpers.app_vars import app_var # pylint: disable=n from music_assistant.server.models.metadata_provider import MetadataProvider if TYPE_CHECKING: - from music_assistant.common.models.config_entries import ProviderConfig + from music_assistant.common.models.config_entries import ( + ConfigEntry, + ConfigValueType, + ProviderConfig, + ) from music_assistant.common.models.media_items import Album, Artist from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant @@ -61,7 +64,7 @@ async def get_config_entries( values: the (intermediate) raw values for config entries sent with the action. """ # ruff: noqa: ARG001 - return tuple() # we do not have any config entries (yet) + return () # we do not have any config entries (yet) class FanartTvMetadataProvider(MetadataProvider): @@ -101,7 +104,7 @@ class FanartTvMetadataProvider(MetadataProvider): if not album.mbid: return None self.logger.debug("Fetching metadata for Album %s on Fanart.tv", album.name) - if data := await self._get_data(f"music/albums/{album.mbid}"): # noqa: SIM102 + if data := await self._get_data(f"music/albums/{album.mbid}"): if data and data.get("albums"): data = data["albums"][album.mbid] metadata = MediaItemMetadata() @@ -130,7 +133,7 @@ class FanartTvMetadataProvider(MetadataProvider): aiohttp.client_exceptions.ContentTypeError, JSONDecodeError, ): - self.logger.error("Failed to retrieve %s", endpoint) + self.logger.exception("Failed to retrieve %s", endpoint) text_result = await response.text() self.logger.debug(text_result) return None diff --git a/music_assistant/server/providers/filesystem_local/__init__.py b/music_assistant/server/providers/filesystem_local/__init__.py index 21e86ea6..8fcce060 100644 --- a/music_assistant/server/providers/filesystem_local/__init__.py +++ b/music_assistant/server/providers/filesystem_local/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import os import os.path -from collections.abc import AsyncGenerator from typing import TYPE_CHECKING import aiofiles @@ -25,6 +24,8 @@ from .base import ( from .helpers import get_absolute_path, get_relative_path if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant @@ -44,7 +45,8 @@ async def setup( """Initialize provider(instance) with given configuration.""" conf_path = config.get_value(CONF_PATH) if not await isdir(conf_path): - raise SetupFailedError(f"Music Directory {conf_path} does not exist") + msg = f"Music Directory {conf_path} does not exist" + raise SetupFailedError(msg) prov = LocalFileSystemProvider(mass, manifest, config) await prov.handle_setup() return prov @@ -136,7 +138,9 @@ class LocalFileSystemProvider(FileSystemProviderBase): yield item async def resolve( - self, file_path: str, require_local: bool = False # noqa: ARG002 + self, + file_path: str, + require_local: bool = False, ) -> FileSystemItem: """Resolve (absolute or relative) path to FileSystemItem. diff --git a/music_assistant/server/providers/filesystem_local/base.py b/music_assistant/server/providers/filesystem_local/base.py index d9ef4da1..e2c37f89 100644 --- a/music_assistant/server/providers/filesystem_local/base.py +++ b/music_assistant/server/providers/filesystem_local/base.py @@ -6,13 +6,16 @@ import asyncio import contextlib import os from abc import abstractmethod -from collections.abc import AsyncGenerator from dataclasses import dataclass +from typing import TYPE_CHECKING import cchardet import xmltodict -from music_assistant.common.helpers.util import create_sort_name, parse_title_and_version +from music_assistant.common.helpers.util import ( + create_sort_name, + parse_title_and_version, +) from music_assistant.common.models.config_entries import ( ConfigEntry, ConfigEntryType, @@ -50,10 +53,14 @@ from music_assistant.server.helpers.compare import compare_strings from music_assistant.server.helpers.playlists import parse_m3u, parse_pls from music_assistant.server.helpers.tags import parse_tags, split_items from music_assistant.server.models.music_provider import MusicProvider -from music_assistant.server.providers.musicbrainz import MusicbrainzProvider from .helpers import get_parentdir +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from music_assistant.server.providers.musicbrainz import MusicbrainzProvider + CONF_MISSING_ALBUM_ARTIST_ACTION = "missing_album_artist_action" CONF_ENTRY_MISSING_ALBUM_ARTIST = ConfigEntry( @@ -73,7 +80,19 @@ CONF_ENTRY_MISSING_ALBUM_ARTIST = ConfigEntry( ), ) -TRACK_EXTENSIONS = ("mp3", "m4a", "m4b", "mp4", "flac", "wav", "ogg", "aiff", "wma", "dsf", "opus") +TRACK_EXTENSIONS = ( + "mp3", + "m4a", + "m4b", + "mp4", + "flac", + "wav", + "ogg", + "aiff", + "wma", + "dsf", + "opus", +) PLAYLIST_EXTENSIONS = ("m3u", "pls", "m3u8") SUPPORTED_EXTENSIONS = TRACK_EXTENSIONS + PLAYLIST_EXTENSIONS IMAGE_EXTENSIONS = ("jpg", "jpeg", "JPG", "JPEG", "png", "PNG", "gif", "GIF") @@ -197,7 +216,10 @@ class FileSystemProviderBase(MusicProvider): return False async def search( - self, search_query: str, media_types=list[MediaType] | None, limit: int = 5 # noqa: ARG002 + self, + search_query: str, + media_types=list[MediaType] | None, + limit: int = 5, ) -> SearchResults: """Perform search on this file based musicprovider.""" result = SearchResults() @@ -278,7 +300,7 @@ class FileSystemProviderBase(MusicProvider): name=item.name, ) - async def sync_library(self, media_types: tuple[MediaType, ...]) -> None: # noqa: ARG002 + async def sync_library(self, media_types: tuple[MediaType, ...]) -> None: """Run library sync for this provider.""" # first build a listing of all current items and their checksums prev_checksums = {} @@ -387,7 +409,8 @@ class FileSystemProviderBase(MusicProvider): prov_artist_id, self.instance_id ) if db_artist is None: - raise MediaNotFoundError(f"Artist not found: {prov_artist_id}") + msg = f"Artist not found: {prov_artist_id}" + raise MediaNotFoundError(msg) if await self.exists(prov_artist_id): # if path exists on disk allow parsing full details to allow refresh of metadata return await self._parse_artist(db_artist.name, artist_path=prov_artist_id) @@ -401,13 +424,15 @@ class FileSystemProviderBase(MusicProvider): if prov_mapping.provider_instance == self.instance_id: full_track = await self.get_track(prov_mapping.item_id) return full_track.album - raise MediaNotFoundError(f"Album not found: {prov_album_id}") + msg = f"Album not found: {prov_album_id}" + raise MediaNotFoundError(msg) async def get_track(self, prov_track_id: str) -> Track: """Get full track details by id.""" # ruff: noqa: PLR0915, PLR0912 if not await self.exists(prov_track_id): - raise MediaNotFoundError(f"Track path does not exist: {prov_track_id}") + msg = f"Track path does not exist: {prov_track_id}" + raise MediaNotFoundError(msg) file_item = await self.resolve(prov_track_id) return await self._parse_track(file_item) @@ -415,7 +440,8 @@ class FileSystemProviderBase(MusicProvider): async def get_playlist(self, prov_playlist_id: str) -> Playlist: """Get full playlist details by id.""" if not await self.exists(prov_playlist_id): - raise MediaNotFoundError(f"Playlist path does not exist: {prov_playlist_id}") + msg = f"Playlist path does not exist: {prov_playlist_id}" + raise MediaNotFoundError(msg) file_item = await self.resolve(prov_playlist_id) playlist = Playlist( @@ -443,7 +469,8 @@ class FileSystemProviderBase(MusicProvider): prov_album_id, self.instance_id ) if db_album is None: - raise MediaNotFoundError(f"Album not found: {prov_album_id}") + msg = f"Album not found: {prov_album_id}" + raise MediaNotFoundError(msg) album_tracks = await self.mass.music.albums.tracks(db_album.item_id, db_album.provider) return [ track @@ -456,7 +483,8 @@ class FileSystemProviderBase(MusicProvider): ) -> AsyncGenerator[PlaylistTrack, None]: """Get playlist tracks for given playlist id.""" if not await self.exists(prov_playlist_id): - raise MediaNotFoundError(f"Playlist path does not exist: {prov_playlist_id}") + msg = f"Playlist path does not exist: {prov_playlist_id}" + raise MediaNotFoundError(msg) _, ext = prov_playlist_id.rsplit(".", 1) try: @@ -514,7 +542,8 @@ class FileSystemProviderBase(MusicProvider): async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None: """Add track(s) to playlist.""" if not await self.exists(prov_playlist_id): - raise MediaNotFoundError(f"Playlist path does not exist: {prov_playlist_id}") + msg = f"Playlist path does not exist: {prov_playlist_id}" + raise MediaNotFoundError(msg) playlist_data = b"" async for chunk in self.read_file_content(prov_playlist_id): playlist_data += chunk @@ -531,7 +560,8 @@ class FileSystemProviderBase(MusicProvider): ) -> None: """Remove track(s) from playlist.""" if not await self.exists(prov_playlist_id): - raise MediaNotFoundError(f"Playlist path does not exist: {prov_playlist_id}") + msg = f"Playlist path does not exist: {prov_playlist_id}" + raise MediaNotFoundError(msg) cur_lines = [] _, ext = prov_playlist_id.rsplit(".", 1) @@ -570,7 +600,8 @@ class FileSystemProviderBase(MusicProvider): item_id, self.instance_id ) if library_item is None: - raise MediaNotFoundError(f"Item not found: {item_id}") + msg = f"Item not found: {item_id}" + raise MediaNotFoundError(msg) prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id) file_item = await self.resolve(item_id) @@ -645,7 +676,7 @@ class FileSystemProviderBase(MusicProvider): position=playlist_position, ) elif tags.album and tags.disc and tags.track: - track = AlbumTrack( + track = AlbumTrack( # pylint: disable=missing-kwoa **base_details, disc_number=tags.disc, track_number=tags.track, @@ -727,10 +758,15 @@ class FileSystemProviderBase(MusicProvider): # fallback to just log error and add track without album else: # default action is to skip the track - raise InvalidDataError("missing ID3 tag [albumartist]") + msg = "missing ID3 tag [albumartist]" + raise InvalidDataError(msg) track.album = await self._parse_album( - tags.album, album_dir, disc_dir, artists=album_artists, barcode=tags.barcode + tags.album, + album_dir, + disc_dir, + artists=album_artists, + barcode=tags.barcode, ) # track artist(s) @@ -928,7 +964,7 @@ class FileSystemProviderBase(MusicProvider): album.sort_name = sort_name if mbid := info.get("musicbrainzreleasegroupid"): album.mbid = mbid - if mb_artist_id := info.get("musicbrainzalbumartistid"): # noqa: SIM102 + if mb_artist_id := info.get("musicbrainzalbumartistid"): if album.artists and not album.artists[0].mbid: album.artists[0].mbid = mb_artist_id if description := info.get("review"): @@ -961,7 +997,9 @@ class FileSystemProviderBase(MusicProvider): try: images.append( MediaItemImage( - type=ImageType(item.name), path=item.path, provider=self.instance_id + type=ImageType(item.name), + path=item.path, + provider=self.instance_id, ) ) except ValueError: @@ -969,7 +1007,9 @@ class FileSystemProviderBase(MusicProvider): if item.name.lower().startswith(filename): images.append( MediaItemImage( - type=ImageType.THUMB, path=item.path, provider=self.instance_id + type=ImageType.THUMB, + path=item.path, + provider=self.instance_id, ) ) break diff --git a/music_assistant/server/providers/filesystem_smb/__init__.py b/music_assistant/server/providers/filesystem_smb/__init__.py index 1e468442..a5d1c7cc 100644 --- a/music_assistant/server/providers/filesystem_smb/__init__.py +++ b/music_assistant/server/providers/filesystem_smb/__init__.py @@ -37,11 +37,13 @@ async def setup( # check if valid dns name is given for the host server: str = config.get_value(CONF_HOST) if not await get_ip_from_host(server): - raise LoginFailed(f"Unable to resolve {server}, make sure the address is resolveable.") + msg = f"Unable to resolve {server}, make sure the address is resolveable." + raise LoginFailed(msg) # check if share is valid share: str = config.get_value(CONF_SHARE) if not share or "/" in share or "\\" in share: - raise LoginFailed("Invalid share name") + msg = "Invalid share name" + raise LoginFailed(msg) prov = SMBFileSystemProvider(mass, manifest, config) await prov.handle_setup() return prov @@ -132,7 +134,7 @@ class SMBFileSystemProvider(LocalFileSystemProvider): async def handle_setup(self) -> None: """Handle async initialization of the provider.""" # base_path will be the path where we're going to mount the remote share - self.base_path = f"/tmp/{self.instance_id}" + self.base_path = f"/tmp/{self.instance_id}" # noqa: S108 if not await exists(self.base_path): await makedirs(self.base_path) @@ -141,7 +143,8 @@ class SMBFileSystemProvider(LocalFileSystemProvider): await self.unmount(ignore_error=True) await self.mount() except Exception as err: - raise LoginFailed(f"Connection failed for the given details: {err}") from err + msg = f"Connection failed for the given details: {err}" + raise LoginFailed(msg) from err async def unload(self) -> None: """ @@ -183,7 +186,8 @@ class SMBFileSystemProvider(LocalFileSystemProvider): mount_cmd = f"mount -t cifs -o {','.join(options)} //{server}/{share}{subfolder} {self.base_path}" # noqa: E501 else: - raise LoginFailed(f"SMB provider is not supported on {platform.system()}") + msg = f"SMB provider is not supported on {platform.system()}" + raise LoginFailed(msg) self.logger.info("Mounting //%s/%s%s to %s", server, share, subfolder, self.base_path) self.logger.debug("Using mount command: %s", mount_cmd.replace(password, "########")) @@ -193,7 +197,8 @@ class SMBFileSystemProvider(LocalFileSystemProvider): ) _, stderr = await proc.communicate() if proc.returncode != 0: - raise LoginFailed(f"SMB mount failed with error: {stderr.decode()}") + msg = f"SMB mount failed with error: {stderr.decode()}" + raise LoginFailed(msg) async def unmount(self, ignore_error: bool = False) -> None: """Unmount the remote share.""" diff --git a/music_assistant/server/providers/fully_kiosk/__init__.py b/music_assistant/server/providers/fully_kiosk/__init__.py index 24d22c9e..6c2e662b 100644 --- a/music_assistant/server/providers/fully_kiosk/__init__.py +++ b/music_assistant/server/providers/fully_kiosk/__init__.py @@ -23,13 +23,13 @@ from music_assistant.common.models.enums import ( ) from music_assistant.common.models.errors import PlayerUnavailableError, SetupFailedError from music_assistant.common.models.player import DeviceInfo, Player -from music_assistant.common.models.queue_item import QueueItem from music_assistant.constants import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT from music_assistant.server.models.player_provider import PlayerProvider if TYPE_CHECKING: from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest + from music_assistant.common.models.queue_item import QueueItem from music_assistant.server import MusicAssistant from music_assistant.server.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType @@ -104,9 +104,8 @@ class FullyKioskProvider(PlayerProvider): self._handle_player_init() self._handle_player_update() except Exception as err: - raise SetupFailedError( - f"Unable to start the FullyKiosk connection ({str(err)}" - ) from err + msg = f"Unable to start the FullyKiosk connection ({err!s}" + raise SetupFailedError(msg) from err def _handle_player_init(self) -> None: """Process FullyKiosk add to Player controller.""" @@ -150,7 +149,8 @@ class FullyKioskProvider(PlayerProvider): async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]: """Return all (provider/player specific) Config Entries for the given player (if any).""" base_entries = await super().get_player_config_entries(player_id) - return base_entries + ( + return ( + *base_entries, CONF_ENTRY_CROSSFADE, CONF_ENTRY_CROSSFADE_DURATION, ConfigEntry( @@ -228,7 +228,7 @@ class FullyKioskProvider(PlayerProvider): player.state = PlayerState.PLAYING self.mass.players.update(player_id) - async def poll_player(self, player_id: str) -> None: # noqa: ARG002 + async def poll_player(self, player_id: str) -> None: """Poll player for state updates. This is called by the Player Manager; @@ -248,6 +248,5 @@ class FullyKioskProvider(PlayerProvider): await self._fully.getDeviceInfo() self._handle_player_update() except Exception as err: - raise PlayerUnavailableError( - f"Unable to start the FullyKiosk connection ({str(err)}" - ) from err + msg = f"Unable to start the FullyKiosk connection ({err!s}" + raise PlayerUnavailableError(msg) from err diff --git a/music_assistant/server/providers/musicbrainz/__init__.py b/music_assistant/server/providers/musicbrainz/__init__.py index a3980ad5..d39ee290 100644 --- a/music_assistant/server/providers/musicbrainz/__init__.py +++ b/music_assistant/server/providers/musicbrainz/__init__.py @@ -6,7 +6,6 @@ At this time only used for retrieval of ID's but to be expanded to fetch metadat from __future__ import annotations import re -from collections.abc import Iterable from contextlib import suppress from dataclasses import dataclass, field from json import JSONDecodeError @@ -18,7 +17,6 @@ from mashumaro import DataClassDictMixin from mashumaro.exceptions import MissingField from music_assistant.common.helpers.util import parse_title_and_version -from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType from music_assistant.common.models.enums import ExternalID, ProviderFeature from music_assistant.common.models.errors import InvalidDataError from music_assistant.server.controllers.cache import use_cache @@ -26,7 +24,13 @@ from music_assistant.server.helpers.compare import compare_strings from music_assistant.server.models.metadata_provider import MetadataProvider if TYPE_CHECKING: - from music_assistant.common.models.config_entries import ProviderConfig + from collections.abc import Iterable + + from music_assistant.common.models.config_entries import ( + ConfigEntry, + ConfigValueType, + ProviderConfig, + ) from music_assistant.common.models.media_items import Album, Artist, Track from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant @@ -35,7 +39,7 @@ if TYPE_CHECKING: LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' -SUPPORTED_FEATURES = tuple() +SUPPORTED_FEATURES = () async def setup( @@ -61,7 +65,7 @@ async def get_config_entries( values: the (intermediate) raw values for config entries sent with the action. """ # ruff: noqa: ARG001 - return tuple() # we do not have any config entries (yet) + return () # we do not have any config entries (yet) def replace_hyphens(data: dict[str, Any]) -> dict[str, Any]: @@ -320,7 +324,8 @@ class MusicbrainzProvider(MetadataProvider): return MusicBrainzArtist.from_dict(replace_hyphens(result)) except MissingField as err: raise InvalidDataError from err - raise InvalidDataError("Invalid MusicBrainz Artist ID provided") + msg = "Invalid MusicBrainz Artist ID provided" + raise InvalidDataError(msg) async def get_recording_details( self, recording_id: str | None = None, isrsc: str | None = None @@ -332,7 +337,8 @@ class MusicbrainzProvider(MetadataProvider): if (result := await self.get_data(f"isrc/{isrsc}")) and result.get("recordings"): recording_id = result["recordings"][0]["id"] else: - raise InvalidDataError("Invalid ISRC provided") + msg = "Invalid ISRC provided" + raise InvalidDataError(msg) if result := await self.get_data(f"recording/{recording_id}?inc=artists+releases"): if "id" not in result: result["id"] = recording_id @@ -340,7 +346,8 @@ class MusicbrainzProvider(MetadataProvider): return MusicBrainzRecording.from_dict(replace_hyphens(result)) except MissingField as err: raise InvalidDataError from err - raise InvalidDataError("Invalid ISRC provided") + msg = "Invalid ISRC provided" + raise InvalidDataError(msg) async def get_releasegroup_details( self, releasegroup_id: str | None = None, barcode: str | None = None @@ -353,7 +360,8 @@ class MusicbrainzProvider(MetadataProvider): if (result := await self.get_data(endpoint)) and result.get("releases"): releasegroup_id = result["releases"][0]["release-group"]["id"] else: - raise InvalidDataError("Invalid barcode provided") + msg = "Invalid barcode provided" + raise InvalidDataError(msg) endpoint = f"release-group/{releasegroup_id}?inc=artists+aliases" if result := await self.get_data(endpoint): if "id" not in result: @@ -362,7 +370,8 @@ class MusicbrainzProvider(MetadataProvider): return MusicBrainzReleaseGroup.from_dict(replace_hyphens(result)) except MissingField as err: raise InvalidDataError from err - raise InvalidDataError("Invalid MusicBrainz ReleaseGroup ID or barcode provided") + msg = "Invalid MusicBrainz ReleaseGroup ID or barcode provided" + raise InvalidDataError(msg) async def get_artist_details_by_album( self, artistname: str, ref_album: Album diff --git a/music_assistant/server/providers/opensubsonic/__init__.py b/music_assistant/server/providers/opensubsonic/__init__.py index 81dbdabd..b0349f13 100644 --- a/music_assistant/server/providers/opensubsonic/__init__.py +++ b/music_assistant/server/providers/opensubsonic/__init__.py @@ -2,19 +2,23 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from music_assistant.common.models.config_entries import ( ConfigEntry, ConfigValueType, ProviderConfig, ) from music_assistant.common.models.enums import ConfigEntryType -from music_assistant.common.models.provider import ProviderManifest from music_assistant.constants import CONF_PASSWORD, CONF_PATH, CONF_PORT, CONF_USERNAME -from music_assistant.server import MusicAssistant -from music_assistant.server.models import ProviderInstanceType from .sonic_provider import CONF_BASE_URL, CONF_ENABLE_PODCASTS, OpenSonicProvider +if TYPE_CHECKING: + from music_assistant.common.models.provider import ProviderManifest + from music_assistant.server import MusicAssistant + from music_assistant.server.models import ProviderInstanceType + async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig diff --git a/music_assistant/server/providers/opensubsonic/sonic_provider.py b/music_assistant/server/providers/opensubsonic/sonic_provider.py index 8c60f8b6..c40d1920 100644 --- a/music_assistant/server/providers/opensubsonic/sonic_provider.py +++ b/music_assistant/server/providers/opensubsonic/sonic_provider.py @@ -3,8 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Callable -from typing import Any +from typing import TYPE_CHECKING, Any from libopensonic.connection import Connection as SonicConnection from libopensonic.errors import ( @@ -14,14 +13,6 @@ from libopensonic.errors import ( ParameterError, SonicError, ) -from libopensonic.media import Album as SonicAlbum -from libopensonic.media import AlbumInfo as SonicAlbumInfo -from libopensonic.media import Artist as SonicArtist -from libopensonic.media import ArtistInfo as SonicArtistInfo -from libopensonic.media import Playlist as SonicPlaylist -from libopensonic.media import PodcastChannel as SonicPodcastChannel -from libopensonic.media import PodcastEpisode as SonicPodcastEpisode -from libopensonic.media import Song as SonicSong from music_assistant.common.models.enums import ContentType, ImageType, MediaType, ProviderFeature from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError @@ -49,6 +40,18 @@ from music_assistant.constants import ( ) from music_assistant.server.models.music_provider import MusicProvider +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Callable + + from libopensonic.media import Album as SonicAlbum + from libopensonic.media import AlbumInfo as SonicAlbumInfo + from libopensonic.media import Artist as SonicArtist + from libopensonic.media import ArtistInfo as SonicArtistInfo + from libopensonic.media import Playlist as SonicPlaylist + from libopensonic.media import PodcastChannel as SonicPodcastChannel + from libopensonic.media import PodcastEpisode as SonicPodcastEpisode + from libopensonic.media import Song as SonicSong + CONF_BASE_URL = "baseURL" CONF_ENABLE_PODCASTS = "enable_podcasts" @@ -79,14 +82,16 @@ class OpenSonicProvider(MusicProvider): ) try: if not self._conn.ping(): - raise LoginFailed( + msg = ( f"Failed to connect to {self.config.get_value(CONF_BASE_URL)}, " "check your settings." ) + raise LoginFailed(msg) except (AuthError, CredentialError) as e: - raise LoginFailed( + msg = ( f"Failed to connect to {self.config.get_value(CONF_BASE_URL)}, check your settings." - ) from e + ) + raise LoginFailed(msg) from e self._enable_podcasts = self.config.get_value(CONF_ENABLE_PODCASTS) @property @@ -149,7 +154,7 @@ class OpenSonicProvider(MusicProvider): return artist def _parse_podcast_album(self, sonic_channel: SonicPodcastChannel) -> Album: - album = Album( + return Album( item_id=sonic_channel.id, provider=self.instance_id, name=sonic_channel.title, @@ -163,7 +168,6 @@ class OpenSonicProvider(MusicProvider): }, album_type=AlbumType.PODCAST, ) - return album def _parse_podcast_episode( self, sonic_episode: SonicPodcastEpisode, sonic_channel: SonicPodcastChannel @@ -519,7 +523,8 @@ class OpenSonicProvider(MusicProvider): return self._parse_podcast_album(sonic_channel=sonic_channel) except SonicError: pass - raise MediaNotFoundError(f"Album {prov_album_id} not found") from e + msg = f"Album {prov_album_id} not found" + raise MediaNotFoundError(msg) from e return self._parse_album(sonic_album, sonic_info) @@ -528,7 +533,8 @@ class OpenSonicProvider(MusicProvider): try: sonic_album: SonicAlbum = await self._run_async(self._conn.getAlbum, prov_album_id) except (ParameterError, DataNotFoundError) as e: - raise MediaNotFoundError(f"Album {prov_album_id} not found") from e + msg = f"Album {prov_album_id} not found" + raise MediaNotFoundError(msg) from e tracks = [] for sonic_song in sonic_album.songs: tracks.append(self._parse_track(sonic_song)) @@ -565,7 +571,8 @@ class OpenSonicProvider(MusicProvider): return self._parse_podcast_artist(sonic_channel=sonic_channel[0]) except SonicError: pass - raise MediaNotFoundError(f"Artist {prov_artist_id} not found") from e + msg = f"Artist {prov_artist_id} not found" + raise MediaNotFoundError(msg) from e return self._parse_artist(sonic_artist, sonic_info) async def get_track(self, prov_track_id: str) -> Track: @@ -573,7 +580,8 @@ class OpenSonicProvider(MusicProvider): try: sonic_song: SonicSong = await self._run_async(self._conn.getSong, prov_track_id) except (ParameterError, DataNotFoundError) as e: - raise MediaNotFoundError(f"Item {prov_track_id} not found") from e + msg = f"Item {prov_track_id} not found" + raise MediaNotFoundError(msg) from e return self._parse_track(sonic_song) async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: @@ -584,7 +592,8 @@ class OpenSonicProvider(MusicProvider): try: sonic_artist: SonicArtist = await self._run_async(self._conn.getArtist, prov_artist_id) except (ParameterError, DataNotFoundError) as e: - raise MediaNotFoundError(f"Album {prov_artist_id} not found") from e + msg = f"Album {prov_artist_id} not found" + raise MediaNotFoundError(msg) from e albums = [] for entry in sonic_artist.albums: albums.append(self._parse_album(entry)) @@ -597,7 +606,8 @@ class OpenSonicProvider(MusicProvider): self._conn.getPlaylist, prov_playlist_id ) except (ParameterError, DataNotFoundError) as e: - raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") from e + msg = f"Playlist {prov_playlist_id} not found" + raise MediaNotFoundError(msg) from e return self._parse_playlist(sonic_playlist) async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]: @@ -607,7 +617,8 @@ class OpenSonicProvider(MusicProvider): self._conn.getPlaylist, prov_playlist_id ) except (ParameterError, DataNotFoundError) as e: - raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") from e + msg = f"Playlist {prov_playlist_id} not found" + raise MediaNotFoundError(msg) from e for index, sonic_song in enumerate(sonic_playlist.songs): yield self._parse_track(sonic_song, {"position": index + 1}) @@ -629,7 +640,8 @@ class OpenSonicProvider(MusicProvider): try: sonic_song: SonicSong = await self._run_async(self._conn.getSong, item_id) except (ParameterError, DataNotFoundError) as e: - raise MediaNotFoundError(f"Item {item_id} not found") from e + msg = f"Item {item_id} not found" + raise MediaNotFoundError(msg) from e self.mass.create_task(self._report_playback_started(item_id)) @@ -658,7 +670,7 @@ class OpenSonicProvider(MusicProvider): """Provide a generator for the stream data.""" audio_buffer = asyncio.Queue(1) - def _streamer(): + def _streamer() -> None: with self._conn.stream( streamdetails.item_id, timeOffset=seek_position, estimateContentLength=True ) as stream: diff --git a/music_assistant/server/providers/plex/__init__.py b/music_assistant/server/providers/plex/__init__.py index 3086be23..fafdba74 100644 --- a/music_assistant/server/providers/plex/__init__.py +++ b/music_assistant/server/providers/plex/__init__.py @@ -5,8 +5,7 @@ from __future__ import annotations import asyncio import logging from asyncio import TaskGroup -from collections.abc import AsyncGenerator, Callable, Coroutine -from typing import Any +from typing import TYPE_CHECKING, Any import plexapi.exceptions from aiohttp import ClientTimeout @@ -14,10 +13,6 @@ from plexapi.audio import Album as PlexAlbum from plexapi.audio import Artist as PlexArtist from plexapi.audio import Playlist as PlexPlaylist from plexapi.audio import Track as PlexTrack -from plexapi.library import MusicSection as PlexMusicSection -from plexapi.media import AudioStream as PlexAudioStream -from plexapi.media import Media as PlexMedia -from plexapi.media import MediaPart as PlexMediaPart from plexapi.myplex import MyPlexAccount, MyPlexPinLogin from plexapi.server import PlexServer @@ -34,7 +29,11 @@ from music_assistant.common.models.enums import ( MediaType, ProviderFeature, ) -from music_assistant.common.models.errors import InvalidDataError, LoginFailed, MediaNotFoundError +from music_assistant.common.models.errors import ( + InvalidDataError, + LoginFailed, + MediaNotFoundError, +) from music_assistant.common.models.media_items import ( Album, AlbumTrack, @@ -51,13 +50,25 @@ from music_assistant.common.models.media_items import ( StreamDetails, Track, ) -from music_assistant.common.models.provider import ProviderManifest -from music_assistant.server import MusicAssistant from music_assistant.server.helpers.auth import AuthenticationHelper from music_assistant.server.helpers.tags import parse_tags -from music_assistant.server.models import ProviderInstanceType from music_assistant.server.models.music_provider import MusicProvider -from music_assistant.server.providers.plex.helpers import discover_local_servers, get_libraries +from music_assistant.server.providers.plex.helpers import ( + discover_local_servers, + get_libraries, +) + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Callable, Coroutine + + from plexapi.library import MusicSection as PlexMusicSection + from plexapi.media import AudioStream as PlexAudioStream + from plexapi.media import Media as PlexMedia + from plexapi.media import MediaPart as PlexMediaPart + + from music_assistant.common.models.provider import ProviderManifest + from music_assistant.server import MusicAssistant + from music_assistant.server.models import ProviderInstanceType CONF_ACTION_AUTH = "auth" CONF_ACTION_LIBRARY = "library" @@ -75,7 +86,8 @@ async def setup( ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" if not config.get_value(CONF_AUTH_TOKEN): - raise LoginFailed("Invalid login credentials") + msg = "Invalid login credentials" + raise LoginFailed(msg) prov = PlexProvider(mass, manifest, config) await prov.handle_setup() @@ -118,7 +130,8 @@ async def get_config_entries( auth_url = plex_auth.oauthUrl(auth_helper.callback_url) await auth_helper.authenticate(auth_url) if not plex_auth.checkLogin(): - raise LoginFailed("Authentication to MyPlex failed") + msg = "Authentication to MyPlex failed" + raise LoginFailed(msg) # set the retrieved token on the values object to pass along values[CONF_AUTH_TOKEN] = plex_auth.token @@ -140,7 +153,8 @@ async def get_config_entries( server_http_ip = values.get(CONF_LOCAL_SERVER_IP) server_http_port = values.get(CONF_LOCAL_SERVER_PORT) if not (libraries := await get_libraries(mass, token, server_http_ip, server_http_port)): - raise LoginFailed("Unable to retrieve Servers and/or Music Libraries") + msg = "Unable to retrieve Servers and/or Music Libraries" + raise LoginFailed(msg) conf_libraries.options = tuple( # use the same value for both the value and the title # until we find out what plex uses as stable identifiers @@ -196,7 +210,7 @@ class PlexProvider(MusicProvider): """Set up the music provider by connecting to the server.""" # silence urllib logger logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO) - server_name, library_name = self.config.get_value(CONF_LIBRARY_ID).split(" / ", 1) + _, library_name = self.config.get_value(CONF_LIBRARY_ID).split(" / ", 1) def connect() -> PlexServer: try: @@ -208,8 +222,9 @@ class PlexProvider(MusicProvider): if "Invalid token" in str(err): # token invalid, invalidate the config self.mass.config.remove_provider_config_value(self.instance_id, CONF_AUTH_TOKEN) - raise LoginFailed("Authentication failed") - raise LoginFailed() from err + msg = "Authentication failed" + raise LoginFailed(msg) + raise LoginFailed from err return plex_server self._myplex_account = await self.get_myplex_account_and_refresh_token( @@ -278,7 +293,7 @@ class PlexProvider(MusicProvider): return ItemMapping.from_item(paged_list.items[0]) artist_id = FAKE_ARTIST_PREFIX + artist_name - artist = Artist( + return Artist( item_id=artist_id, name=artist_name, provider=self.domain, @@ -290,7 +305,6 @@ class PlexProvider(MusicProvider): ) }, ) - return artist async def _parse(self, plex_media) -> MediaItem | None: if plex_media.type == "artist": @@ -385,7 +399,8 @@ class PlexProvider(MusicProvider): """Parse a Plex Artist response to Artist model object.""" artist_id = plex_artist.key if not artist_id: - raise InvalidDataError("Artist does not have a valid ID") + msg = "Artist does not have a valid ID" + raise InvalidDataError(msg) artist = Artist( item_id=artist_id, name=plex_artist.title, @@ -480,11 +495,14 @@ class PlexProvider(MusicProvider): elif plex_track.grandparentKey: track.artists.append( self._get_item_mapping( - MediaType.ARTIST, plex_track.grandparentKey, plex_track.grandparentTitle + MediaType.ARTIST, + plex_track.grandparentKey, + plex_track.grandparentTitle, ) ) else: - raise InvalidDataError("No artist was found for track") + msg = "No artist was found for track" + raise InvalidDataError(msg) if thumb := plex_track.firstAttr("thumb", "parentThumb", "grandparentThumb"): track.metadata.images = [ @@ -525,7 +543,12 @@ class PlexProvider(MusicProvider): :param limit: Number of items to return in the search (per type). """ if not media_types: - media_types = [MediaType.ARTIST, MediaType.ALBUM, MediaType.TRACK, MediaType.PLAYLIST] + media_types = [ + MediaType.ARTIST, + MediaType.ALBUM, + MediaType.TRACK, + MediaType.PLAYLIST, + ] tasks = {} @@ -552,7 +575,8 @@ class PlexProvider(MusicProvider): elif media_type == MediaType.PLAYLIST: tasks[MediaType.ARTIST] = tg.create_task( self._search_and_parse( - self._search_playlist(search_query, limit), self._parse_playlist + self._search_playlist(search_query, limit), + self._parse_playlist, ) ) @@ -598,7 +622,8 @@ class PlexProvider(MusicProvider): """Get full album details by id.""" if plex_album := await self._get_data(prov_album_id, PlexAlbum): return await self._parse_album(plex_album) - raise MediaNotFoundError(f"Item {prov_album_id} not found") + msg = f"Item {prov_album_id} not found" + raise MediaNotFoundError(msg) async def get_album_tracks(self, prov_album_id: str) -> list[AlbumTrack]: """Get album tracks for given album id.""" @@ -624,23 +649,27 @@ class PlexProvider(MusicProvider): prov_artist_id, self.instance_id ): return db_artist - raise MediaNotFoundError(f"Artist not found: {prov_artist_id}") + msg = f"Artist not found: {prov_artist_id}" + raise MediaNotFoundError(msg) if plex_artist := await self._get_data(prov_artist_id, PlexArtist): return await self._parse_artist(plex_artist) - raise MediaNotFoundError(f"Item {prov_artist_id} not found") + msg = f"Item {prov_artist_id} not found" + raise MediaNotFoundError(msg) async def get_track(self, prov_track_id) -> Track: """Get full track details by id.""" if plex_track := await self._get_data(prov_track_id, PlexTrack): return await self._parse_track(plex_track) - raise MediaNotFoundError(f"Item {prov_track_id} not found") + msg = f"Item {prov_track_id} not found" + raise MediaNotFoundError(msg) async def get_playlist(self, prov_playlist_id) -> Playlist: """Get full playlist details by id.""" if plex_playlist := await self._get_data(prov_playlist_id, PlexPlaylist): return await self._parse_playlist(plex_playlist) - raise MediaNotFoundError(f"Item {prov_playlist_id} not found") + msg = f"Item {prov_playlist_id} not found" + raise MediaNotFoundError(msg) async def get_playlist_tracks( # type: ignore[return] self, prov_playlist_id: str @@ -669,7 +698,8 @@ class PlexProvider(MusicProvider): """Get streamdetails for a track.""" plex_track = await self._get_data(item_id, PlexTrack) if not plex_track or not plex_track.media: - raise MediaNotFoundError(f"track {item_id} not found") + msg = f"track {item_id} not found" + raise MediaNotFoundError(msg) media: PlexMedia = plex_track.media[0] @@ -732,5 +762,4 @@ class PlexProvider(MusicProvider): self._myplex_account.ping() return self._myplex_account - result = await asyncio.to_thread(_refresh_plex_token) - return result + return await asyncio.to_thread(_refresh_plex_token) diff --git a/music_assistant/server/providers/plex/helpers.py b/music_assistant/server/providers/plex/helpers.py index a60de938..21e3d4e0 100644 --- a/music_assistant/server/providers/plex/helpers.py +++ b/music_assistant/server/providers/plex/helpers.py @@ -31,7 +31,7 @@ async def get_libraries( f"http://{local_server_ip}:{local_server_port}", auth_token ) for media_section in plex_server.library.sections(): - media_section: PlexLibrarySection # noqa: PLW2901 + media_section: PlexLibrarySection if media_section.type != PlexMusicSection.TYPE: continue # TODO: figure out what plex uses as stable id and use that instead of names @@ -62,5 +62,4 @@ async def discover_local_servers(): else: return None, None - result = await asyncio.to_thread(_discover_local_servers) - return result + return await asyncio.to_thread(_discover_local_servers) diff --git a/music_assistant/server/providers/qobuz/__init__.py b/music_assistant/server/providers/qobuz/__init__.py index 504421c1..44a60182 100644 --- a/music_assistant/server/providers/qobuz/__init__.py +++ b/music_assistant/server/providers/qobuz/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import datetime import hashlib import time -from collections.abc import AsyncGenerator from json import JSONDecodeError from typing import TYPE_CHECKING @@ -14,7 +13,11 @@ from asyncio_throttle import Throttler from music_assistant.common.helpers.util import parse_title_and_version, try_parse_int from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType -from music_assistant.common.models.enums import ConfigEntryType, ExternalID, ProviderFeature +from music_assistant.common.models.enums import ( + ConfigEntryType, + ExternalID, + ProviderFeature, +) from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError from music_assistant.common.models.media_items import ( Album, @@ -39,10 +42,16 @@ from music_assistant.constants import ( VARIOUS_ARTISTS_ID_MBID, VARIOUS_ARTISTS_NAME, ) -from music_assistant.server.helpers.app_vars import app_var # pylint: disable=no-name-in-module + +# pylint: disable=no-name-in-module +from music_assistant.server.helpers.app_vars import app_var + +# pylint: enable=no-name-in-module from music_assistant.server.models.music_provider import MusicProvider if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant @@ -93,10 +102,16 @@ async def get_config_entries( # ruff: noqa: ARG001 return ( ConfigEntry( - key=CONF_USERNAME, type=ConfigEntryType.STRING, label="Username", required=True + key=CONF_USERNAME, + type=ConfigEntryType.STRING, + label="Username", + required=True, ), ConfigEntry( - key=CONF_PASSWORD, type=ConfigEntryType.SECURE_STRING, label="Password", required=True + key=CONF_PASSWORD, + type=ConfigEntryType.SECURE_STRING, + label="Password", + required=True, ), ) @@ -112,11 +127,13 @@ class QobuzProvider(MusicProvider): self._throttler = Throttler(rate_limit=4, period=1) if not self.config.get_value(CONF_USERNAME) or not self.config.get_value(CONF_PASSWORD): - raise LoginFailed("Invalid login credentials") + msg = "Invalid login credentials" + raise LoginFailed(msg) # try to get a token, raise if that fails token = await self._auth_token() if not token: - raise LoginFailed(f"Login failed for user {self.config.get_value(CONF_USERNAME)}") + msg = f"Login failed for user {self.config.get_value(CONF_USERNAME)}" + raise LoginFailed(msg) @property def supported_features(self) -> tuple[ProviderFeature, ...]: @@ -204,28 +221,32 @@ class QobuzProvider(MusicProvider): params = {"artist_id": prov_artist_id} if (artist_obj := await self._get_data("artist/get", **params)) and artist_obj["id"]: return await self._parse_artist(artist_obj) - raise MediaNotFoundError(f"Item {prov_artist_id} not found") + msg = f"Item {prov_artist_id} not found" + raise MediaNotFoundError(msg) async def get_album(self, prov_album_id) -> Album: """Get full album details by id.""" params = {"album_id": prov_album_id} if (album_obj := await self._get_data("album/get", **params)) and album_obj["id"]: return await self._parse_album(album_obj) - raise MediaNotFoundError(f"Item {prov_album_id} not found") + msg = f"Item {prov_album_id} not found" + raise MediaNotFoundError(msg) async def get_track(self, prov_track_id) -> Track: """Get full track details by id.""" params = {"track_id": prov_track_id} if (track_obj := await self._get_data("track/get", **params)) and track_obj["id"]: return await self._parse_track(track_obj) - raise MediaNotFoundError(f"Item {prov_track_id} not found") + msg = f"Item {prov_track_id} not found" + raise MediaNotFoundError(msg) async def get_playlist(self, prov_playlist_id) -> Playlist: """Get full playlist details by id.""" params = {"playlist_id": prov_playlist_id} if (playlist_obj := await self._get_data("playlist/get", **params)) and playlist_obj["id"]: return await self._parse_playlist(playlist_obj) - raise MediaNotFoundError(f"Item {prov_playlist_id} not found") + msg = f"Item {prov_playlist_id} not found" + raise MediaNotFoundError(msg) async def get_album_tracks(self, prov_album_id) -> list[AlbumTrack]: """Get all album tracks for given album id.""" @@ -298,7 +319,7 @@ class QobuzProvider(MusicProvider): ) ] - async def get_similar_artists(self, prov_artist_id): + async def get_similar_artists(self, prov_artist_id) -> None: """Get similar artists for given artist.""" # https://www.qobuz.com/api.json/0.2/artist/getSimilarArtists?artist_id=220020&offset=0&limit=3 @@ -374,13 +395,15 @@ class QobuzProvider(MusicProvider): streamdata = result break if not streamdata: - raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}") + msg = f"Unable to retrieve stream details for {item_id}" + raise MediaNotFoundError(msg) if streamdata["mime_type"] == "audio/mpeg": content_type = ContentType.MPEG elif streamdata["mime_type"] == "audio/flac": content_type = ContentType.FLAC else: - raise MediaNotFoundError(f"Unsupported mime type for {item_id}") + msg = f"Unsupported mime type for {item_id}" + raise MediaNotFoundError(msg) # report playback started as soon as the streamdetails are requested self.mass.create_task(self._report_playback_started(streamdata)) return StreamDetails( @@ -460,7 +483,7 @@ class QobuzProvider(MusicProvider): artist.metadata.description = artist_obj["biography"].get("content") return artist - async def _parse_album(self, album_obj: dict, artist_obj: dict = None): + async def _parse_album(self, album_obj: dict, artist_obj: dict | None = None): """Parse qobuz album object to generic layout.""" if not artist_obj and "artist" not in album_obj: # artist missing in album info, return full abum instead @@ -577,7 +600,18 @@ class QobuzProvider(MusicProvider): role = performer_str.split(", ")[1] name = performer_str.split(", ")[0] if "artist" in role.lower(): - artist = Artist(item_id=name, provider=self.domain, name=name) + artist = Artist( + item_id=name, + provider=self.domain, + name=name, + provider_mappings={ + ProviderMapping( + item_id=name, + provider_domain=self.domain, + provider_instance=self.instance_id, + ) + }, + ) track.artists.append(artist) # TODO: fix grabbing composer from details diff --git a/music_assistant/server/providers/qobuz/icon.svg b/music_assistant/server/providers/qobuz/icon.svg old mode 100755 new mode 100644 diff --git a/music_assistant/server/providers/qobuz/icon_dark.svg b/music_assistant/server/providers/qobuz/icon_dark.svg old mode 100755 new mode 100644 diff --git a/music_assistant/server/providers/radiobrowser/__init__.py b/music_assistant/server/providers/radiobrowser/__init__.py index a55b0034..3a8d779d 100644 --- a/music_assistant/server/providers/radiobrowser/__init__.py +++ b/music_assistant/server/providers/radiobrowser/__init__.py @@ -2,13 +2,11 @@ from __future__ import annotations -from collections.abc import AsyncGenerator from time import time from typing import TYPE_CHECKING from radios import FilterBy, Order, RadioBrowser, RadioBrowserError -from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType from music_assistant.common.models.enums import LinkType, ProviderFeature from music_assistant.common.models.media_items import ( AudioFormat, @@ -30,7 +28,13 @@ from music_assistant.server.models.music_provider import MusicProvider SUPPORTED_FEATURES = (ProviderFeature.SEARCH, ProviderFeature.BROWSE) if TYPE_CHECKING: - from music_assistant.common.models.config_entries import ProviderConfig + from collections.abc import AsyncGenerator + + from music_assistant.common.models.config_entries import ( + ConfigEntry, + ConfigValueType, + ProviderConfig, + ) from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant from music_assistant.server.models import ProviderInstanceType @@ -59,7 +63,7 @@ async def get_config_entries( values: the (intermediate) raw values for config entries sent with the action. """ # ruff: noqa: ARG001 D205 - return tuple() # we do not have any config entries (yet) + return () # we do not have any config entries (yet) class RadioBrowserProvider(MusicProvider): @@ -79,7 +83,7 @@ class RadioBrowserProvider(MusicProvider): # Try to get some stats to check connection to RadioBrowser API await self.radios.stats() except RadioBrowserError as err: - self.logger.error("%s", err) + self.logger.exception("%s", err) async def search( self, search_query: str, media_types=list[MediaType] | None, limit: int = 10 @@ -293,7 +297,9 @@ class RadioBrowserProvider(MusicProvider): ) async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 # noqa: ARG002 + self, + streamdetails: StreamDetails, + seek_position: int = 0, ) -> AsyncGenerator[bytes, None]: """Return the audio stream for the provider item.""" async for chunk in get_radio_stream(self.mass, streamdetails.data, streamdetails): diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index e3b65d97..5a5e0d72 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -6,7 +6,6 @@ import asyncio import statistics import time from collections import deque -from collections.abc import Callable, Coroutine from contextlib import suppress from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -37,15 +36,17 @@ from music_assistant.common.models.enums import ( ) from music_assistant.common.models.errors import QueueEmpty, SetupFailedError from music_assistant.common.models.player import DeviceInfo, Player -from music_assistant.common.models.queue_item import QueueItem from music_assistant.constants import CONF_CROSSFADE, CONF_CROSSFADE_DURATION, CONF_PORT from music_assistant.server.models.player_provider import PlayerProvider from .cli import LmsCli if TYPE_CHECKING: + from collections.abc import Callable, Coroutine + from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest + from music_assistant.common.models.queue_item import QueueItem from music_assistant.server import MusicAssistant from music_assistant.server.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType @@ -213,9 +214,8 @@ class SlimprotoProvider(PlayerProvider): ] self.logger.info("Started SLIMProto server on port %s", self.port) except OSError: - raise SetupFailedError( - f"Unable to start the Slimproto server - is port {self.port} already taken ?" - ) + msg = f"Unable to start the Slimproto server - is port {self.port} already taken ?" + raise SetupFailedError(msg) # start CLI interface(s) enable_telnet = self.config.get_value(CONF_CLI_TELNET) @@ -240,7 +240,8 @@ class SlimprotoProvider(PlayerProvider): async def unload(self) -> None: """Handle close/cleanup of the provider.""" if getattr(self, "_virtual_providers", None): - raise RuntimeError("Virtual providers loaded") + msg = "Virtual providers loaded" + raise RuntimeError(msg) if hasattr(self, "_socket_clients"): for client in list(self._socket_clients.values()): with suppress(RuntimeError): @@ -264,8 +265,10 @@ class SlimprotoProvider(PlayerProvider): self.logger.debug("Socket client connected: %s", addr) def client_callback( - event_type: SlimEventType, client: SlimClient, data: Any = None # noqa: ARG001 - ): + event_type: SlimEventType, + client: SlimClient, + data: Any = None, + ) -> None: if event_type == SlimEventType.PLAYER_DISCONNECTED: self.mass.create_task(self._handle_disconnected(client)) return @@ -304,7 +307,7 @@ class SlimprotoProvider(PlayerProvider): return base_entries # create preset entries (for players that support it) - preset_entries = tuple() + preset_entries = () if client.device_model not in self._virtual_providers: presets = [] async for playlist in self.mass.music.playlists.iter_library_items(True): @@ -404,7 +407,8 @@ class SlimprotoProvider(PlayerProvider): self._resync_handle = None player = self.mass.players.get(player_id) if player.synced_to: - raise RuntimeError("A synced player cannot receive play commands directly") + msg = "A synced player cannot receive play commands directly" + raise RuntimeError(msg) # stop any existing streams first await self.cmd_stop(player_id) if player.group_childs: @@ -471,7 +475,7 @@ class SlimprotoProvider(PlayerProvider): ) ) - async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem): + async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem) -> None: """Handle enqueuing of the next queue item on the player.""" # we don't have to do anything, # enqueuing the next item is handled in the buffer ready callback @@ -555,7 +559,7 @@ class SlimprotoProvider(PlayerProvider): active_queue = self.mass.player_queues.get_active_queue(parent_player.player_id) if parent_player.state == PlayerState.PLAYING: # playback needs to be restarted to form a new multi client stream session - def resync(): + def resync() -> None: self._resync_handle = None self.mass.create_task( self.mass.player_queues.resume(active_queue.queue_id, fade_in=False) @@ -699,7 +703,7 @@ class SlimprotoProvider(PlayerProvider): if client.state != SlimPlayerState.PLAYING: return - if backoff_time := self._do_not_resync_before.get(client.player_id): # noqa: SIM102 + if backoff_time := self._do_not_resync_before.get(client.player_id): # player has set a timestamp we should backoff from syncing it if time.time() < backoff_time: return @@ -748,12 +752,12 @@ class SlimprotoProvider(PlayerProvider): # handle player lagging behind, fix with skip_ahead self.logger.debug("%s resync: skipAhead %sms", player.display_name, delta) self._do_not_resync_before[client.player_id] = time.time() + 2 - asyncio.create_task(self._skip_over(client.player_id, delta)) + self.mass.create_task(self._skip_over(client.player_id, delta)) else: # handle player is drifting too far ahead, use pause_for to adjust self.logger.debug("%s resync: pauseFor %sms", player.display_name, delta) self._do_not_resync_before[client.player_id] = time.time() + (delta / 1000) + 2 - asyncio.create_task(self._pause_for(client.player_id, delta)) + self.mass.create_task(self._pause_for(client.player_id, delta)) async def _handle_decoder_ready(self, client: SlimClient) -> None: """Handle decoder ready event, player is ready for the next track.""" @@ -805,13 +809,13 @@ class SlimprotoProvider(PlayerProvider): await asyncio.sleep(0.1) # all child's ready (or timeout) - start play async with asyncio.TaskGroup() as tg: - for client in self._get_sync_clients(player.player_id): - timestamp = client.jiffies + 20 + for _client in self._get_sync_clients(player.player_id): + timestamp = _client.jiffies + 20 sync_delay = self.mass.config.get_raw_player_config_value( - client.player_id, CONF_SYNC_ADJUST, 0 + _client.player_id, CONF_SYNC_ADJUST, 0 ) timestamp -= sync_delay - self._do_not_resync_before[client.player_id] = time.time() + 1 + self._do_not_resync_before[_client.player_id] = time.time() + 1 tg.create_task(client.send_strm(b"u", replay_gain=int(timestamp))) async def _handle_connected(self, client: SlimClient) -> None: @@ -854,7 +858,8 @@ class SlimprotoProvider(PlayerProvider): if client := self._socket_clients.pop(player_id, None): # store last state in cache await self.mass.cache.set( - f"{CACHE_KEY_PREV_STATE}.{player_id}", (client.powered, client.volume_level) + f"{CACHE_KEY_PREV_STATE}.{player_id}", + (client.powered, client.volume_level), ) self.logger.info( "Player %s disconnected", diff --git a/music_assistant/server/providers/slimproto/cli.py b/music_assistant/server/providers/slimproto/cli.py index 3822fd2e..805fad34 100644 --- a/music_assistant/server/providers/slimproto/cli.py +++ b/music_assistant/server/providers/slimproto/cli.py @@ -15,7 +15,6 @@ import asyncio import contextlib import time import urllib.parse -from collections.abc import Callable from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -24,12 +23,13 @@ from aiohttp import web from music_assistant.common.helpers.json import json_dumps, json_loads from music_assistant.common.helpers.util import empty_queue, select_free_port -from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType -from music_assistant.common.models.enums import EventType, PlayerState, QueueOption, RepeatMode +from music_assistant.common.models.enums import ( + EventType, + PlayerState, + QueueOption, + RepeatMode, +) from music_assistant.common.models.errors import MusicAssistantError -from music_assistant.common.models.event import MassEvent -from music_assistant.common.models.media_items import MediaItemType -from music_assistant.common.models.queue_item import QueueItem from .models import ( PLAYMODE_MAP, @@ -51,12 +51,22 @@ from .models import ( ) if TYPE_CHECKING: + from collections.abc import Callable + + from music_assistant.common.models.config_entries import ( + ConfigEntry, + ConfigValueType, + ) + from music_assistant.common.models.event import MassEvent + from music_assistant.common.models.media_items import MediaItemType + from music_assistant.common.models.queue_item import QueueItem from music_assistant.server import MusicAssistant from . import SlimprotoProvider -# ruff: noqa: ARG002, E501 +# ruff: noqa: ARG002, E501, ERA001 +# pylint: disable=keyword-arg-before-vararg ArgsType = list[int | str] KwargsType = dict[str, Any] @@ -89,7 +99,7 @@ async def get_config_entries( values: the (intermediate) raw values for config entries sent with the action. """ # ruff: noqa: ARG001 - return tuple() # we do not have any config entries (yet) + return () # we do not have any config entries (yet) def parse_value(raw_value: int | str) -> int | str | tuple[str, int | str]: @@ -264,7 +274,7 @@ class LmsCli: # return the response to the client return web.json_response(result, dumps=json_dumps) - async def _handle_cometd(self, request: web.Request) -> web.Response: # noqa: PLR0912 + async def _handle_cometd(self, request: web.Request) -> web.Response: """ Handle CometD request on the json CLI. @@ -410,7 +420,7 @@ class LmsCli: "subscription": cometd_msg["subscription"], } ) - elif channel == "/slim/subscribe": # noqa: SIM114 + elif channel == "/slim/subscribe": # A request to execute & subscribe to some Logitech Media Server event # A valid /slim/subscribe message looks like this: # { @@ -539,7 +549,7 @@ class LmsCli: def _handle_cometd_request(self, client: CometDClient, cometd_request: dict[str, Any]) -> None: """Handle request for CometD client (and put result on client queue).""" - async def _handle(): + async def _handle() -> None: result = await self._handle_request(cometd_request["data"]["request"]) await client.queue.put( { @@ -772,7 +782,10 @@ class LmsCli: **kwargs, ) -> ServerStatusResponse: """Handle firmwareupgrade command.""" - return {"firmwareUpgrade": 0, "relativeFirmwareUrl": "/firmware/baby_7.7.3_r16676.bin"} + return { + "firmwareUpgrade": 0, + "relativeFirmwareUrl": "/firmware/baby_7.7.3_r16676.bin", + } async def _handle_artworkspec( self, @@ -805,27 +818,27 @@ class LmsCli: # self.mass.players.update(player_id) else: self.mass.create_task(self.mass.players.cmd_volume_set, player_id, arg) - return + return None if subcommand == "volume" and arg == "?": return player.volume_level if subcommand == "volume" and "+" in arg: volume_level = min(100, player.volume_level + int(arg.split("+")[1])) self.mass.create_task(self.mass.players.cmd_volume_set, player_id, volume_level) - return + return None if subcommand == "volume" and "-" in arg: volume_level = max(0, player.volume_level - int(arg.split("-")[1])) self.mass.create_task(self.mass.players.cmd_volume_set, player_id, volume_level) - return + return None # mixer muting <0|1|toggle|?|> if subcommand == "muting" and isinstance(arg, int): self.mass.create_task(self.mass.players.cmd_volume_mute, player_id, int(arg)) - return + return None if subcommand == "muting" and arg == "toggle": self.mass.create_task( self.mass.players.cmd_volume_mute, player_id, not player.volume_muted ) - return + return None if subcommand == "muting": return int(player.volume_muted) self.logger.warning( @@ -835,6 +848,7 @@ class LmsCli: str(args), str(kwargs), ) + return None def _handle_time(self, player_id: str, number: str | int) -> int | None: """Handle player `time` command.""" @@ -853,8 +867,10 @@ class LmsCli: if isinstance(number, str) and ("+" in number or "-" in number): jump = int(number.split("+")[1]) self.mass.create_task(self.mass.player_queues.skip, player_queue.queue_id, jump) + return None else: self.mass.create_task(self.mass.player_queues.seek, player_queue.queue_id, int(number)) + return None def _handle_power(self, player_id: str, value: str | int, *args, **kwargs) -> int | None: """Handle player `time` command.""" @@ -872,9 +888,10 @@ class LmsCli: # itself and just reports the new state player.powered = bool(value) # self.mass.players.update(player_id) - return + return None self.mass.create_task(self.mass.players.cmd_power, player_id, bool(value)) + return None def _handle_playlist( self, @@ -891,31 +908,32 @@ class LmsCli: # playlist index if subcommand == "index" and isinstance(arg, int): self.mass.create_task(self.mass.player_queues.play_index, player_id, arg) - return + return None if subcommand == "index" and arg == "?": return queue.current_index if subcommand == "index" and "+" in arg: next_index = (queue.current_index or 0) + int(arg.split("+")[1]) self.mass.create_task(self.mass.player_queues.play_index, player_id, next_index) - return + return None if subcommand == "index" and "-" in arg: next_index = (queue.current_index or 0) - int(arg.split("-")[1]) self.mass.create_task(self.mass.player_queues.play_index, player_id, next_index) - return + return None if subcommand == "shuffle" and arg == "?": return queue.shuffle_enabled if subcommand == "shuffle": self.mass.player_queues.set_shuffle(queue.queue_id, bool(arg)) - return + return None if subcommand == "repeat" and arg == "?": return str(REPEATMODE_MAP[queue.repeat_mode]) if subcommand == "repeat": repeat_map = {val: key for key, val in REPEATMODE_MAP.items()} new_repeat_mode = repeat_map.get(int(arg)) self.mass.player_queues.set_repeat(queue.queue_id, new_repeat_mode) - return + return None self.logger.warning("Unhandled command: playlist/%s", subcommand) + return None def _handle_playlistcontrol( self, @@ -944,7 +962,7 @@ class LmsCli: return if cmd == "insert": self.mass.create_task( - self.mass.player_queues.play_media(queue.queue_id, uri, QueueOption.IN) + self.mass.player_queues.play_media(queue.queue_id, uri, QueueOption.NEXT) ) return self.logger.warning("Unhandled command: playlistcontrol/%s", cmd) @@ -1009,6 +1027,7 @@ class LmsCli: str(args), str(kwargs), ) + return None def _handle_button( self, @@ -1063,7 +1082,10 @@ class LmsCli: ): option = QueueOption.REPLACE if "playlist" in preset_uri else QueueOption.PLAY self.mass.create_task( - self.mass.player_queues.play_media, queue.queue_id, preset_uri, option + self.mass.player_queues.play_media, + queue.queue_id, + preset_uri, + option, ) return @@ -1366,5 +1388,5 @@ def dict_to_strings(source: dict) -> list[str]: elif isinstance(value, dict): result += dict_to_strings(value) else: - result.append(f"{key}:{str(value)}") + result.append(f"{key}:{value!s}") return result diff --git a/music_assistant/server/providers/slimproto/icon.svg b/music_assistant/server/providers/slimproto/icon.svg old mode 100755 new mode 100644 diff --git a/music_assistant/server/providers/slimproto/models.py b/music_assistant/server/providers/slimproto/models.py index 06f4e904..5e5c88a1 100644 --- a/music_assistant/server/providers/slimproto/models.py +++ b/music_assistant/server/providers/slimproto/models.py @@ -5,9 +5,9 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, TypedDict from music_assistant.common.models.enums import MediaType, PlayerState, RepeatMode -from music_assistant.common.models.media_items import MediaItemType if TYPE_CHECKING: + from music_assistant.common.models.media_items import MediaItemType from music_assistant.common.models.player import Player from music_assistant.common.models.queue_item import QueueItem from music_assistant.server import MusicAssistant @@ -217,7 +217,7 @@ class SlimMenuItem(TypedDict): style: str track: str album: str - trackType: str # noqa: N815 + trackType: str icon: str artist: str text: str diff --git a/music_assistant/server/providers/snapcast/__init__.py b/music_assistant/server/providers/snapcast/__init__.py index 489692d5..c7fcccfa 100644 --- a/music_assistant/server/providers/snapcast/__init__.py +++ b/music_assistant/server/providers/snapcast/__init__.py @@ -10,8 +10,6 @@ from typing import TYPE_CHECKING, cast from snapcast.control import create_server from snapcast.control.client import Snapclient -from snapcast.control.group import Snapgroup -from snapcast.control.stream import Snapstream from music_assistant.common.models.config_entries import ( CONF_ENTRY_CROSSFADE, @@ -30,14 +28,16 @@ from music_assistant.common.models.enums import ( from music_assistant.common.models.errors import SetupFailedError from music_assistant.common.models.media_items import AudioFormat from music_assistant.common.models.player import DeviceInfo, Player -from music_assistant.common.models.queue_item import QueueItem from music_assistant.server.models.player_provider import PlayerProvider if TYPE_CHECKING: + from snapcast.control.group import Snapgroup from snapcast.control.server import Snapserver + from snapcast.control.stream import Snapstream from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest + from music_assistant.common.models.queue_item import QueueItem from music_assistant.server import MusicAssistant from music_assistant.server.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType @@ -125,7 +125,8 @@ class SnapCastProvider(PlayerProvider): f"{self.snapcast_server_host}:{self.snapcast_server_control_port}" ) except OSError as err: - raise SetupFailedError("Unable to start the Snapserver connection ?") from err + msg = "Unable to start the Snapserver connection ?" + raise SetupFailedError(msg) from err def _handle_update(self) -> None: """Process Snapcast init Player/Group and set callback .""" @@ -137,7 +138,7 @@ class SnapCastProvider(PlayerProvider): for snap_group in self._snapserver.groups: snap_group.set_callback(self._handle_group_update) - def _handle_group_update(self, snap_group: Snapgroup) -> None: # noqa: ARG002 + def _handle_group_update(self, snap_group: Snapgroup) -> None: """Process Snapcast group callback.""" for snap_client in self._snapserver.clients: self._handle_player_update(snap_client) @@ -198,10 +199,7 @@ class SnapCastProvider(PlayerProvider): async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]: """Return all (provider/player specific) Config Entries for the given player (if any).""" base_entries = await super().get_player_config_entries(player_id) - return base_entries + ( - CONF_ENTRY_CROSSFADE, - CONF_ENTRY_CROSSFADE_DURATION, - ) + return (*base_entries, CONF_ENTRY_CROSSFADE, CONF_ENTRY_CROSSFADE_DURATION) async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: """Send VOLUME_SET command to given player.""" @@ -212,7 +210,7 @@ class SnapCastProvider(PlayerProvider): async def cmd_stop(self, player_id: str) -> None: """Send STOP command to given player.""" player = self.mass.players.get(player_id, raise_unavailable=False) - if stream_task := self._stream_tasks.pop(player_id, None): # noqa: SIM102 + if stream_task := self._stream_tasks.pop(player_id, None): if not stream_task.done(): stream_task.cancel() player.state = PlayerState.IDLE @@ -257,7 +255,8 @@ class SnapCastProvider(PlayerProvider): """ player = self.mass.players.get(player_id) if player.synced_to: - raise RuntimeError("A synced player cannot receive play commands directly") + msg = "A synced player cannot receive play commands directly" + raise RuntimeError(msg) # stop any existing streams first await self.cmd_stop(player_id) queue = self.mass.player_queues.get(queue_item.queue_id) @@ -265,7 +264,7 @@ class SnapCastProvider(PlayerProvider): snap_group = self._get_snapgroup(player_id) await snap_group.set_stream(stream.identifier) - async def _streamer(): + async def _streamer() -> None: host = self.snapcast_server_host _, writer = await asyncio.open_connection(host, port) self.logger.debug("Opened connection to %s:%s", host, port) @@ -311,7 +310,8 @@ class SnapCastProvider(PlayerProvider): """ player = self.mass.players.get(player_id) if player.synced_to: - raise RuntimeError("A synced player cannot receive play commands directly") + msg = "A synced player cannot receive play commands directly" + raise RuntimeError(msg) # stop any existing streams first await self.cmd_stop(player_id) # TEMP - TODO - WARNING - ACHTUNG - HACK @@ -326,7 +326,7 @@ class SnapCastProvider(PlayerProvider): snap_group = self._get_snapgroup(player_id) await snap_group.set_stream(stream.identifier) - async def _streamer(): + async def _streamer() -> None: host = self.snapcast_server_host _, writer = await asyncio.open_connection(host, port) self.logger.debug("Opened connection to %s:%s", host, port) @@ -368,6 +368,7 @@ class SnapCastProvider(PlayerProvider): snap_group = self._get_snapgroup(player_id) if player_id != snap_group.clients[0]: return snap_group.clients[0] + return None def _group_childs(self, player_id: str) -> set[str]: """Return player_ids of the players synced to this player.""" @@ -393,7 +394,8 @@ class SnapCastProvider(PlayerProvider): continue stream = self._snapserver.stream(result["id"]) return (stream, port) - raise RuntimeError("Unable to create stream - No free port found?") + msg = "Unable to create stream - No free port found?" + raise RuntimeError(msg) def _get_player_state(self, player_id: str) -> PlayerState: """Return the state of the player.""" diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 5fa2aa6c..b553b61d 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -17,7 +17,6 @@ from typing import TYPE_CHECKING import soco.config as soco_config from requests.exceptions import RequestException from soco import events_asyncio, zonegroupstate -from soco.core import SoCo from soco.discovery import discover from music_assistant.common.models.config_entries import ( @@ -34,7 +33,6 @@ from music_assistant.common.models.enums import ( ) from music_assistant.common.models.errors import PlayerCommandFailed, PlayerUnavailableError from music_assistant.common.models.player import DeviceInfo, Player -from music_assistant.common.models.queue_item import QueueItem from music_assistant.constants import CONF_CROSSFADE from music_assistant.server.helpers.didl_lite import create_didl_metadata from music_assistant.server.models.player_provider import PlayerProvider @@ -42,8 +40,11 @@ from music_assistant.server.models.player_provider import PlayerProvider from .player import SonosPlayer if TYPE_CHECKING: + from soco.core import SoCo + from music_assistant.common.models.config_entries import PlayerConfig, ProviderConfig from music_assistant.common.models.provider import ProviderManifest + from music_assistant.common.models.queue_item import QueueItem from music_assistant.server import MusicAssistant from music_assistant.server.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType @@ -172,13 +173,15 @@ class SonosPlayerProvider(PlayerProvider): self.sonosplayers = None async def get_player_config_entries( - self, player_id: str # noqa: ARG002 + self, + player_id: str, ) -> tuple[ConfigEntry, ...]: """Return Config Entries for the given player.""" base_entries = await super().get_player_config_entries(player_id) if not (sonos_player := self.sonosplayers.get(player_id)): return base_entries - return base_entries + ( + return ( + *base_entries, CONF_ENTRY_CROSSFADE, ConfigEntry( key="sonos_bass", @@ -212,7 +215,9 @@ class SonosPlayerProvider(PlayerProvider): ) def on_player_config_changed( - self, config: PlayerConfig, changed_keys: set[str] # noqa: ARG002 + self, + config: PlayerConfig, + changed_keys: set[str], ) -> None: """Call (by config manager) when the configuration of a player changes.""" super().on_player_config_changed(config, changed_keys) @@ -350,10 +355,11 @@ class SonosPlayerProvider(PlayerProvider): mass_player = self.mass.players.get(player_id) if sonos_player.sync_coordinator: # this should be already handled by the player manager, but just in case... - raise PlayerCommandFailed( + msg = ( f"Player {mass_player.display_name} can not " "accept play_media command, it is synced to another player." ) + raise PlayerCommandFailed(msg) metadata = create_didl_metadata(self.mass, url, queue_item) await self.mass.create_task(sonos_player.soco.play_uri, url, meta=metadata) @@ -367,10 +373,11 @@ class SonosPlayerProvider(PlayerProvider): mass_player = self.mass.players.get(player_id) if sonos_player.sync_coordinator: # this should be already handled by the player manager, but just in case... - raise PlayerCommandFailed( + msg = ( f"Player {mass_player.display_name} can not " "accept play_stream command, it is synced to another player." ) + raise PlayerCommandFailed(msg) metadata = create_didl_metadata(self.mass, url, None) # sonos players do not like our multi client stream # add to the workaround players list @@ -380,7 +387,7 @@ class SonosPlayerProvider(PlayerProvider): mass_player.elapsed_time = 0 mass_player.elapsed_time_last_updated = time.time() - async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem): + async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem) -> None: """ Handle enqueuing of the next queue item on the player. @@ -404,7 +411,7 @@ class SonosPlayerProvider(PlayerProvider): crossfade = await self.mass.config.get_player_config_value(player_id, CONF_CROSSFADE) if sonos_player.crossfade != crossfade: - def set_crossfade(): + def set_crossfade() -> None: try: sonos_player.soco.cross_fade = crossfade sonos_player.crossfade = crossfade @@ -451,7 +458,7 @@ class SonosPlayerProvider(PlayerProvider): allow_network_scan = self.config.get_value(CONF_NETWORK_SCAN) - def do_discover(): + def do_discover() -> None: """Run discovery and add players in executor thread.""" self._discovery_running = True try: @@ -475,7 +482,7 @@ class SonosPlayerProvider(PlayerProvider): await self.mass.create_task(do_discover) - def reschedule(): + def reschedule() -> None: self._discovery_reschedule_timer = None self.mass.create_task(self._run_discovery()) diff --git a/music_assistant/server/providers/sonos/helpers.py b/music_assistant/server/providers/sonos/helpers.py index 872b7682..f8be90e7 100644 --- a/music_assistant/server/providers/sonos/helpers.py +++ b/music_assistant/server/providers/sonos/helpers.py @@ -35,13 +35,15 @@ class SonosUpdateError(PlayerCommandFailed): @overload def soco_error( errorcodes: None = ..., -) -> Callable[[_FuncType[_T, _P, _R]], _FuncType[_T, _P, _R]]: ... +) -> Callable[[_FuncType[_T, _P, _R]], _FuncType[_T, _P, _R]]: + ... @overload def soco_error( errorcodes: list[str], -) -> Callable[[_FuncType[_T, _P, _R]], _ReturnFuncType[_T, _P, _R]]: ... +) -> Callable[[_FuncType[_T, _P, _R]], _ReturnFuncType[_T, _P, _R]]: + ... def soco_error( @@ -65,7 +67,8 @@ def soco_error( return None if (target := _find_target_identifier(self, args_soco)) is None: - raise RuntimeError("Unexpected use of soco_error") from err + msg = "Unexpected use of soco_error" + raise RuntimeError(msg) from err message = f"Error calling {function} on {target}: {err}" raise SonosUpdateError(message) from err @@ -96,7 +99,8 @@ def hostname_to_uid(hostname: str) -> str: elif hostname.startswith("sonos"): baseuid = hostname.removeprefix("sonos").replace(".local.", "") else: - raise ValueError(f"{hostname} is not a sonos device.") + msg = f"{hostname} is not a sonos device." + raise ValueError(msg) return f"{UID_PREFIX}{baseuid}{UID_POSTFIX}" diff --git a/music_assistant/server/providers/sonos/player.py b/music_assistant/server/providers/sonos/player.py index 2a1b2893..bba358c9 100644 --- a/music_assistant/server/providers/sonos/player.py +++ b/music_assistant/server/providers/sonos/player.py @@ -26,8 +26,6 @@ from soco.core import ( SoCo, ) from soco.data_structures import DidlAudioBroadcast, DidlPlaylistContainer -from soco.events_base import Event as SonosEvent -from soco.events_base import SubscriptionBase from sonos_websocket import SonosWebsocket from music_assistant.common.helpers.datetime import utc @@ -38,6 +36,9 @@ from music_assistant.common.models.player import DeviceInfo, Player from .helpers import SonosUpdateError, soco_error if TYPE_CHECKING: + from soco.events_base import Event as SonosEvent + from soco.events_base import SubscriptionBase + from . import SonosPlayerProvider CALLBACK_TYPE = Callable[[], None] @@ -463,7 +464,7 @@ class SonosPlayer: self._set_basic_track_info(update_position=state_changed) - if (ct_md := evars["current_track_meta_data"]) and not self.image_url: # noqa: SIM102 + if (ct_md := evars["current_track_meta_data"]) and not self.image_url: if album_art_uri := getattr(ct_md, "album_art_uri", None): # TODO: handle library mess here self.image_url = album_art_uri @@ -588,7 +589,7 @@ class SonosPlayer: if p.uid != coordinator_uid and p.is_visible ] - return [coordinator_uid] + joined_uids + return [coordinator_uid, *joined_uids] async def _extract_group(event: SonosEvent | None) -> list[str]: """Extract group layout from a topology event.""" @@ -676,13 +677,13 @@ class SonosPlayer: async with asyncio.timeout(5): while not _test_groups(groups): await self.sonos_prov.topology_condition.wait() - except asyncio.TimeoutError: + except TimeoutError: self.logger.warning("Timeout waiting for target groups %s", groups) any_speaker = next(iter(self.sonos_prov.sonosplayers.values())) any_speaker.soco.zone_group_state.clear_cache() - def _update_attributes(self): + def _update_attributes(self) -> None: """Update attributes of the MA Player from SoCo state.""" # generic attributes (player_info) self.mass_player.available = self.available diff --git a/music_assistant/server/providers/soundcloud/__init__.py b/music_assistant/server/providers/soundcloud/__init__.py index 362e1d4b..a8c4df7d 100644 --- a/music_assistant/server/providers/soundcloud/__init__.py +++ b/music_assistant/server/providers/soundcloud/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import time -from collections.abc import AsyncGenerator, Callable from typing import TYPE_CHECKING from music_assistant.common.helpers.util import parse_title_and_version @@ -44,6 +43,8 @@ SUPPORTED_FEATURES = ( if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Callable + from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant @@ -55,7 +56,8 @@ async def setup( ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" if not config.get_value(CONF_CLIENT_ID) or not config.get_value(CONF_AUTHORIZATION): - raise LoginFailed("Invalid login credentials") + msg = "Invalid login credentials" + raise LoginFailed(msg) prov = SoundcloudMusicProvider(mass, manifest, config) await prov.handle_setup() return prov @@ -77,7 +79,10 @@ async def get_config_entries( # ruff: noqa: ARG001 return ( ConfigEntry( - key=CONF_CLIENT_ID, type=ConfigEntryType.SECURE_STRING, label="Client ID", required=True + key=CONF_CLIENT_ID, + type=ConfigEntryType.SECURE_STRING, + label="Client ID", + required=True, ), ConfigEntry( key=CONF_AUTHORIZATION, @@ -115,7 +120,7 @@ class SoundcloudMusicProvider(MusicProvider): return SUPPORTED_FEATURES @classmethod - async def _run_async(cls, call: Callable, *args, **kwargs): + async def _run_async(cls, call: Callable, *args, **kwargs): # noqa: ANN206 return await asyncio.to_thread(call, *args, **kwargs) async def search( @@ -193,7 +198,9 @@ class SoundcloudMusicProvider(MusicProvider): yield await self._parse_playlist(playlist) except (KeyError, TypeError, InvalidDataError, IndexError) as error: self.logger.debug( - "Failed to obtain Soundcloud playlist details: %s", raw_playlist, exc_info=error + "Failed to obtain Soundcloud playlist details: %s", + raw_playlist, + exc_info=error, ) continue @@ -310,10 +317,11 @@ class SoundcloudMusicProvider(MusicProvider): """Parse a Soundcloud user response to Artist model object.""" artist_id = None permalink = artist_obj["permalink"] - if "id" in artist_obj and artist_obj["id"]: + if artist_obj.get("id"): artist_id = artist_obj["id"] if not artist_id: - raise InvalidDataError("Artist does not have a valid ID") + msg = "Artist does not have a valid ID" + raise InvalidDataError(msg) artist = Artist( item_id=artist_id, name=artist_obj["username"], @@ -367,7 +375,7 @@ class SoundcloudMusicProvider(MusicProvider): """Parse a Soundcloud Track response to a Track model object.""" name, version = parse_title_and_version(track_obj["title"]) track_class = PlaylistTrack if playlist_position is not None else Track - track = track_class( + track = track_class( # pylint: disable=missing-kwoa item_id=track_obj["id"], provider=self.domain, name=name, diff --git a/music_assistant/server/providers/soundcloud/soundcloudpy/asyncsoundcloudpy.py b/music_assistant/server/providers/soundcloud/soundcloudpy/asyncsoundcloudpy.py index c5969287..81709c04 100644 --- a/music_assistant/server/providers/soundcloud/soundcloudpy/asyncsoundcloudpy.py +++ b/music_assistant/server/providers/soundcloud/soundcloudpy/asyncsoundcloudpy.py @@ -3,16 +3,17 @@ Async helpers for connecting to the Soundcloud API. This file is based on soundcloudpy from Naím Rodríguez https://github.com/naim-prog Original package https://github.com/naim-prog/soundcloud-py -""" +""" # noqa: INP001 from __future__ import annotations -from collections.abc import AsyncGenerator from typing import TYPE_CHECKING BASE_URL = "https://api-v2.soundcloud.com" if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from aiohttp.client import ClientSession # TODO: Fix docstring @@ -39,10 +40,11 @@ class SoundcloudAsyncAPI: async with self.http_session.get(url=url, params=params, headers=headers) as response: return await response.json() - async def login(self): + async def login(self) -> None: """Login to soundcloud.""" if len(self.client_id) != 32: - raise ValueError("Not valid client id") + msg = "Not valid client id" + raise ValueError(msg) # To get the last version of Firefox to prevent some type of deprecated version json_versions = await self.get( @@ -235,7 +237,7 @@ class SoundcloudAsyncAPI: # ---------------- MISCELLANEOUS ---------------- - async def get_recommended(self, track_id: str, limit: int = 10): # noqa: ARG002 + async def get_recommended(self, track_id: str, limit: int = 10): """:param track_id: track id to get recommended tracks from this""" return await self.get( f"{BASE_URL}/tracks/{track_id}/related?client_id={self.client_id}", @@ -346,7 +348,8 @@ class SoundcloudAsyncAPI: # Sanity check. if "collection" not in response: - raise RuntimeError("Unexpected Soundcloud API response") + msg = "Unexpected Soundcloud API response" + raise RuntimeError(msg) for item in response["collection"]: yield item diff --git a/music_assistant/server/providers/spotify/__init__.py b/music_assistant/server/providers/spotify/__init__.py index 94f3b434..0f3eb78f 100644 --- a/music_assistant/server/providers/spotify/__init__.py +++ b/music_assistant/server/providers/spotify/__init__.py @@ -8,7 +8,6 @@ import json import os import platform import time -from collections.abc import AsyncGenerator from json.decoder import JSONDecodeError from tempfile import gettempdir from typing import TYPE_CHECKING, Any @@ -18,7 +17,11 @@ from asyncio_throttle import Throttler from music_assistant.common.helpers.util import parse_title_and_version from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType -from music_assistant.common.models.enums import ConfigEntryType, ExternalID, ProviderFeature +from music_assistant.common.models.enums import ( + ConfigEntryType, + ExternalID, + ProviderFeature, +) from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError from music_assistant.common.models.media_items import ( Album, @@ -38,11 +41,17 @@ from music_assistant.common.models.media_items import ( Track, ) from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME + +# pylint: disable=no-name-in-module from music_assistant.server.helpers.app_vars import app_var + +# pylint: enable=no-name-in-module from music_assistant.server.helpers.process import AsyncProcess from music_assistant.server.models.music_provider import MusicProvider if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant @@ -93,10 +102,16 @@ async def get_config_entries( # ruff: noqa: ARG001 return ( ConfigEntry( - key=CONF_USERNAME, type=ConfigEntryType.STRING, label="Username", required=True + key=CONF_USERNAME, + type=ConfigEntryType.STRING, + label="Username", + required=True, ), ConfigEntry( - key=CONF_PASSWORD, type=ConfigEntryType.SECURE_STRING, label="Password", required=True + key=CONF_PASSWORD, + type=ConfigEntryType.SECURE_STRING, + label="Password", + required=True, ), ) @@ -234,19 +249,22 @@ class SpotifyProvider(MusicProvider): """Get full album details by id.""" if album_obj := await self._get_data(f"albums/{prov_album_id}"): return await self._parse_album(album_obj) - raise MediaNotFoundError(f"Item {prov_album_id} not found") + msg = f"Item {prov_album_id} not found" + raise MediaNotFoundError(msg) async def get_track(self, prov_track_id) -> Track: """Get full track details by id.""" if track_obj := await self._get_data(f"tracks/{prov_track_id}"): return await self._parse_track(track_obj) - raise MediaNotFoundError(f"Item {prov_track_id} not found") + msg = f"Item {prov_track_id} not found" + raise MediaNotFoundError(msg) async def get_playlist(self, prov_playlist_id) -> Playlist: """Get full playlist details by id.""" if playlist_obj := await self._get_data(f"playlists/{prov_playlist_id}"): return await self._parse_playlist(playlist_obj) - raise MediaNotFoundError(f"Item {prov_playlist_id} not found") + msg = f"Item {prov_playlist_id} not found" + raise MediaNotFoundError(msg) async def get_album_tracks(self, prov_album_id) -> list[AlbumTrack]: """Get all album tracks for given album id.""" @@ -323,9 +341,7 @@ class SpotifyProvider(MusicProvider): async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]): """Add track(s) to playlist.""" - track_uris = [] - for track_id in prov_track_ids: - track_uris.append(f"spotify:track:{track_id}") + track_uris = [f"spotify:track:{track_id}" for track_id in prov_track_ids] data = {"uris": track_uris} return await self._post_data(f"playlists/{prov_playlist_id}/tracks", data=data) @@ -353,7 +369,8 @@ class SpotifyProvider(MusicProvider): # make sure a valid track is requested. track = await self.get_track(item_id) if not track: - raise MediaNotFoundError(f"track {item_id} not found") + msg = f"track {item_id} not found" + raise MediaNotFoundError(msg) # make sure that the token is still valid by just requesting it await self.login() return StreamDetails( @@ -531,7 +548,8 @@ class SpotifyProvider(MusicProvider): if track_obj["album"].get("images"): track.metadata.images = [ MediaItemImage( - type=ImageType.THUMB, path=track_obj["album"]["images"][0]["url"] + type=ImageType.THUMB, + path=track_obj["album"]["images"][0]["url"], ) ] if track_obj.get("copyright"): @@ -579,7 +597,8 @@ class SpotifyProvider(MusicProvider): return self._auth_token tokeninfo, userinfo = None, self._sp_user if not self.config.get_value(CONF_USERNAME) or not self.config.get_value(CONF_PASSWORD): - raise LoginFailed("Invalid login credentials") + msg = "Invalid login credentials" + raise LoginFailed(msg) # retrieve token with librespot retries = 0 while retries < 20: @@ -607,15 +626,19 @@ class SpotifyProvider(MusicProvider): self._auth_token = tokeninfo return tokeninfo if tokeninfo and not userinfo: - raise LoginFailed( - "Unable to retrieve userdetails from Spotify API - probably just a temporary error" + msg = ( + "Unable to retrieve userdetails from Spotify API - " + "probably just a temporary error" ) + raise LoginFailed(msg) if self.config.get_value(CONF_USERNAME).isnumeric(): # a spotify free/basic account can be recognized when # the username consists of numbers only - check that here # an integer can be parsed of the username, this is a free account - raise LoginFailed("Only Spotify Premium accounts are supported") - raise LoginFailed(f"Login failed for user {self.config.get_value(CONF_USERNAME)}") + msg = "Only Spotify Premium accounts are supported" + raise LoginFailed(msg) + msg = f"Login failed for user {self.config.get_value(CONF_USERNAME)}" + raise LoginFailed(msg) async def _get_token(self): """Get spotify auth token with librespot bin.""" @@ -729,8 +752,8 @@ class SpotifyProvider(MusicProvider): except ( aiohttp.ContentTypeError, JSONDecodeError, - ) as err: - self.logger.error("%s - %s", endpoint, str(err)) + ): + self.logger.exception("%s", endpoint) return None finally: self.logger.debug( @@ -807,4 +830,5 @@ class SpotifyProvider(MusicProvider): ): return bridge_binary - raise RuntimeError(f"Unable to locate Librespot for {system}/{architecture}") + msg = f"Unable to locate Librespot for {system}/{architecture}" + raise RuntimeError(msg) diff --git a/music_assistant/server/providers/theaudiodb/__init__.py b/music_assistant/server/providers/theaudiodb/__init__.py index fe6bcb60..b7dc8001 100644 --- a/music_assistant/server/providers/theaudiodb/__init__.py +++ b/music_assistant/server/providers/theaudiodb/__init__.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Any import aiohttp.client_exceptions from asyncio_throttle import Throttler -from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType from music_assistant.common.models.enums import ProviderFeature from music_assistant.common.models.media_items import ( Album, @@ -27,7 +26,11 @@ from music_assistant.server.helpers.compare import compare_strings from music_assistant.server.models.metadata_provider import MetadataProvider if TYPE_CHECKING: - from music_assistant.common.models.config_entries import ProviderConfig + from music_assistant.common.models.config_entries import ( + ConfigEntry, + ConfigValueType, + ProviderConfig, + ) from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant from music_assistant.server.models import ProviderInstanceType @@ -95,7 +98,7 @@ async def get_config_entries( values: the (intermediate) raw values for config entries sent with the action. """ # ruff: noqa: ARG001 - return tuple() # we do not have any config entries (yet) + return () # we do not have any config entries (yet) class AudioDbMetadataProvider(MetadataProvider): @@ -118,7 +121,7 @@ class AudioDbMetadataProvider(MetadataProvider): if not artist.mbid: # for 100% accuracy we require the musicbrainz id for all lookups return None - if data := await self._get_data("artist-mb.php", i=artist.mbid): # noqa: SIM102 + if data := await self._get_data("artist-mb.php", i=artist.mbid): if data.get("artists"): return self.__parse_artist(data["artists"][0]) return None @@ -276,7 +279,7 @@ class AudioDbMetadataProvider(MetadataProvider): aiohttp.client_exceptions.ContentTypeError, JSONDecodeError, ): - self.logger.error("Failed to retrieve %s", endpoint) + self.logger.exception("Failed to retrieve %s", endpoint) text_result = await response.text() self.logger.debug(text_result) return None diff --git a/music_assistant/server/providers/tidal/__init__.py b/music_assistant/server/providers/tidal/__init__.py index f470f377..470ec88e 100644 --- a/music_assistant/server/providers/tidal/__init__.py +++ b/music_assistant/server/providers/tidal/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Awaitable, Callable from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any @@ -15,7 +14,6 @@ from tidalapi import Playlist as TidalPlaylist from tidalapi import Quality as TidalQuality from tidalapi import Session as TidalSession from tidalapi import Track as TidalTrack -from tidalapi.media import Lyrics as TidalLyrics from music_assistant.common.models.config_entries import ( ConfigEntry, @@ -73,6 +71,10 @@ from .helpers import ( ) if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Awaitable, Callable + + from tidalapi.media import Lyrics as TidalLyrics + from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant @@ -128,7 +130,8 @@ async def get_config_entries( async with AuthenticationHelper(mass, values["session_id"]) as auth_helper: tidal_session = await tidal_code_login(auth_helper, values.get(CONF_QUALITY)) if not tidal_session.check_login(): - raise LoginFailed("Authentication to Tidal failed") + msg = "Authentication to Tidal failed" + raise LoginFailed(msg) # set the retrieved token on the values object to pass along values[CONF_AUTH_TOKEN] = tidal_session.access_token values[CONF_REFRESH_TOKEN] = tidal_session.refresh_token @@ -155,7 +158,8 @@ async def get_config_entries( title=TidalQuality.low_320k.value, value=TidalQuality.low_320k.name ), ConfigValueOption( - title=TidalQuality.high_lossless.value, value=TidalQuality.high_lossless.name + title=TidalQuality.high_lossless.value, + value=TidalQuality.high_lossless.name, ), ConfigValueOption(title=TidalQuality.hi_res.value, value=TidalQuality.hi_res.name), ], @@ -339,7 +343,8 @@ class TidalProvider(MusicProvider): ): total_playlist_tracks += 1 track = await self._parse_track( - track_obj=track_obj, extra_init_kwargs={"position": total_playlist_tracks} + track_obj=track_obj, + extra_init_kwargs={"position": total_playlist_tracks}, ) yield track @@ -410,7 +415,8 @@ class TidalProvider(MusicProvider): url = await get_track_url(tidal_session, item_id) media_info = await self._get_media_info(item_id=item_id, url=url) if not track: - raise MediaNotFoundError(f"track {item_id} not found") + msg = f"track {item_id} not found" + raise MediaNotFoundError(msg) return StreamDetails( item_id=track.id, provider=self.instance_id, @@ -438,7 +444,8 @@ class TidalProvider(MusicProvider): tidal_session = await self._get_tidal_session() async with self._throttler: return await self._parse_album( - album_obj=await get_album(tidal_session, prov_album_id), full_details=True + album_obj=await get_album(tidal_session, prov_album_id), + full_details=True, ) async def get_track(self, prov_track_id: str) -> Track: @@ -446,7 +453,8 @@ class TidalProvider(MusicProvider): tidal_session = await self._get_tidal_session() async with self._throttler: return await self._parse_track( - track_obj=await get_track(tidal_session, prov_track_id), full_details=True + track_obj=await get_track(tidal_session, prov_track_id), + full_details=True, ) async def get_playlist(self, prov_playlist_id: str) -> Playlist: @@ -454,7 +462,8 @@ class TidalProvider(MusicProvider): tidal_session = await self._get_tidal_session() async with self._throttler: return await self._parse_playlist( - playlist_obj=await get_playlist(tidal_session, prov_playlist_id), full_details=True + playlist_obj=await get_playlist(tidal_session, prov_playlist_id), + full_details=True, ) def get_item_mapping(self, media_type: MediaType, key: str, name: str) -> ItemMapping: @@ -500,7 +509,12 @@ class TidalProvider(MusicProvider): return self._tidal_session async def _load_tidal_session( - self, token_type, quality: TidalQuality, access_token, refresh_token=None, expiry_time=None + self, + token_type, + quality: TidalQuality, + access_token, + refresh_token=None, + expiry_time=None, ) -> TidalSession: """Load the tidalapi Session.""" diff --git a/music_assistant/server/providers/tidal/helpers.py b/music_assistant/server/providers/tidal/helpers.py index cfe3f862..9c4c5215 100644 --- a/music_assistant/server/providers/tidal/helpers.py +++ b/music_assistant/server/providers/tidal/helpers.py @@ -42,7 +42,11 @@ async def get_library_artists( async def library_items_add_remove( - session: TidalSession, user_id: str, item_id: str, media_type: MediaType, add: bool = True + session: TidalSession, + user_id: str, + item_id: str, + media_type: MediaType, + add: bool = True, ) -> None: """Async wrapper around the tidalapi Favorites.items add/remove function.""" @@ -86,8 +90,9 @@ async def get_artist(session: TidalSession, prov_artist_id: str) -> TidalArtist: return TidalArtist(session, prov_artist_id) except HTTPError as err: if err.response.status_code == 404: - raise MediaNotFoundError(f"Artist {prov_artist_id} not found") from err - raise err + msg = f"Artist {prov_artist_id} not found" + raise MediaNotFoundError(msg) from err + raise return await asyncio.to_thread(inner) @@ -105,11 +110,13 @@ async def get_artist_albums(session: TidalSession, prov_artist_id: str) -> list[ all_albums.extend(albums) all_albums.extend(eps_singles) all_albums.extend(compilations) - return all_albums except HTTPError as err: if err.response.status_code == 404: - raise MediaNotFoundError(f"Artist {prov_artist_id} not found") from err - raise err + msg = f"Artist {prov_artist_id} not found" + raise MediaNotFoundError(msg) from err + raise + else: + return all_albums return await asyncio.to_thread(inner) @@ -144,8 +151,9 @@ async def get_album(session: TidalSession, prov_album_id: str) -> TidalAlbum: return TidalAlbum(session, prov_album_id) except HTTPError as err: if err.response.status_code == 404: - raise MediaNotFoundError(f"Album {prov_album_id} not found") from err - raise err + msg = f"Album {prov_album_id} not found" + raise MediaNotFoundError(msg) from err + raise return await asyncio.to_thread(inner) @@ -158,8 +166,9 @@ async def get_track(session: TidalSession, prov_track_id: str) -> TidalTrack: return TidalTrack(session, prov_track_id) except HTTPError as err: if err.response.status_code == 404: - raise MediaNotFoundError(f"Track {prov_track_id} not found") from err - raise err + msg = f"Track {prov_track_id} not found" + raise MediaNotFoundError(msg) from err + raise return await asyncio.to_thread(inner) @@ -172,8 +181,9 @@ async def get_track_url(session: TidalSession, prov_track_id: str) -> dict[str, return TidalTrack(session, prov_track_id).get_url() except HTTPError as err: if err.response.status_code == 404: - raise MediaNotFoundError(f"Track {prov_track_id} not found") from err - raise err + msg = f"Track {prov_track_id} not found" + raise MediaNotFoundError(msg) from err + raise return await asyncio.to_thread(inner) @@ -186,8 +196,9 @@ async def get_album_tracks(session: TidalSession, prov_album_id: str) -> list[Ti return TidalAlbum(session, prov_album_id).tracks(limit=DEFAULT_LIMIT) except HTTPError as err: if err.response.status_code == 404: - raise MediaNotFoundError(f"Album {prov_album_id} not found") from err - raise err + msg = f"Album {prov_album_id} not found" + raise MediaNotFoundError(msg) from err + raise return await asyncio.to_thread(inner) @@ -222,14 +233,18 @@ async def get_playlist(session: TidalSession, prov_playlist_id: str) -> TidalPla return TidalPlaylist(session, prov_playlist_id) except HTTPError as err: if err.response.status_code == 404: - raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") from err - raise err + msg = f"Playlist {prov_playlist_id} not found" + raise MediaNotFoundError(msg) from err + raise return await asyncio.to_thread(inner) async def get_playlist_tracks( - session: TidalSession, prov_playlist_id: str, limit: int = DEFAULT_LIMIT, offset: int = 0 + session: TidalSession, + prov_playlist_id: str, + limit: int = DEFAULT_LIMIT, + offset: int = 0, ) -> list[TidalTrack]: """Async wrapper around the tidal Playlist.tracks function.""" @@ -238,8 +253,9 @@ async def get_playlist_tracks( return TidalPlaylist(session, prov_playlist_id).tracks(limit=limit, offset=offset) except HTTPError as err: if err.response.status_code == 404: - raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") from err - raise err + msg = f"Playlist {prov_playlist_id} not found" + raise MediaNotFoundError(msg) from err + raise return await asyncio.to_thread(inner) @@ -254,12 +270,13 @@ async def add_remove_playlist_tracks( return TidalUserPlaylist(session, prov_playlist_id).add(track_ids) for item in track_ids: TidalUserPlaylist(session, prov_playlist_id).remove_by_id(int(item)) + return None return await asyncio.to_thread(inner) async def create_playlist( - session: TidalSession, user_id: str, title: str, description: str = None + session: TidalSession, user_id: str, title: str, description: str | None = None ) -> TidalPlaylist: """Async wrapper around the tidal LoggedInUser.create_playlist function.""" @@ -280,8 +297,9 @@ async def get_similar_tracks( return TidalTrack(session, prov_track_id).get_track_radio(limit=limit) except HTTPError as err: if err.response.status_code == 404: - raise MediaNotFoundError(f"Track {prov_track_id} not found") from err - raise err + msg = f"Track {prov_track_id} not found" + raise MediaNotFoundError(msg) from err + raise return await asyncio.to_thread(inner) diff --git a/music_assistant/server/providers/tidal/icon.svg b/music_assistant/server/providers/tidal/icon.svg old mode 100755 new mode 100644 diff --git a/music_assistant/server/providers/tidal/icon_dark.svg b/music_assistant/server/providers/tidal/icon_dark.svg old mode 100755 new mode 100644 diff --git a/music_assistant/server/providers/tunein/__init__.py b/music_assistant/server/providers/tunein/__init__.py index eebae417..05a414d3 100644 --- a/music_assistant/server/providers/tunein/__init__.py +++ b/music_assistant/server/providers/tunein/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import AsyncGenerator from time import time from typing import TYPE_CHECKING @@ -11,7 +10,11 @@ from asyncio_throttle import Throttler from music_assistant.common.helpers.util import create_sort_name from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType from music_assistant.common.models.enums import ConfigEntryType, ProviderFeature -from music_assistant.common.models.errors import InvalidDataError, LoginFailed, MediaNotFoundError +from music_assistant.common.models.errors import ( + InvalidDataError, + LoginFailed, + MediaNotFoundError, +) from music_assistant.common.models.media_items import ( AudioFormat, ContentType, @@ -33,6 +36,8 @@ SUPPORTED_FEATURES = ( ) if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant @@ -44,7 +49,8 @@ async def setup( ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" if not config.get_value(CONF_USERNAME): - raise LoginFailed("Username is invalid") + msg = "Username is invalid" + raise LoginFailed(msg) prov = TuneInProvider(mass, manifest, config) if "@" in config.get_value(CONF_USERNAME): @@ -72,7 +78,10 @@ async def get_config_entries( # ruff: noqa: ARG001 return ( ConfigEntry( - key=CONF_USERNAME, type=ConfigEntryType.STRING, label="Username", required=True + key=CONF_USERNAME, + type=ConfigEntryType.STRING, + label="Username", + required=True, ), ) @@ -94,7 +103,9 @@ class TuneInProvider(MusicProvider): async def get_library_radios(self) -> AsyncGenerator[Radio, None]: """Retrieve library/subscribed radio stations from the provider.""" - async def parse_items(items: list[dict], folder: str = None) -> AsyncGenerator[Radio, None]: + async def parse_items( + items: list[dict], folder: str | None = None + ) -> AsyncGenerator[Radio, None]: for item in items: item_type = item.get("type", "") if item_type == "audio": @@ -147,7 +158,8 @@ class TuneInProvider(MusicProvider): async for radio in self.get_library_radios(): if radio.item_id == prov_radio_id: return radio - raise MediaNotFoundError(f"Item {prov_radio_id} not found") + msg = f"Item {prov_radio_id} not found" + raise MediaNotFoundError(msg) async def _parse_radio( self, details: dict, stream: dict | None = None, folder: str | None = None @@ -215,7 +227,6 @@ class TuneInProvider(MusicProvider): return StreamDetails( provider=self.instance_id, item_id=item_id, - content_type=ContentType.UNKNOWN, audio_format=AudioFormat( content_type=ContentType.UNKNOWN, ), @@ -240,10 +251,13 @@ class TuneInProvider(MusicProvider): expires=time() + 24 * 3600, direct=url_resolved if not supports_icy else None, ) - raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}") + msg = f"Unable to retrieve stream details for {item_id}" + raise MediaNotFoundError(msg) async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 # noqa: ARG002 + self, + streamdetails: StreamDetails, + seek_position: int = 0, ) -> AsyncGenerator[bytes, None]: """Return the audio stream for the provider item.""" async for chunk in get_radio_stream(self.mass, streamdetails.data, streamdetails): diff --git a/music_assistant/server/providers/ugp/__init__.py b/music_assistant/server/providers/ugp/__init__.py index 0e1efdf0..7e621f3a 100644 --- a/music_assistant/server/providers/ugp/__init__.py +++ b/music_assistant/server/providers/ugp/__init__.py @@ -8,7 +8,6 @@ allowing the user to create player groups from all players known in the system. from __future__ import annotations import asyncio -from collections.abc import Iterable from typing import TYPE_CHECKING import shortuuid @@ -27,13 +26,19 @@ from music_assistant.common.models.enums import ( ProviderFeature, ) from music_assistant.common.models.player import DeviceInfo, Player -from music_assistant.common.models.queue_item import QueueItem -from music_assistant.constants import CONF_CROSSFADE, CONF_GROUP_MEMBERS, SYNCGROUP_PREFIX +from music_assistant.constants import ( + CONF_CROSSFADE, + CONF_GROUP_MEMBERS, + SYNCGROUP_PREFIX, +) from music_assistant.server.models.player_provider import PlayerProvider if TYPE_CHECKING: + from collections.abc import Iterable + from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest + from music_assistant.common.models.queue_item import QueueItem from music_assistant.server import MusicAssistant from music_assistant.server.models import ProviderInstanceType @@ -65,7 +70,7 @@ async def get_config_entries( action: [optional] action key called from config entries UI. values: the (intermediate) raw values for config entries sent with the action. """ - return tuple() + return () class UniversalGroupProvider(PlayerProvider): @@ -84,10 +89,11 @@ class UniversalGroupProvider(PlayerProvider): self.prev_sync_leaders = {} self.mass.loop.create_task(self._register_all_players()) - async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]: # noqa: ARG002 + async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]: """Return all (provider/player specific) Config Entries for the given player (if any).""" base_entries = await super().get_player_config_entries(player_id) - return base_entries + ( + return ( + *base_entries, ConfigEntry( key=CONF_GROUP_MEMBERS, type=ConfigEntryType.STRING, @@ -173,7 +179,10 @@ class UniversalGroupProvider(PlayerProvider): # create multi-client stream job stream_job = await self.mass.streams.create_multi_client_stream_job( - player_id, start_queue_item=queue_item, seek_position=seek_position, fade_in=fade_in + player_id, + start_queue_item=queue_item, + seek_position=seek_position, + fade_in=fade_in, ) # forward the stream job to all group members @@ -215,8 +224,7 @@ class UniversalGroupProvider(PlayerProvider): enabled=True, values={CONF_GROUP_MEMBERS: members}, ) - player = self._register_group_player(new_group_id, name=name, members=members) - return player + return self._register_group_player(new_group_id, name=name, members=members) async def _register_all_players(self) -> None: """Register all (virtual/fake) group players in the Player controller.""" @@ -224,7 +232,9 @@ class UniversalGroupProvider(PlayerProvider): for player_config in player_configs: members = player_config.get_value(CONF_GROUP_MEMBERS) self._register_group_player( - player_config.player_id, player_config.name or player_config.default_name, members + player_config.player_id, + player_config.name or player_config.default_name, + members, ) def _register_group_player( @@ -271,7 +281,7 @@ class UniversalGroupProvider(PlayerProvider): if not group_player.powered: # guard, this should be caught in the player controller but just in case... - return + return None powered_childs = [ x @@ -300,5 +310,9 @@ class UniversalGroupProvider(PlayerProvider): group_player.display_name, ) self.mass.loop.call_later( - 1, self.mass.create_task, self.mass.player_queues.resume(group_player.player_id) + 1, + self.mass.create_task, + self.mass.player_queues.resume(group_player.player_id), ) + return None + return None diff --git a/music_assistant/server/providers/url/__init__.py b/music_assistant/server/providers/url/__init__.py index f1e5575b..38422d77 100644 --- a/music_assistant/server/providers/url/__init__.py +++ b/music_assistant/server/providers/url/__init__.py @@ -3,10 +3,8 @@ from __future__ import annotations import os -from collections.abc import AsyncGenerator from typing import TYPE_CHECKING -from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType from music_assistant.common.models.enums import ContentType, ImageType, MediaType from music_assistant.common.models.media_items import ( Artist, @@ -29,7 +27,13 @@ from music_assistant.server.helpers.tags import AudioTags, parse_tags from music_assistant.server.models.music_provider import MusicProvider if TYPE_CHECKING: - from music_assistant.common.models.config_entries import ProviderConfig + from collections.abc import AsyncGenerator + + from music_assistant.common.models.config_entries import ( + ConfigEntry, + ConfigValueType, + ProviderConfig, + ) from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant from music_assistant.server.models import ProviderInstanceType @@ -58,7 +62,7 @@ async def get_config_entries( values: the (intermediate) raw values for config entries sent with the action. """ # ruff: noqa: ARG001 - return tuple() # we do not have any config entries (yet) + return () # we do not have any config entries (yet) class URLProvider(MusicProvider): @@ -70,7 +74,6 @@ class URLProvider(MusicProvider): Called when provider is registered. """ self._full_url = {} - # self.mass.register_api_command("music/tracks", self.library_items) async def get_track(self, prov_track_id: str) -> Track: """Get full track details by id.""" @@ -111,7 +114,10 @@ class URLProvider(MusicProvider): raise NotImplementedError async def parse_item( - self, item_id_or_url: str, force_refresh: bool = False, force_radio: bool = False + self, + item_id_or_url: str, + force_refresh: bool = False, + force_radio: bool = False, ) -> Track | Radio: """Parse plain URL to MediaItem of type Radio or Track.""" item_id, url, media_info = await self._get_media_info(item_id_or_url, force_refresh) @@ -158,11 +164,7 @@ class URLProvider(MusicProvider): ) -> tuple[str, str, AudioTags]: """Retrieve (cached) mediainfo for url.""" # check if the radio stream is not a playlist - if ( - item_id_or_url.endswith("m3u8") - or item_id_or_url.endswith("m3u") - or item_id_or_url.endswith("pls") - ): + if item_id_or_url.endswith(("m3u8", "m3u", "pls")): playlist = await fetch_playlist(self.mass, item_id_or_url) url = playlist[0] item_id = item_id_or_url diff --git a/music_assistant/server/providers/ytmusic/__init__.py b/music_assistant/server/providers/ytmusic/__init__.py index ecfc97cc..9701d639 100644 --- a/music_assistant/server/providers/ytmusic/__init__.py +++ b/music_assistant/server/providers/ytmusic/__init__.py @@ -189,7 +189,8 @@ class YoutubeMusicProvider(MusicProvider): async def handle_setup(self) -> None: """Set up the YTMusic provider.""" if not self.config.get_value(CONF_AUTH_TOKEN): - raise LoginFailed("Invalid login credentials") + msg = "Invalid login credentials" + raise LoginFailed(msg) await self._initialize_headers() await self._initialize_context() self._cookies = {"CONSENT": "YES+1"} @@ -283,7 +284,8 @@ class YoutubeMusicProvider(MusicProvider): await self._check_oauth_token() if album_obj := await get_album(prov_album_id=prov_album_id): return await self._parse_album(album_obj=album_obj, album_id=prov_album_id) - raise MediaNotFoundError(f"Item {prov_album_id} not found") + msg = f"Item {prov_album_id} not found" + raise MediaNotFoundError(msg) async def get_album_tracks(self, prov_album_id: str) -> list[AlbumTrack]: """Get album tracks for given album id.""" @@ -307,7 +309,8 @@ class YoutubeMusicProvider(MusicProvider): await self._check_oauth_token() if artist_obj := await get_artist(prov_artist_id=prov_artist_id, headers=self._headers): return await self._parse_artist(artist_obj=artist_obj) - raise MediaNotFoundError(f"Item {prov_artist_id} not found") + msg = f"Item {prov_artist_id} not found" + raise MediaNotFoundError(msg) async def get_track(self, prov_track_id) -> Track: """Get full track details by id.""" @@ -318,7 +321,8 @@ class YoutubeMusicProvider(MusicProvider): signature_timestamp=self._signature_timestamp, ): return await self._parse_track(track_obj) - raise MediaNotFoundError(f"Item {prov_track_id} not found") + msg = f"Item {prov_track_id} not found" + raise MediaNotFoundError(msg) async def get_playlist(self, prov_playlist_id) -> Playlist: """Get full playlist details by id.""" @@ -330,7 +334,8 @@ class YoutubeMusicProvider(MusicProvider): prov_playlist_id=prov_playlist_id, headers=self._headers ): return await self._parse_playlist(playlist_obj) - raise MediaNotFoundError(f"Item {prov_playlist_id} not found") + msg = f"Item {prov_playlist_id} not found" + raise MediaNotFoundError(msg) async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[PlaylistTrack, None]: """Get all playlist tracks for given playlist id.""" @@ -494,16 +499,18 @@ class YoutubeMusicProvider(MusicProvider): signature_timestamp=self._signature_timestamp, ) if not track_obj: - raise MediaNotFoundError(f"Item {item_id} not found") + msg = f"Item {item_id} not found" + raise MediaNotFoundError(msg) stream_format = await self._parse_stream_format(track_obj) url = await self._parse_stream_url(stream_format=stream_format, item_id=item_id) if not await self._is_valid_deciphered_url(url=url): if retry > 4: - self.logger.warn( + self.logger.warning( f"Could not resolve a valid URL for item '{item_id}'. " "Are you playing music on another device using the same account?" ) - raise UnplayableMediaError(f"Could not resolve a valid URL for item '{item_id}'.") + msg = f"Could not resolve a valid URL for item '{item_id}'." + raise UnplayableMediaError(msg) self.logger.debug( "Invalid playback URL encountered. Retrying with new signature timestamp." ) @@ -531,7 +538,7 @@ class YoutubeMusicProvider(MusicProvider): stream_details.audio_format.sample_rate = int(stream_format.get("audioSampleRate")) return stream_details - async def _post_data(self, endpoint: str, data: dict[str, str], **kwargs): # noqa: ARG002 + async def _post_data(self, endpoint: str, data: dict[str, str], **kwargs): """Post data to the given endpoint.""" await self._check_oauth_token() url = f"{YTM_BASE_URL}{endpoint}" @@ -545,7 +552,7 @@ class YoutubeMusicProvider(MusicProvider): ) as response: return await response.json() - async def _get_data(self, url: str, params: dict = None): + async def _get_data(self, url: str, params: dict | None = None): """Get data from the given URL.""" await self._check_oauth_token() async with self.mass.http_session.get( @@ -588,7 +595,7 @@ class YoutubeMusicProvider(MusicProvider): } } - async def _parse_album(self, album_obj: dict, album_id: str = None) -> Album: + async def _parse_album(self, album_obj: dict, album_id: str | None = None) -> Album: """Parse a YT Album response to an Album model object.""" album_id = album_id or album_obj.get("id") or album_obj.get("browseId") if "title" in album_obj: @@ -640,12 +647,13 @@ class YoutubeMusicProvider(MusicProvider): artist_id = None if "channelId" in artist_obj: artist_id = artist_obj["channelId"] - elif "id" in artist_obj and artist_obj["id"]: + elif artist_obj.get("id"): artist_id = artist_obj["id"] elif artist_obj["name"] == "Various Artists": artist_id = VARIOUS_ARTISTS_YTM_ID if not artist_id: - raise InvalidDataError("Artist does not have a valid ID") + msg = "Artist does not have a valid ID" + raise InvalidDataError(msg) artist = Artist( item_id=artist_id, name=artist_obj["name"], @@ -661,7 +669,7 @@ class YoutubeMusicProvider(MusicProvider): ) if "description" in artist_obj: artist.metadata.description = artist_obj["description"] - if "thumbnails" in artist_obj and artist_obj["thumbnails"]: + if artist_obj.get("thumbnails"): artist.metadata.images = await self._parse_thumbnails(artist_obj["thumbnails"]) return artist @@ -688,7 +696,7 @@ class YoutubeMusicProvider(MusicProvider): ) if "description" in playlist_obj: playlist.metadata.description = playlist_obj["description"] - if "thumbnails" in playlist_obj and playlist_obj["thumbnails"]: + if playlist_obj.get("thumbnails"): playlist.metadata.images = await self._parse_thumbnails(playlist_obj["thumbnails"]) is_editable = False if playlist_obj.get("privacy") and playlist_obj.get("privacy") == "PRIVATE": @@ -709,7 +717,8 @@ class YoutubeMusicProvider(MusicProvider): async def _parse_track(self, track_obj: dict) -> Track | AlbumTrack | PlaylistTrack: """Parse a YT Track response to a Track model object.""" if not track_obj.get("videoId"): - raise InvalidDataError("Track is missing videoId") + msg = "Track is missing videoId" + raise InvalidDataError(msg) if "position" in track_obj: track_class = PlaylistTrack @@ -741,7 +750,7 @@ class YoutubeMusicProvider(MusicProvider): **extra_init_kwargs, ) - if "artists" in track_obj and track_obj["artists"]: + if track_obj.get("artists"): track.artists = [ self._get_artist_item_mapping(artist) for artist in track_obj["artists"] @@ -751,8 +760,9 @@ class YoutubeMusicProvider(MusicProvider): ] # guard that track has valid artists if not track.artists: - raise InvalidDataError("Track is missing artists") - if "thumbnails" in track_obj and track_obj["thumbnails"]: + msg = "Track is missing artists" + raise InvalidDataError(msg) + if track_obj.get("thumbnails"): track.metadata.images = await self._parse_thumbnails(track_obj["thumbnails"]) if ( track_obj.get("album") @@ -778,12 +788,14 @@ class YoutubeMusicProvider(MusicProvider): response = await self._get_data(url=YT_DOMAIN) match = re.search(r'jsUrl"\s*:\s*"([^"]+)"', response) if match is None: - raise Exception("Could not identify the URL for base.js player.") + msg = "Could not identify the URL for base.js player." + raise Exception(msg) # pylint: disable=broad-exception-raised url = YTM_DOMAIN + match.group(1) response = await self._get_data(url=url) match = re.search(r"signatureTimestamp[:=](\d+)", response) if match is None: - raise Exception("Unable to identify the signatureTimestamp.") + msg = "Unable to identify the signatureTimestamp." + raise Exception(msg) # pylint: disable=broad-exception-raised return int(match.group(1)) async def _parse_stream_url(self, stream_format: dict, item_id: str) -> str: @@ -869,5 +881,6 @@ class YoutubeMusicProvider(MusicProvider): ): stream_format = adaptive_format if stream_format is None: - raise MediaNotFoundError("No stream found for this track") + msg = "No stream found for this track" + raise MediaNotFoundError(msg) return stream_format diff --git a/music_assistant/server/providers/ytmusic/helpers.py b/music_assistant/server/providers/ytmusic/helpers.py index 8a0d7318..c73bde9b 100644 --- a/music_assistant/server/providers/ytmusic/helpers.py +++ b/music_assistant/server/providers/ytmusic/helpers.py @@ -141,8 +141,7 @@ async def get_library_tracks(headers: dict[str, str]) -> dict[str, str]: def _get_library_tracks(): ytm = ytmusicapi.YTMusic(auth=headers) - tracks = ytm.get_library_songs(limit=9999) - return tracks + return ytm.get_library_songs(limit=9999) return await asyncio.to_thread(_get_library_tracks) @@ -236,7 +235,7 @@ async def get_song_radio_tracks( return await asyncio.to_thread(_get_song_radio_tracks) -async def search(query: str, ytm_filter: str = None, limit: int = 20) -> list[dict]: +async def search(query: str, ytm_filter: str | None = None, limit: int = 20) -> list[dict]: """Async wrapper around the ytmusicapi search function.""" def _search(): @@ -293,8 +292,7 @@ async def login_oauth(auth_helper: AuthenticationHelper): """Use device login to get a token.""" http_session = auth_helper.mass.http_session code = await get_oauth_code(http_session) - token = await visit_oauth_auth_url(auth_helper, code) - return token + return await visit_oauth_auth_url(auth_helper, code) def _get_data_and_headers(data: dict): @@ -324,7 +322,8 @@ async def visit_oauth_auth_url(auth_helper: AuthenticationHelper, code: dict[str return token await asyncio.sleep(interval) expiry -= interval - raise TimeoutError("You took too long to log in") + msg = "You took too long to log in" + raise TimeoutError(msg) async def get_oauth_token_from_code(session: ClientSession, device_code: str): diff --git a/music_assistant/server/providers/ytmusic/icon.svg b/music_assistant/server/providers/ytmusic/icon.svg old mode 100755 new mode 100644 diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index 25479632..32d21515 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -7,7 +7,7 @@ import logging import os import sys from collections.abc import Awaitable, Callable, Coroutine -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self from uuid import uuid4 import aiofiles @@ -16,7 +16,6 @@ from zeroconf import InterfaceChoice, NonUniqueNameException, ServiceInfo, Zeroc from music_assistant.common.helpers.util import get_ip_pton from music_assistant.common.models.api import ServerInfoMessage -from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.enums import EventType, ProviderType from music_assistant.common.models.errors import SetupFailedError from music_assistant.common.models.event import MassEvent @@ -45,13 +44,14 @@ from music_assistant.server.helpers.util import ( is_hass_supervisor, ) -from .models import ProviderInstanceType - if TYPE_CHECKING: from types import TracebackType + from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.server.models.core_controller import CoreController + from .models import ProviderInstanceType + EventCallBackType = Callable[[MassEvent], None] EventSubscriptionType = tuple[ EventCallBackType, tuple[EventType, ...] | None, tuple[str, ...] | None @@ -291,7 +291,7 @@ class MusicAssistant: listener = (cb_func, event_filter, id_filter) self._subscribers.add(listener) - def remove_listener(): + def remove_listener() -> None: self._subscribers.remove(listener) return remove_listener @@ -308,7 +308,8 @@ class MusicAssistant: Tasks created by this helper will be properly cancelled on stop. """ if target is None: - raise RuntimeError("Target is missing") + msg = "Target is missing" + raise RuntimeError(msg) if existing := self._tracked_tasks.get(task_id): # prevent duplicate tasks if task_id is given and already present return existing @@ -322,8 +323,8 @@ class MusicAssistant: # assume normal callable (non coroutine or awaitable) task = self.loop.create_task(asyncio.to_thread(target, *args, **kwargs)) - def task_done_callback(_task: asyncio.Future | asyncio.Task): # noqa: ARG001 - _task_id = getattr(task, "task_id") + def task_done_callback(_task: asyncio.Future | asyncio.Task) -> None: + _task_id = task.task_id self._tracked_tasks.pop(_task_id) # print unhandled exceptions if LOGGER.isEnabledFor(logging.DEBUG) and not _task.cancelled() and _task.exception(): @@ -337,7 +338,7 @@ class MusicAssistant: if task_id is None: task_id = uuid4().hex - setattr(task, "task_id", task_id) + task.task_id = task_id self._tracked_tasks[task_id] = task task.add_done_callback(task_done_callback) return task @@ -352,7 +353,7 @@ class MusicAssistant: ) -> asyncio.Task | asyncio.Future: """Run callable/awaitable after given delay.""" - def _create_task(): + def _create_task() -> None: self.create_task(target, *args, task_id=task_id, **kwargs) self.loop.call_later(delay, _create_task) @@ -362,7 +363,8 @@ class MusicAssistant: if existing := self._tracked_tasks.get(task_id): # prevent duplicate tasks if task_id is given and already present return existing - raise KeyError("Task does not exist") + msg = "Task does not exist" + raise KeyError(msg) def register_api_command( self, @@ -371,34 +373,37 @@ class MusicAssistant: ) -> None: """Dynamically register a command on the API.""" if command in self.command_handlers: - raise RuntimeError(f"Command {command} is already registered") + msg = f"Command {command} is already registered" + raise RuntimeError(msg) self.command_handlers[command] = APICommandHandler.parse(command, handler) - async def load_provider(self, conf: ProviderConfig) -> None: # noqa: C901 + async def load_provider(self, conf: ProviderConfig) -> None: """Load (or reload) a provider.""" # if provider is already loaded, stop and unload it first await self.unload_provider(conf.instance_id) LOGGER.debug("Loading provider %s", conf.name or conf.domain) if not conf.enabled: - raise SetupFailedError("Provider is disabled") + msg = "Provider is disabled" + raise SetupFailedError(msg) # validate config try: conf.validate() except (KeyError, ValueError, AttributeError, TypeError) as err: - raise SetupFailedError("Configuration is invalid") from err + msg = "Configuration is invalid" + raise SetupFailedError(msg) from err domain = conf.domain prov_manifest = self._provider_manifests.get(domain) # check for other instances of this provider existing = next((x for x in self.providers if x.domain == domain), None) if existing and not prov_manifest.multi_instance: - raise SetupFailedError( - f"Provider {domain} already loaded and only one instance allowed." - ) + msg = f"Provider {domain} already loaded and only one instance allowed." + raise SetupFailedError(msg) # check valid manifest (just in case) if not prov_manifest: - raise SetupFailedError(f"Provider {domain} manifest not found") + msg = f"Provider {domain} manifest not found" + raise SetupFailedError(msg) # handle dependency on other provider if prov_manifest.depends_on: @@ -407,10 +412,11 @@ class MusicAssistant: break await asyncio.sleep(1) else: - raise SetupFailedError( + msg = ( f"Provider {domain} depends on {prov_manifest.depends_on} " "which is not available." ) + raise SetupFailedError(msg) # try to load the module prov_mod = await get_provider_module(domain) @@ -418,7 +424,8 @@ class MusicAssistant: async with asyncio.timeout(30): provider = await prov_mod.setup(self, prov_manifest, conf) except TimeoutError as err: - raise SetupFailedError(f"Provider {domain} did not load within 30 seconds") from err + msg = f"Provider {domain} did not load within 30 seconds" + raise SetupFailedError(msg) from err # if we reach this point, the provider loaded successfully LOGGER.info( "Loaded %s provider %s", @@ -489,9 +496,8 @@ class MusicAssistant: # pylint: disable=broad-except except Exception as exc: LOGGER.exception( - "Error loading provider(instance) %s: %s", + "Error loading provider(instance) %s", prov_conf.name or prov_conf.domain, - str(exc), ) # if loading failed, we store the error in the config object # so we can show something useful to the user @@ -560,22 +566,22 @@ class MusicAssistant: await self.zeroconf.async_update_service(info) else: await self.zeroconf.async_register_service(info) - setattr(self, "mass_zc_service_set", True) + self.mass_zc_service_set = True except NonUniqueNameException: - LOGGER.error( + LOGGER.exception( "Music Assistant instance with identical name present in the local network!" ) - async def __aenter__(self) -> MusicAssistant: + async def __aenter__(self) -> Self: """Return Context manager.""" await self.start() return self async def __aexit__( self, - exc_type: type[BaseException], - exc_val: BaseException, - exc_tb: TracebackType, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> bool | None: """Exit context manager.""" await self.stop() diff --git a/pyproject.toml b/pyproject.toml index b8126041..96995d7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,22 +6,18 @@ build-backend = "setuptools.build_meta" name = "music_assistant" # The version is set by GH action on release version = "0.0.0" -license = {text = "Apache-2.0"} +license = { text = "Apache-2.0" } description = "Music Assistant" readme = "README.md" requires-python = ">=3.11" -authors = [ - {name = "The Music Assistant Authors", email = "marcelveldt@users.noreply.github.com"} +authors = [ + { name = "The Music Assistant Authors", email = "marcelveldt@users.noreply.github.com" }, ] classifiers = [ "Environment :: Console", "Programming Language :: Python :: 3.11", ] -dependencies = [ - "aiohttp", - "orjson", - "mashumaro" -] +dependencies = ["aiohttp", "orjson", "mashumaro"] [project.optional-dependencies] server = [ @@ -46,94 +42,243 @@ server = [ "zeroconf==0.131.0", "cryptography==41.0.7", "ifaddr==0.2.0", - "uvloop==0.19.0" + "uvloop==0.19.0", ] test = [ "black==24.1.1", "codespell==2.2.6", + "isort==5.13.2", "mypy==1.8.0", - "ruff==0.1.14", + "pre-commit==3.6.0", + "pre-commit-hooks==4.5.0", + "pylint==3.0.3", "pytest==7.4.4", - "pytest-asyncio==0.23.3", "pytest-aiohttp==1.0.5", "pytest-cov==4.1.0", - "pre-commit==3.6.0" + "ruff==0.2.1", + "safety==3.0.1", ] [project.scripts] mass = "music_assistant.__main__:main" -[tool.black] -target-version = ['py311'] +[tool.codespell] +ignore-words-list = "provid,hass,followings,childs" + +[tool.setuptools] +platforms = ["any"] +zip-safe = false +packages = ["music_assistant"] +include-package-data = true + +[tool.setuptools.package-data] +music_assistant = ["py.typed"] + +[tool.ruff] +fix = true +show-fixes = true + line-length = 100 +target-version = "py311" + + +[tool.ruff.lint.pydocstyle] +# Use Google-style docstrings. +convention = "pep257" + +[tool.ruff.lint.pylint] + +max-branches = 25 +max-returns = 15 +max-args = 10 +max-statements = 50 -[tool.codespell] -ignore-words-list = "provid,hass,followings" [tool.mypy] +platform = "linux" python_version = "3.11" + +# show error messages from unrelated files +follow_imports = "normal" + +# suppress errors about unsatisfied imports +ignore_missing_imports = true + +# be strict check_untyped_defs = true -#disallow_any_generics = true +disallow_any_generics = true disallow_incomplete_defs = true -disallow_untyped_calls = false +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true disallow_untyped_defs = true -mypy_path = "music_assistant/" no_implicit_optional = true -show_error_codes = true +no_implicit_reexport = true +strict_optional = true warn_incomplete_stub = true +warn_no_return = true warn_redundant_casts = true warn_return_any = true -warn_unreachable = true warn_unused_configs = true warn_unused_ignores = true -[[tool.mypy.overrides]] -ignore_missing_imports = true -module = [ - "aiorun", -] +[tool.pylint.MASTER] +extension-pkg-whitelist = ["orjson"] +ignore = ["tests"] + +[tool.pylint.BASIC] +good-names = ["_", "id", "on", "Run", "T"] + +[tool.pylint.DESIGN] +max-attributes = 8 + +[tool.pylint."MESSAGES CONTROL"] +disable = [ + "duplicate-code", + "format", + "unsubscriptable-object", + "unused-argument", # handled by ruff + "unspecified-encoding", # handled by ruff + "isinstance-second-argument-not-valid-type", # conflict with ruff + "fixme", # we're still developing + + # TEMPORARY DISABLED rules + # The below rules must be enabled later one-by-one ! + "too-many-return-statements", + "unsupported-assignment-operation", + "invalid-name", + "redefined-outer-name", + "too-many-statements", + "deprecated-method", + "logging-fstring-interpolation", + "attribute-defined-outside-init", + "broad-exception-caught", + "expression-not-assigned", + "consider-using-f-string", + "consider-using-with", + "arguments-renamed", + "protected-access", + "too-many-boolean-expressions", + "raise-missing-from", + "too-many-locals", + "abstract-method", + "unnecessary-lambda", + "stop-iteration-return", + "no-else-return", + "no-else-raise", + "undefined-loop-variable", + "too-many-nested-blocks", + "too-many-public-methods", # unavoidable? + "too-many-arguments", # unavoidable? + "too-many-branches", # unavoidable? + "too-many-instance-attributes", # unavoidable? + -[tool.pytest.ini_options] -asyncio_mode = "auto" -pythonpath = [ - "." ] -[tool.setuptools] -platforms = ["any"] -zip-safe = false -packages = ["music_assistant"] -include-package-data = true +[tool.pylint.SIMILARITIES] +ignore-imports = true -[tool.setuptools.package-data] -music_assistant = ["py.typed"] +[tool.pylint.FORMAT] +max-line-length = 100 -[tool.ruff] -fix = true -show-fixes = true +[tool.pytest.ini_options] +addopts = "--cov" +asyncio_mode = "auto" -# enable later: "C90", "PTH", "TCH", "RET", "ANN" -select = ["E", "F", "W", "I", "N", "D", "UP", "PL", "Q", "SIM", "TID", "ARG"] -ignore = ["PLR2004", "N818"] -extend-exclude = ["app_vars.py"] -unfixable = ["F841"] -line-length = 100 -target-version = "py311" +[tool.ruff.lint] +ignore = [ + "ANN002", # Just annoying, not really useful + "ANN003", # Just annoying, not really useful + "ANN101", # Self... explanatory + "ANN401", # Opinioated warning on disallowing dynamically typed expressions + "D203", # Conflicts with other rules + "D213", # Conflicts with other rules + "D417", # False positives in some occasions + "FIX002", # Just annoying, not really useful + "PLR2004", # Just annoying, not really useful + "PD011", # Just annoying, not really useful + "S101", # assert is often used to satisfy type checking + "TD002", # Just annoying, not really useful + "TD003", # Just annoying, not really useful + "TD004", # Just annoying, not really useful + + # Conflicts with the Ruff formatter + "COM812", + "ISC001", -[tool.ruff.flake8-annotations] -allow-star-arg-any = true -suppress-dummy-args = true + # TEMPORARY DISABLED rules + # The below rules must be enabled later one-by-one ! + "BLE001", + "FBT001", + "FBT002", + "FBT003", + "ANN001", + "ANN102", + "ANN201", + "ANN202", + "TRY002", + "PTH103", + "PTH100", + "PTH110", + "PTH111", + "PTH112", + "PTH113", + "PTH118", + "PTH120", + "PTH123", + "PYI034", + "PYI036", + "G004", + "PGH003", + "DTZ005", + "S104", + "S105", + "S106", + "SLF001", + "SIM113", + "SIM102", + "PERF401", + "PERF402", + "ARG002", + "S311", + "TRY301", + "RET505", + "PLR0912", + "B904", + "TRY401", + "S324", + "DTZ006", + "ERA001", + "PTH206", + "C901", + "PTH119", + "PTH116", + "DTZ003", + "RUF012", + "S304", + "DTZ003", + "RET507", + "RUF006", + "TRY300", + "PTH107", + "S608", + "N818", + "S307", + "B007", + "RUF009", + "ANN204", + "PTH202", +] -[tool.ruff.flake8-builtins] -builtins-ignorelist = ["id"] +select = ["ALL"] -[tool.ruff.pydocstyle] -# Use Google-style docstrings. -convention = "pep257" +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false +mark-parentheses = false -[tool.ruff.pylint] +[tool.ruff.lint.isort] +known-first-party = ["music_assistant"] -max-branches=25 -max-returns=15 -max-args=10 -max-statements=50 +[tool.ruff.lint.mccabe] +max-complexity = 25 diff --git a/script/example.py b/script/example.py deleted file mode 100644 index b40f201c..00000000 --- a/script/example.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Example script to test the MusicAssistant server and client.""" - -import argparse -import asyncio -import logging -import os -from os.path import abspath, dirname -from pathlib import Path -from sys import path - -from aiorun import run - -path.insert(1, dirname(dirname(abspath(__file__)))) - -from music_assistant.client.client import MusicAssistantClient # noqa: E402 -from music_assistant.server.server import MusicAssistant # noqa: E402 - -logging.basicConfig(level=logging.DEBUG) - -DEFAULT_PORT = 8095 -DEFAULT_URL = f"http://127.0.0.1:{DEFAULT_PORT}" -DEFAULT_STORAGE_PATH = os.path.join(Path.home(), ".musicassistant") - - -# Get parsed passed in arguments. -parser = argparse.ArgumentParser(description="MusicAssistant Server Example.") -parser.add_argument( - "--config", - type=str, - default=DEFAULT_STORAGE_PATH, - help="Storage path to keep persistent (configuration) data, " - "defaults to {DEFAULT_STORAGE_PATH}", -) -parser.add_argument( - "--log-level", - type=str, - default="info", - help="Provide logging level. Example --log-level debug, default=info, " - "possible=(critical, error, warning, info, debug)", -) - -args = parser.parse_args() - - -if __name__ == "__main__": - # configure logging - logging.basicConfig(level=args.log_level.upper()) - - # make sure storage path exists - if not os.path.isdir(args.config): - os.mkdir(args.config) - - # Init server - server = MusicAssistant(args.config) - - async def run_mass(): - """Run the MusicAssistant server and client.""" - # start MusicAssistant Server - await server.start() - - # run the client - async with MusicAssistantClient(DEFAULT_URL) as client: - # start listening - await client.start_listening() - - async def handle_stop(loop: asyncio.AbstractEventLoop): # noqa: ARG001 - """Handle server stop.""" - await server.stop() - - # run the server - run(run_mass(), shutdown_callback=handle_stop) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py deleted file mode 100644 index b0da3f4f..00000000 --- a/script/gen_requirements_all.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -"""Generate updated constraint and requirements files.""" -from __future__ import annotations - -import json -import os -import re -import sys -import tomllib -from pathlib import Path - -PACKAGE_REGEX = re.compile(r"^(?:--.+\s)?([-_\.\w\d]+).*==.+$") -GIT_REPO_REGEX = re.compile(r"^(git\+https:\/\/[-_\.\w\d\/]+[@-_\.\w\d\/]*)$") - - -def gather_core_requirements() -> list[str]: - """Gather core requirements out of pyproject.toml.""" - with open("pyproject.toml", "rb") as fp: - data = tomllib.load(fp) - # server deps - dependencies: list[str] = data["project"]["optional-dependencies"]["server"] - # regular/client deps - dependencies += data["project"]["dependencies"] - return dependencies - - -def gather_requirements_from_manifests() -> list[str]: - """Gather all of the requirements from provider manifests.""" - dependencies: list[str] = [] - providers_path = "music_assistant/server/providers" - for dir_str in os.listdir(providers_path): - dir_path = os.path.join(providers_path, dir_str) - if not os.path.isdir(dir_path): - continue - # get files in subdirectory - for file_str in os.listdir(dir_path): - file_path = os.path.join(dir_path, file_str) - if not os.path.isfile(file_path): - continue - if file_str != "manifest.json": - continue - - with open(file_path) as _file: - provider_manifest = json.loads(_file.read()) - dependencies += provider_manifest["requirements"] - return dependencies - - -def main() -> int: - """Run the script.""" - if not os.path.isfile("requirements_all.txt"): - print("Run this from MA root dir") - return 1 - - core_reqs = gather_core_requirements() - extra_reqs = gather_requirements_from_manifests() - - # use intermediate dict to detect duplicates - # TODO: compare versions and only store most recent - final_requirements: dict[str, str] = {} - for req_str in core_reqs + extra_reqs: - package_name = req_str - if match := PACKAGE_REGEX.search(req_str): - package_name = match.group(1).lower().replace("_", "-") - elif match := GIT_REPO_REGEX.search(req_str): - package_name = match.group(1) - elif package_name in final_requirements: - # duplicate package without version is safe to ignore - continue - else: - print("Found requirement without (exact) version specifier: %s" % req_str) - package_name = req_str - - existing = final_requirements.get(package_name) - if existing: - print(f"WARNING: ignore duplicate package: {package_name} - existing: {existing}") - continue - final_requirements[package_name] = req_str - - content = "# WARNING: this file is autogenerated!\n\n" - for req_key in sorted(final_requirements): - req_str = final_requirements[req_key] - content += f"{req_str}\n" - Path("requirements_all.txt").write_text(content) - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/script/profiler.py b/script/profiler.py deleted file mode 100644 index 681c9018..00000000 --- a/script/profiler.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -Helper to trace memory usage. - -https://www.red-gate.com/simple-talk/development/python/memory-profiling-in-python-with-tracemalloc/ -""" - -import asyncio -import tracemalloc - -# ruff: noqa: D103,E501,E741 - -# list to store memory snapshots -snaps = [] - - -def _take_snapshot(): - snaps.append(tracemalloc.take_snapshot()) - - -async def take_snapshot(): - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, _take_snapshot) - - -def _display_stats(): - stats = snaps[0].statistics("filename") - print("\n*** top 5 stats grouped by filename ***") - for s in stats[:5]: - print(s) - - -async def display_stats(): - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, _display_stats) - - -def compare(): - first = snaps[0] - for snapshot in snaps[1:]: - stats = snapshot.compare_to(first, "lineno") - print("\n*** top 10 stats ***") - for s in stats[:10]: - print(s) - - -def print_trace(): - # pick the last saved snapshot, filter noise - snapshot = snaps[-1].filter_traces( - ( - tracemalloc.Filter(False, ""), - tracemalloc.Filter(False, ""), - tracemalloc.Filter(False, ""), - ) - ) - largest = snapshot.statistics("traceback")[0] - - print( - f"\n*** Trace for largest memory block - ({largest.count} blocks, {largest.size/1024} Kb) ***" - ) - for l in largest.traceback.format(): - print(l) diff --git a/script/run-in-env.sh b/script/run-in-env.sh deleted file mode 100755 index 271e7a4a..00000000 --- a/script/run-in-env.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env sh -set -eu - -# Activate pyenv and virtualenv if present, then run the specified command - -# pyenv, pyenv-virtualenv -if [ -s .python-version ]; then - PYENV_VERSION=$(head -n 1 .python-version) - export PYENV_VERSION -fi - -# other common virtualenvs -my_path=$(git rev-parse --show-toplevel) - -for venv in venv .venv .; do - if [ -f "${my_path}/${venv}/bin/activate" ]; then - . "${my_path}/${venv}/bin/activate" - break - fi -done - -exec "$@" diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..796441c0 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Music Assistant scripts.""" diff --git a/scripts/example.py b/scripts/example.py new file mode 100644 index 00000000..7bacd370 --- /dev/null +++ b/scripts/example.py @@ -0,0 +1,68 @@ +"""Example script to test the MusicAssistant server and client.""" + +import argparse +import asyncio +import logging +import os +from pathlib import Path + +from aiorun import run + +from music_assistant.client.client import MusicAssistantClient +from music_assistant.server.server import MusicAssistant + +# ruff: noqa: ANN201,PTH102,PTH112,PTH113,PTH118,PTH123,T201 + +DEFAULT_PORT = 8095 +DEFAULT_URL = f"http://127.0.0.1:{DEFAULT_PORT}" +DEFAULT_STORAGE_PATH = os.path.join(Path.home(), ".musicassistant") + +logging.basicConfig(level=logging.DEBUG) + +# Get parsed passed in arguments. +parser = argparse.ArgumentParser(description="MusicAssistant Server Example.") +parser.add_argument( + "--config", + type=str, + default=DEFAULT_STORAGE_PATH, + help="Storage path to keep persistent (configuration) data, " + "defaults to {DEFAULT_STORAGE_PATH}", +) +parser.add_argument( + "--log-level", + type=str, + default="info", + help="Provide logging level. Example --log-level debug, default=info, " + "possible=(critical, error, warning, info, debug)", +) + +args = parser.parse_args() + + +if __name__ == "__main__": + # configure logging + logging.basicConfig(level=args.log_level.upper()) + + # make sure storage path exists + if not os.path.isdir(args.config): + os.mkdir(args.config) + + # Init server + server = MusicAssistant(args.config) + + async def run_mass(): + """Run the MusicAssistant server and client.""" + # start MusicAssistant Server + await server.start() + + # run the client + async with MusicAssistantClient(DEFAULT_URL, None) as client: + # start listening + await client.start_listening() + + async def handle_stop(loop: asyncio.AbstractEventLoop): # noqa: ARG001 + """Handle server stop.""" + await server.stop() + + # run the server + run(run_mass(), shutdown_callback=handle_stop) diff --git a/scripts/gen_requirements_all.py b/scripts/gen_requirements_all.py new file mode 100644 index 00000000..f10f4581 --- /dev/null +++ b/scripts/gen_requirements_all.py @@ -0,0 +1,92 @@ +"""Generate updated constraint and requirements files.""" + +from __future__ import annotations + +import json +import os +import re +import sys +import tomllib +from pathlib import Path + +PACKAGE_REGEX = re.compile(r"^(?:--.+\s)?([-_\.\w\d]+).*==.+$") +GIT_REPO_REGEX = re.compile(r"^(git\+https:\/\/[-_\.\w\d\/]+[@-_\.\w\d\/]*)$") + +# ruff: noqa: PTH112,PTH113,PTH118,PTH123,T201 + + +def gather_core_requirements() -> list[str]: + """Gather core requirements out of pyproject.toml.""" + with open("pyproject.toml", "rb") as fp: + data = tomllib.load(fp) + # server deps + dependencies: list[str] = data["project"]["optional-dependencies"]["server"] + # regular/client deps + dependencies += data["project"]["dependencies"] + return dependencies + + +def gather_requirements_from_manifests() -> list[str]: + """Gather all of the requirements from provider manifests.""" + dependencies: list[str] = [] + providers_path = "music_assistant/server/providers" + for dir_str in os.listdir(providers_path): + dir_path = os.path.join(providers_path, dir_str) + if not os.path.isdir(dir_path): + continue + # get files in subdirectory + for file_str in os.listdir(dir_path): + file_path = os.path.join(dir_path, file_str) + if not os.path.isfile(file_path): + continue + if file_str != "manifest.json": + continue + + with open(file_path) as _file: + provider_manifest = json.loads(_file.read()) + dependencies += provider_manifest["requirements"] + return dependencies + + +def main() -> int: + """Run the script.""" + if not os.path.isfile("requirements_all.txt"): + print("Run this from MA root dir") + return 1 + + core_reqs = gather_core_requirements() + extra_reqs = gather_requirements_from_manifests() + + # use intermediate dict to detect duplicates + # TODO: compare versions and only store most recent + final_requirements: dict[str, str] = {} + for req_str in core_reqs + extra_reqs: + package_name = req_str + if match := PACKAGE_REGEX.search(req_str): + package_name = match.group(1).lower().replace("_", "-") + elif match := GIT_REPO_REGEX.search(req_str): + package_name = match.group(1) + elif package_name in final_requirements: + # duplicate package without version is safe to ignore + continue + else: + print(f"Found requirement without (exact) version specifier: {req_str}") + package_name = req_str + + existing = final_requirements.get(package_name) + if existing: + print(f"WARNING: ignore duplicate package: {package_name} - existing: {existing}") + continue + final_requirements[package_name] = req_str + + content = "# WARNING: this file is autogenerated!\n\n" + for req_key in sorted(final_requirements): + req_str = final_requirements[req_key] + content += f"{req_str}\n" + Path("requirements_all.txt").write_text(content) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/profiler.py b/scripts/profiler.py new file mode 100644 index 00000000..44d7844b --- /dev/null +++ b/scripts/profiler.py @@ -0,0 +1,63 @@ +""" +Helper to trace memory usage. + +https://www.red-gate.com/simple-talk/development/python/memory-profiling-in-python-with-tracemalloc/ +""" + +import asyncio +import tracemalloc + +# ruff: noqa: D103,E501,E741,FBT003,T201,ANN201,ANN202 +# pylint: disable=missing-function-docstring + +# list to store memory snapshots +snaps = [] + + +def _take_snapshot(): + snaps.append(tracemalloc.take_snapshot()) + + +async def take_snapshot(): + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, _take_snapshot) + + +def _display_stats(): + stats = snaps[0].statistics("filename") + print("\n*** top 5 stats grouped by filename ***") + for s in stats[:5]: + print(s) + + +async def display_stats(): + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, _display_stats) + + +def compare(): + first = snaps[0] + for snapshot in snaps[1:]: + stats = snapshot.compare_to(first, "lineno") + print("\n*** top 10 stats ***") + for s in stats[:10]: + print(s) + + +def print_trace(): + # pick the last saved snapshot, filter noise + snapshot = snaps[-1].filter_traces( + ( + tracemalloc.Filter(False, ""), + tracemalloc.Filter(False, ""), + tracemalloc.Filter(False, ""), + ) + ) + largest = snapshot.statistics("traceback")[0] + + print( + "\n*** Trace for largest memory block - " + f"({largest.count} blocks, {largest.size/1024} Kb) ***" + ) + for l in largest.traceback.format(): + print(l) diff --git a/scripts/run-in-env.sh b/scripts/run-in-env.sh new file mode 100755 index 00000000..271e7a4a --- /dev/null +++ b/scripts/run-in-env.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +set -eu + +# Activate pyenv and virtualenv if present, then run the specified command + +# pyenv, pyenv-virtualenv +if [ -s .python-version ]; then + PYENV_VERSION=$(head -n 1 .python-version) + export PYENV_VERSION +fi + +# other common virtualenvs +my_path=$(git rev-parse --show-toplevel) + +for venv in venv .venv .; do + if [ -f "${my_path}/${venv}/bin/activate" ]; then + . "${my_path}/${venv}/bin/activate" + break + fi +done + +exec "$@" diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 976d6e9f..25903ace 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,13 +1,13 @@ """Tests for utility/helper functions.""" -from pytest import raises +import pytest from music_assistant.common.helpers import uri, util from music_assistant.common.models import media_items from music_assistant.common.models.errors import MusicAssistantError -def test_version_extract(): +def test_version_extract() -> None: """Test the extraction of version from title.""" test_str = "Bam Bam (feat. Ed Sheeran) - Karaoke Version" title, version = util.parse_title_and_version(test_str) @@ -15,7 +15,7 @@ def test_version_extract(): assert version == "Karaoke Version" -def test_uri_parsing(): +def test_uri_parsing() -> None: """Test parsing of URI.""" # test regular uri test_uri = "spotify://track/123456789" @@ -42,5 +42,5 @@ def test_uri_parsing(): assert provider == "filesystem" assert item_id == "Artist/Album/Track.flac" # test invalid uri - with raises(MusicAssistantError): + with pytest.raises(MusicAssistantError): uri.parse_uri("invalid://blah") diff --git a/tests/test_tags.py b/tests/test_tags.py index 3fd9b976..dccb6c2f 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -50,11 +50,11 @@ async def test_parse_metadata_from_filename(): assert _tags.album is None assert _tags.title == "MyTitle without Tags" assert _tags.duration == 1 - assert _tags.album_artists == tuple() + assert _tags.album_artists == () assert _tags.artists == ("MyArtist",) - assert _tags.genres == tuple() - assert _tags.musicbrainz_albumartistids == tuple() - assert _tags.musicbrainz_artistids == tuple() + assert _tags.genres == () + assert _tags.musicbrainz_albumartistids == () + assert _tags.musicbrainz_artistids == () assert _tags.musicbrainz_releasegroupid is None assert _tags.musicbrainz_recordingid is None @@ -66,10 +66,10 @@ async def test_parse_metadata_from_invalid_filename(): assert _tags.album is None assert _tags.title == "test" assert _tags.duration == 1 - assert _tags.album_artists == tuple() + assert _tags.album_artists == () assert _tags.artists == (tags.UNKNOWN_ARTIST,) - assert _tags.genres == tuple() - assert _tags.musicbrainz_albumartistids == tuple() - assert _tags.musicbrainz_artistids == tuple() + assert _tags.genres == () + assert _tags.musicbrainz_albumartistids == () + assert _tags.musicbrainz_artistids == () assert _tags.musicbrainz_releasegroupid is None assert _tags.musicbrainz_recordingid is None