Reconfigure linting,testing and formatting (#1070)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 9 Feb 2024 08:36:02 +0000 (09:36 +0100)
committerGitHub <noreply@github.com>
Fri, 9 Feb 2024 08:36:02 +0000 (09:36 +0100)
107 files changed:
.github/workflows/pre-commit-updater.yml [deleted file]
.pre-commit-config.yaml
music_assistant/__main__.py
music_assistant/client/client.py
music_assistant/client/connection.py
music_assistant/client/music.py
music_assistant/client/players.py
music_assistant/common/helpers/json.py
music_assistant/common/helpers/uri.py
music_assistant/common/helpers/util.py [changed mode: 0755->0644]
music_assistant/common/models/api.py
music_assistant/common/models/config_entries.py
music_assistant/common/models/event.py
music_assistant/common/models/media_items.py [changed mode: 0755->0644]
music_assistant/common/models/player.py
music_assistant/common/models/player_queue.py
music_assistant/constants.py [changed mode: 0755->0644]
music_assistant/server/__init__.py
music_assistant/server/controllers/cache.py
music_assistant/server/controllers/config.py
music_assistant/server/controllers/media/albums.py
music_assistant/server/controllers/media/artists.py
music_assistant/server/controllers/media/base.py
music_assistant/server/controllers/media/playlists.py
music_assistant/server/controllers/media/radio.py
music_assistant/server/controllers/media/tracks.py
music_assistant/server/controllers/metadata.py [changed mode: 0755->0644]
music_assistant/server/controllers/music.py [changed mode: 0755->0644]
music_assistant/server/controllers/player_queues.py [changed mode: 0755->0644]
music_assistant/server/controllers/players.py [changed mode: 0755->0644]
music_assistant/server/controllers/streams.py
music_assistant/server/controllers/webserver.py
music_assistant/server/helpers/api.py
music_assistant/server/helpers/audio.py
music_assistant/server/helpers/auth.py
music_assistant/server/helpers/compare.py
music_assistant/server/helpers/database.py [changed mode: 0755->0644]
music_assistant/server/helpers/didl_lite.py
music_assistant/server/helpers/images.py
music_assistant/server/helpers/logging.py
music_assistant/server/helpers/playlists.py
music_assistant/server/helpers/process.py
music_assistant/server/helpers/tags.py
music_assistant/server/helpers/util.py
music_assistant/server/helpers/webserver.py
music_assistant/server/models/__init__.py
music_assistant/server/models/core_controller.py
music_assistant/server/models/music_provider.py
music_assistant/server/models/player_provider.py
music_assistant/server/models/plugin.py
music_assistant/server/models/provider.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/chromecast/helpers.py
music_assistant/server/providers/deezer/__init__.py
music_assistant/server/providers/deezer/gw_client.py
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/dlna/icon.svg [changed mode: 0755->0644]
music_assistant/server/providers/fanarttv/__init__.py
music_assistant/server/providers/filesystem_local/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/filesystem_smb/__init__.py
music_assistant/server/providers/fully_kiosk/__init__.py
music_assistant/server/providers/musicbrainz/__init__.py
music_assistant/server/providers/opensubsonic/__init__.py
music_assistant/server/providers/opensubsonic/sonic_provider.py
music_assistant/server/providers/plex/__init__.py
music_assistant/server/providers/plex/helpers.py
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/qobuz/icon.svg [changed mode: 0755->0644]
music_assistant/server/providers/qobuz/icon_dark.svg [changed mode: 0755->0644]
music_assistant/server/providers/radiobrowser/__init__.py
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/slimproto/cli.py
music_assistant/server/providers/slimproto/icon.svg [changed mode: 0755->0644]
music_assistant/server/providers/slimproto/models.py
music_assistant/server/providers/snapcast/__init__.py
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/sonos/helpers.py
music_assistant/server/providers/sonos/player.py
music_assistant/server/providers/soundcloud/__init__.py
music_assistant/server/providers/soundcloud/soundcloudpy/asyncsoundcloudpy.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/theaudiodb/__init__.py
music_assistant/server/providers/tidal/__init__.py
music_assistant/server/providers/tidal/helpers.py
music_assistant/server/providers/tidal/icon.svg [changed mode: 0755->0644]
music_assistant/server/providers/tidal/icon_dark.svg [changed mode: 0755->0644]
music_assistant/server/providers/tunein/__init__.py
music_assistant/server/providers/ugp/__init__.py
music_assistant/server/providers/url/__init__.py
music_assistant/server/providers/ytmusic/__init__.py
music_assistant/server/providers/ytmusic/helpers.py
music_assistant/server/providers/ytmusic/icon.svg [changed mode: 0755->0644]
music_assistant/server/server.py
pyproject.toml
script/example.py [deleted file]
script/gen_requirements_all.py [deleted file]
script/profiler.py [deleted file]
script/run-in-env.sh [deleted file]
scripts/__init__.py [new file with mode: 0644]
scripts/example.py [new file with mode: 0644]
scripts/gen_requirements_all.py [new file with mode: 0644]
scripts/profiler.py [new file with mode: 0644]
scripts/run-in-env.sh [new file with mode: 0755]
tests/test_helpers.py
tests/test_tags.py

diff --git a/.github/workflows/pre-commit-updater.yml b/.github/workflows/pre-commit-updater.yml
deleted file mode 100644 (file)
index e1a1838..0000000
+++ /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
index 763cd50adff07d8853b3d194ddc4c5088182790b..2002eb64ed615332684752b41b60343ac9becf0e 100644 (file)
 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)$
index 6ba281a7117aa74130adf0812c0651be0ecb9b72..3a4b0f49443ad8192f4cb417004c0e9f74b2f1a7 100644 (file)
@@ -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":
index af79e88699f56c02fb8a0819d7a039de7f2993bc..d3daa74da1f4d50f91a87c2e68299e601e4ad85c 100644 (file)
@@ -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:
index 0fb1c49b3a3a792f9ae98bcdee812af6c36da4de..4dfc9651ce6f6d281b729ba06a5cf0162fba8778 100644 (file)
@@ -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))
index 1840d27cd1187e9b761c13ad1e8765adadf2cbbb..006867bf29a4ae5646c09be5296b7eee193f1229 100644 (file)
@@ -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")]
index 1cf7cb0a0c4f17f2aa2fef3ce157983665659df4..119081df9225ed24bca12f52663908066c1dafc3 100644 (file)
@@ -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
 
 
index 917c4574233e1d935c4d1f9936d2608b2a65fa68..839a19dffe0955c4766fa81585fab3122543d273 100644 (file)
@@ -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
 
 
index be2fa4afecb7392d6ec0dfe98de9799a13855659..5db630da42680356ebe0a6cf6f8c0287aa717d91 100644 (file)
@@ -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)
 
 
old mode 100755 (executable)
new mode 100644 (file)
index 2bd40ec..a06cb8a
@@ -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(
index 1894d20128536906bb4e9d39a6ca971172559511..f5e45d5713638ff8f0955fd0e0c5f21fd89a3597 100644 (file)
@@ -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):
index f853f6f1c3b26294f1d95e419b838aeff9d70290..f348cb862728fc98d6d28e540f66c89869760290 100644 (file)
@@ -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
 
index d61a25a4d93083048848a1faba5bd94998a35ee8..74b40dc7d8a0f092ae336335f265d7fa2d0e0cb0 100644 (file)
@@ -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
+        },
+    )
old mode 100755 (executable)
new mode 100644 (file)
index 53df383..5085916
@@ -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
 
index 55f9ab58945f47c502d6b07166fae511f5fedfe8..e2b8ca7748bfdeea7061270d68d2dcac13823729 100644 (file)
@@ -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"
index 30b9cae71febd6dd78e96c484a6f0ec640522844..48a455decafd7053bbe55678d27a25fdb9a99681 100644 (file)
@@ -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
old mode 100755 (executable)
new mode 100644 (file)
index 57155dc..ab5b810
@@ -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 = (
index 0f273b47b540f1d11a15448126bcf305d6ab03a2..7fe0caca8dfa43fb55f48bf2f3625c25ddfc4792 100644 (file)
@@ -1,3 +1,3 @@
 """Music Assistant: The music library manager in python."""
 
-from .server import MusicAssistant  # noqa
+from .server import MusicAssistant  # noqa: F401
index d6760d63ac061032e74dbf35e141b43e03f42eee..3523ebaa245d25a90a0725ecb5dac0808a470c41 100644 (file)
@@ -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()
index 8b21c74b9206b835ad33cfac2910217d4c6d71b5..6a112f80057251d62c506a954da6447c2c83cb60 100644 (file)
@@ -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(
index 945d490d114a999b9f30eb7c5381b8c0fa9fa6cb..33050b50b0dbbc547de63fb747c9ff8228ec08ad 100644 (file)
@@ -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,
index 55794d947331983685354ae3a6eb4695409e481e..e4e8d5061287b67d40cb900dbb37f14275c6316b 100644 (file)
@@ -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:
index 0833f4c4a045717671e2aef13a06fca81bae9b82..66c112ef02e7ce0e26ba69e9f394c5d905fdd018 100644 (file)
@@ -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
index 2659aa150b64a5927cb3c9f1b6ebd9f6a3265162..18adbf867fbaa647387f23424d34f8ccf0204a65 100644 (file)
@@ -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)
index ccbf9271437aa4210c111058f9b4c82c430a0643..c4b1c53926e02c1e65ead1a416860316042dcb96 100644 (file)
@@ -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)
index 4f00d267f3c119d6c1cdd4c8385c69f34c8768df..b3068511c782449a2ddfa32ab538114f8e82b769 100644 (file)
@@ -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":
old mode 100755 (executable)
new mode 100644 (file)
index f8e15bc..072bfb5
@@ -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
old mode 100755 (executable)
new mode 100644 (file)
index 8af0139..b4db9e1
@@ -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)
old mode 100755 (executable)
new mode 100644 (file)
index 94daaff..99ce19a
@@ -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:
old mode 100755 (executable)
new mode 100644 (file)
index 7feb208..671f06b
@@ -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)
index 33080999293026d11876f9b3928c892abf7c3090..d85be427542c4471f5787845c3869b814630117a 100644 (file)
@@ -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:
index 6d82c47fe5ddd0324ae69b6881bcc51441e7df42..0917c96917576d5daf07b09e802e0a590d010abf 100644 (file)
@@ -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()
 
index 95af94bd4d3d196e9dc6cb88c966e4805b54103a..c0bf9345e076b6865c2c9b71c1a1257bd6f70ae7 100644 (file)
@@ -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
index 0b8a976497e209cacce399117fbd50c01f1ae6a2..a072ce7f443d3540a127bcf0e254f6a5799fe405 100644 (file)
@@ -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()))
index c609943af2e95d799ede119c339568a1f0e05228..a057abc2a2f9e4dcbc7ddf017ca82eab831016d1 100644 (file)
@@ -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.
 
index 259a5f210d5a77132bf0fe7770cd5ea48c0ed56e..9b3521c224b914438fce47beec3d11c302328789 100644 (file)
@@ -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
old mode 100755 (executable)
new mode 100644 (file)
index 2d9cfb7..a15148d
@@ -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
index dc7e84ca7e57eb3650a0a796c7f0ebefaa21dd04..159be91075033cb2e3a1531909f928f848ed4ffd 100644 (file)
@@ -81,5 +81,4 @@ def escape_string(data: str) -> str:
     data = data.replace("&", "&amp;")
     # data = data.replace("?", "&#63;")
     data = data.replace(">", "&gt;")
-    data = data.replace("<", "&lt;")
-    return data
+    return data.replace("<", "&lt;")
index 9f85eec13c746aeb9999e5d25cead1ef80ba160b..4e54a1a5990ebf5c733d571235d247bae953fc63 100644 (file)
@@ -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()
index 3034c80c71291e4a9543850015548b06c2ea8741..fee87a63374a303627faf209bf976321cafd84d5 100644 (file)
@@ -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
index efbf2e84d6f30b36aa6a4eeb95758e7ab25f7102..a8c2009c9a35cb12036d5950752c1180edf849dd 100644 (file)
@@ -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
index fa9ad501cce159d179e67dcc272196cd5013ff6b..3c725296ffc45c60aed67b60d27a79afa57be59c 100644 (file)
@@ -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
index 155e8bc81d1c89f6601c134d8c9904b58e587f07..8f5830a00af24eca38a5802b8c406f1bffd7e06b 100644 (file)
@@ -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:
index 57b48417adfe48d2a3c6b22b54a256aad99e65d7..0d00fb96de545806d29f8d634baac2a731d92ba5 100644 (file)
@@ -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)
 
index 2ce47c506d63f08a5f11db5404ba164f7f2df15f..29dcc23535dba6bdef4b622c33b34f10a257e4a5 100644 (file)
@@ -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)
 
index cdf98962dd224a3daee394a440d8b99ed538d152..4e20ea729b26aa3ff6879dc4a344e8da06400601 100644 (file)
@@ -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
 
index 1e150dcb66ea6d624caa9936019074912faa83f2..3f35884b538abab17921b63636b7a42b5d847575 100644 (file)
@@ -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."""
index 0f96b474d9e06e74199e0a0e8a10da63122a2ad9..b6d8fadc62d1ff9a8d4be427d455c32a70683162 100644 (file)
@@ -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(
index be404abf338f7044c5da7095e6dd8d0d15436da6..02b54bc1edd5cc9a9132cf058f65797e3863219d 100644 (file)
@@ -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.
index 6e7207e24be0416cc5d25bc9d4d4a192eca3f82d..8c0ebc5e9899c16272e739b75853345f08e95c78 100644 (file)
@@ -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
 
 
index d7063477ec18a88780c2478c1332c6a26c48e7eb..c4ae5cfc5b69599072e396a5bf422c87e2bcacc2 100644 (file)
@@ -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,
index 5d2701f333a42d13e3c56e29aec971974864d719..2a82348c94b9d0a4ce95ed323d56ff40fa1602f5 100644 (file)
@@ -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()
index 491e8aaae8e2431295cc6c46dd16c4fafd289bfe..6b379735f8ed3428b5cc3a5dacb6e738f1713f2d 100644 (file)
@@ -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()
index 7959f5d7986c6d80454841b63bdc981b5eff3be1..114a338380671952bc859426531ac006923aed5e 100644 (file)
@@ -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.
 
index 65e05191754ddfbd1f3bd85351bd500ba08e04a0..9937a08ddaa593b5997b4c1de16ebf4c62109ab8 100644 (file)
@@ -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)
index 606e662a174eea5be9b5b2f49a1186651e0c1cbb..1d16f119aa620be720c6163dc11af506e824ec0b 100644 (file)
@@ -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 = {}
 
index ced3fc2c34e7487312866723808dcc33ec4f4bcf..42de72ff3348761ac945abd10239d16e5fc3326e 100644 (file)
@@ -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,
             )
old mode 100755 (executable)
new mode 100644 (file)
index 6398812b16ff56e2d699059ee60aaf4a9e06ea66..24a13b9114e17ec1c3012c5988b95acd6ec4955c 100644 (file)
@@ -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
index 21e86ea698dc1e119efaf60d6faa6aee08af4715..8fcce0606e3696316e6867e76ccea9ecf362fd2c 100644 (file)
@@ -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.
 
index d9ef4da199d1475fb5981176d31e22812898a69a..e2c37f8986d6519ed497699f3c3c33c46f5350b0 100644 (file)
@@ -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
index 1e46844226e7f6edf577d2df6cde064fcabffa8b..a5d1c7cc777e6ec1974e4fc1e4879b3422651ea8 100644 (file)
@@ -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."""
index 24d22c9e134dcf21cadfd45aa8c89a669400ca63..6c2e662b78ac95825ec3f8ed1aecea6094f368b3 100644 (file)
@@ -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
index a3980ad54edd240b2470ade0bd009735aa03de79..d39ee290545dad5d38665bd54aa81dc55a2664f0 100644 (file)
@@ -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
index 81dbdabd2f67bb6f347479aa61b8259fac20c96b..b0349f135830ac4c4361b67826ffccce577f264d 100644 (file)
@@ -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
index 8c60f8b69e8acca9a4f84103344059ff859bb3b0..c40d19209de0ec8bad57273ae7c33ca1f31b13a1 100644 (file)
@@ -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:
index 3086be23b88b2ad8262c8ef6209b433025d753b2..fafdba74a6272118ba60b96e7ea52191ec7ba38e 100644 (file)
@@ -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)
index a60de938391f754b113dbc1104d4bdf5f8434b2e..21e3d4e0927ec87589358454141260d8d8d4bca7 100644 (file)
@@ -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)
index 504421c1ab78076ed479445bb2c7456d31295e1f..44a60182a78df42331d48d6a6b4c6312d86dcbe9 100644 (file)
@@ -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
 
old mode 100755 (executable)
new mode 100644 (file)
index a55b0034ef6c02c855eca03e8fdd6e0d2d25d178..3a8d779dd5beef14e5a176fd9ea4042c6ae0e19a 100644 (file)
@@ -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):
index e3b65d97e4198acf2da0646ed2d02622290ec3f4..5a5e0d727f961e02aa4815aae38998e07df423e3 100644 (file)
@@ -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",
index 3822fd2e2f298c12832c039ec551edde886e4151..805fad343d3bf0bdfb5d2aca5201e0d23f6cd4f0 100644 (file)
@@ -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
 
         # <playerid> 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:
         # <playerid> playlist index <index|+index|-index|?> <fadeInSecs>
         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
index 06f4e904ab1da79e4b277e8444168f883ba92fe3..5e5c88a14b199c8f9feb891bc05579ca1c5e196c 100644 (file)
@@ -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
index 489692d50c09e2b4474e54a76d6718bcbc611955..c7fcccfa922a4951ffe8f39f550892175a5489d8 100644 (file)
@@ -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."""
index 5fa2aa6cb31ba13d2d9f1745c0d0c5d2d32b659c..b553b61d27fdc38f90c167786dac83bb8b13b00e 100644 (file)
@@ -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())
 
index 872b7682fad9159ac4412e27bf5a0c07f58c1ca8..f8be90e78e05d2cf50630981e6e76d89cd0edf6e 100644 (file)
@@ -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}"
 
 
index 2a1b2893fefeac3c6b9b0566ebd1668073b6441c..bba358c925c5e3ac9eb9dcd4c1c23ae3903a38ce 100644 (file)
@@ -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
index 362e1d4bdd4cfe2a65c1d378893bdbfbc34ee397..a8c4df7dadab1d88f63269f020f2ca2a8fd905fe 100644 (file)
@@ -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,
index c5969287b30716837aaddd930749ce04276b99fd..81709c04cfedca3f2d5e9861893d93fa2f65d697 100644 (file)
@@ -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
index 94f3b4343f811c7e174e1218a44a1ea5e7bb2562..0f3eb78f60edc206f0417a12f03fc5615199fd07 100644 (file)
@@ -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)
index fe6bcb605d7ccee418843844796e3911160623c7..b7dc8001ab95d58dfb7780c34f25336f3086e60e 100644 (file)
@@ -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
index f470f377e7141aa9f66530f0e0c8d18fb7ddbbe5..470ec88e326588c657db48e59a4db396d6dc5da4 100644 (file)
@@ -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."""
 
index cfe3f8629105a19a0731a51e541ab9b1b628edbd..9c4c52152737dc0805fbfb10f621eecb40c30f57 100644 (file)
@@ -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)
 
old mode 100755 (executable)
new mode 100644 (file)
index eebae417eccdb36794946760b36e6f624e2c5d56..05a414d3356da839a97684b3d277e74ebffce702 100644 (file)
@@ -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):
index 0e1efdf00aee4254026f126dcd6c5b0a1166f80b..7e621f3aba3a9673c30157b203e4e8a2cfa387f8 100644 (file)
@@ -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
index f1e5575b7f0a2b62f71424531ff379e7b42be6e7..38422d773574823e0f0ec468ffeacf2a1469104c 100644 (file)
@@ -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
index ecfc97cc541afe5af9d2aff5f87255d83f5c8e23..9701d639504a8993ac0f0e1cfad28c81a802cf80 100644 (file)
@@ -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
index 8a0d73189630fdc532831db0d668b2d62f871254..c73bde9b52d336498e0ba85d350ac9c4f26730c2 100644 (file)
@@ -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):
index 2547963292543a2278a931ebc06688b00fa9f355..32d21515df2567b862aa607d6c771335586a04fc 100644 (file)
@@ -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()
index b812604118cc16a9b321d46a1b393a832c00fd32..96995d7c3f325878b6a4b05778efee71473105cf 100644 (file)
@@ -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 (file)
index b40f201..0000000
+++ /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 (file)
index b0da3f4..0000000
+++ /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 (file)
index 681c901..0000000
+++ /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, "<frozen importlib._bootstrap>"),
-            tracemalloc.Filter(False, "<frozen importlib._bootstrap_external>"),
-            tracemalloc.Filter(False, "<unknown>"),
-        )
-    )
-    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 (executable)
index 271e7a4..0000000
+++ /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 (file)
index 0000000..796441c
--- /dev/null
@@ -0,0 +1 @@
+"""Music Assistant scripts."""
diff --git a/scripts/example.py b/scripts/example.py
new file mode 100644 (file)
index 0000000..7bacd37
--- /dev/null
@@ -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 (file)
index 0000000..f10f458
--- /dev/null
@@ -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 (file)
index 0000000..44d7844
--- /dev/null
@@ -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, "<frozen importlib._bootstrap>"),
+            tracemalloc.Filter(False, "<frozen importlib._bootstrap_external>"),
+            tracemalloc.Filter(False, "<unknown>"),
+        )
+    )
+    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 (executable)
index 0000000..271e7a4
--- /dev/null
@@ -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 "$@"
index 976d6e9f04f0befb26c74375b5357bbd59002567..25903aceb7be5966162345e3e524dbaee2d81616 100644 (file)
@@ -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")
index 3fd9b9760c27b5172982f869ee477024a21dcf47..dccb6c2fa7fc6d2104515890d3306106bf15aa8b 100644 (file)
@@ -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