+++ /dev/null
-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
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)$
"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"):
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",
subprocess._USE_POSIX_SPAWN = os.path.exists(ALPINE_RELEASE_FILE)
-def main():
+def main() -> None:
"""Start MusicAssistant."""
# parse arguments
args = get_arguments()
# 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":
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,
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
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
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)
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
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
) -> 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,
) -> 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,
# 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:
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"
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:
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))
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(
]
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")]
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
if isinstance(obj, DO_NOT_SERIALIZE_TYPES):
return None
if raise_unhandled:
- raise TypeError()
+ raise TypeError
return obj
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
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)
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 [" (", " [", " - ", " (", " [", "-"]:
_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)
"""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):
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(
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):
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,
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
)
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
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
+ },
+ )
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,
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
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)
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())
d.pop("callback")
return d
- def __str__(self):
+ def __str__(self) -> str:
"""Return pretty printable string of object."""
return self.uri
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()
# 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"
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
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 = (
"""Music Assistant: The music library manager in python."""
-from .server import MusicAssistant # noqa
+from .server import MusicAssistant # noqa: F401
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:
),
)
- 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()
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
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})
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
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)
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
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()
from __future__ import annotations
-import asyncio
import base64
import logging
import os
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,
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,
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
# 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:
"""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:
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 (
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":
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(
) -> 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
if not changed_keys:
# no changes
- return
+ return None
conf_key = f"{CONF_PLAYERS}/{player_id}"
self.set(conf_key, config.to_raw())
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
# 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:
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():
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(
"""
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:
"""
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:
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."""
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
# 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:
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(
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()
) -> 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)
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)
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,
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,
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:
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()
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)
# 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.
)
# 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."""
# 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:
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
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")
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}"
)
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
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,
# 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
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
from .base import MediaControllerBase
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator
+
class PlaylistController(MediaControllerBase[Playlist]):
"""Controller managing MediaItems of type 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()
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)
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)
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))
# 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
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
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])
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:
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)
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()
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)
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)
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 = (
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)
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)
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."""
# 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":
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")
)
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)
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
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
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
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"
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 (
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,
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(
| TracksController
| RadioController
| PlaylistController
- ): # noqa: E501
+ ):
"""Return controller for MediaType."""
if media_type == MediaType.ARTIST:
return self.artists
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:
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(
# 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)
import logging
import random
import time
-from collections.abc import AsyncGenerator
from contextlib import suppress
from typing import TYPE_CHECKING, Any
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
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)
@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)
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:
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()
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(
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
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).
"""
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):
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
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
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:
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
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,
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")
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."""
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())
"""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")
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(
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)
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)
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)
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.
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
"""
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 "
"""
# 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
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."""
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."""
break
else:
# edge case: no child player is (yet) available; postpone register
- return
+ return None
player = Player(
player_id=group_player_id,
provider=provider,
# 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:
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)
import logging
import time
import urllib.parse
-from collections.abc import AsyncGenerator
from contextlib import suppress
from typing import TYPE_CHECKING
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,
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 = {
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.
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):
"""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:
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,
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)
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()
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
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)
# 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
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,
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,
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:
import logging
import os
import urllib.parse
-from collections.abc import Awaitable
from concurrent import futures
from contextlib import suppress
from functools import partial
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
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"
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)
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()
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())
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")
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
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()
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__)
"""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)):
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 {
)
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:
# 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]
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
import os
import re
import struct
-from collections.abc import AsyncGenerator
from contextlib import suppress
from io import BytesIO
from time import time
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(
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
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
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)
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:
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):
"""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:
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
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()))
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.
"""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
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
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]:
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
data = data.replace("&", "&")
# data = data.replace("?", "?")
data = data.replace(">", ">")
- data = data.replace("<", "<")
- return data
+ return data.replace("<", "<")
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
# 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(
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()
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))
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()
@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(
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
from __future__ import annotations
-import asyncio
import logging
from typing import TYPE_CHECKING
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
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__)
enable_stdin: bool = False,
enable_stdout: bool = True,
enable_stderr: bool = False,
- ):
+ ) -> None:
"""Initialize."""
self._proc = None
self._args = args
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
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,
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()
if TAG_SPLITTER in tag:
return split_items(tag)
return split_artists(tag)
- return tuple()
+ return ()
@property
def genres(self) -> tuple[str, ...]:
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:
"""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(
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:
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
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:
) 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:
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
import memory_tempfile
if TYPE_CHECKING:
+ from collections.abc import Iterator
+
from music_assistant.server.models import ProviderModuleType
LOGGER = logging.getLogger(__name__)
return getattr(err, "code", 999) == 401
except Exception:
return False
+ return False
return await asyncio.to_thread(_check)
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
self,
logger: logging.Logger,
enable_dynamic_routes: bool = False,
- ):
+ ) -> None:
"""Initialize instance."""
self.logger = logger
# the below gets initialized in async setup
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():
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)
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
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."""
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
from .provider import Provider
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator
+
# ruff: noqa: ARG001, ARG002
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:
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(
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
)
if player_id.startswith(SYNCGROUP_PREFIX):
# add default entries for syncgroups
- return entries + (
+ return (
+ *entries,
ConfigEntry(
key=CONF_GROUP_MEMBERS,
type=ConfigEntryType.STRING,
- 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,
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
from __future__ import annotations
-from typing import TYPE_CHECKING
-
from .provider import Provider
-if TYPE_CHECKING:
- pass
-
# ruff: noqa: ARG001, ARG002
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."""
@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."""
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,
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):
# 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."""
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()
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")
):
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."""
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
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
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()
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,
)
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,
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
_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", []):
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
return
# stop discovery
- def stop_discovery():
+ def stop_discovery() -> None:
"""Stop the chromecast discovery threads."""
if self.browser._zc_browser:
with contextlib.suppress(RuntimeError):
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)
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(
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
### Discovery callbacks
- def _on_chromecast_discovered(self, uuid, _):
+ def _on_chromecast_discovered(self, uuid, _) -> None:
"""Handle Chromecast discovered callback."""
if self.mass.closing:
return
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,
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)
# 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
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
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()
from pychromecast import dial
from pychromecast.const import CAST_TYPE_GROUP
-from zeroconf import ServiceInfo
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 zeroconf import Zeroconf
+ from zeroconf import ServiceInfo, Zeroconf
from . import CastPlayer, ChromecastProvider
castplayer: CastPlayer,
mz_mgr: MultizoneManager,
mz_only=False,
- ):
+ ) -> None:
"""Initialize the status listener."""
self.prov = prov
self.castplayer = castplayer
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
)
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:
)
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
"%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.
AlbumTrack,
Artist,
AudioFormat,
- BrowseFolder,
ItemMapping,
MediaItemImage,
MediaItemMetadata,
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
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(
: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 = {}
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])
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)
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."""
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)
]
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},
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"]
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()
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):
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 = {}
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,
)
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
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
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."""
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
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
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)
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(
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:
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
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,
)
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
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
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):
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()
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
import asyncio
import os
import os.path
-from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING
import aiofiles
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
"""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
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.
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,
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(
),
)
-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")
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()
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 = {}
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)
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)
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(
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
) -> 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:
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
) -> 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)
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)
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,
# 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)
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"):
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:
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
# 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
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)
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:
"""
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, "########"))
)
_, 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."""
)
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
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."""
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(
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;
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
from __future__ import annotations
import re
-from collections.abc import Iterable
from contextlib import suppress
from dataclasses import dataclass, field
from json import JSONDecodeError
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
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
LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
-SUPPORTED_FEATURES = tuple()
+SUPPORTED_FEATURES = ()
async def setup(
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]:
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
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
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
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:
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
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
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 (
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
)
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"
)
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
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,
},
album_type=AlbumType.PODCAST,
)
- return album
def _parse_podcast_episode(
self, sonic_episode: SonicPodcastEpisode, sonic_channel: SonicPodcastChannel
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)
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))
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:
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]:
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))
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]:
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})
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))
"""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:
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
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
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,
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"
) -> 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()
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
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
"""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:
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(
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,
)
},
)
- return artist
async def _parse(self, plex_media) -> MediaItem | None:
if plex_media.type == "artist":
"""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,
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 = [
: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 = {}
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,
)
)
"""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."""
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
"""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]
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)
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
else:
return None, None
- result = await asyncio.to_thread(_discover_local_servers)
- return result
+ return await asyncio.to_thread(_discover_local_servers)
import datetime
import hashlib
import time
-from collections.abc import AsyncGenerator
from json import JSONDecodeError
from typing import TYPE_CHECKING
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,
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
# 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,
),
)
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, ...]:
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."""
)
]
- 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
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(
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
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
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,
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
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):
# 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
)
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):
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
)
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
]
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)
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):
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
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):
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:
)
)
- 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
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)
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
# 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."""
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:
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",
import contextlib
import time
import urllib.parse
-from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
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,
)
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]
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]:
# 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.
"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:
# {
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(
{
**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,
# 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(
str(args),
str(kwargs),
)
+ return None
def _handle_time(self, player_id: str, number: str | int) -> int | None:
"""Handle player `time` command."""
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."""
# 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,
# <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,
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)
str(args),
str(kwargs),
)
+ return None
def _handle_button(
self,
):
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
elif isinstance(value, dict):
result += dict_to_strings(value)
else:
- result.append(f"{key}:{str(value)}")
+ result.append(f"{key}:{value!s}")
return result
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
style: str
track: str
album: str
- trackType: str # noqa: N815
+ trackType: str
icon: str
artist: str
text: str
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,
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
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 ."""
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)
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."""
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
"""
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)
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)
"""
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
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)
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."""
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."""
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 (
)
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
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
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",
)
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)
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)
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
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.
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
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:
await self.mass.create_task(do_discover)
- def reschedule():
+ def reschedule() -> None:
self._discovery_reschedule_timer = None
self.mass.create_task(self._run_discovery())
@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(
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
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}"
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
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]
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
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."""
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
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
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
) -> 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
# 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,
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(
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
"""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"],
"""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,
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
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(
# ---------------- 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}",
# 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
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
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,
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
# 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,
),
)
"""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."""
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)
# 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(
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"):
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:
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."""
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(
):
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)
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,
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
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):
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
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
from __future__ import annotations
import asyncio
-from collections.abc import AsyncGenerator, Awaitable, Callable
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
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,
)
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
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
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),
],
):
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
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,
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:
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:
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:
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."""
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."""
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)
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)
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)
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)
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)
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)
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."""
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)
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."""
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)
from __future__ import annotations
-from collections.abc import AsyncGenerator
from time import time
from typing import TYPE_CHECKING
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,
)
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
) -> 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):
# 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,
),
)
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":
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
return StreamDetails(
provider=self.instance_id,
item_id=item_id,
- content_type=ContentType.UNKNOWN,
audio_format=AudioFormat(
content_type=ContentType.UNKNOWN,
),
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):
from __future__ import annotations
import asyncio
-from collections.abc import Iterable
from typing import TYPE_CHECKING
import shortuuid
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
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):
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,
# 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
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."""
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(
if not group_player.powered:
# guard, this should be caught in the player controller but just in case...
- return
+ return None
powered_childs = [
x
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
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,
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
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):
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."""
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)
) -> 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
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"}
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."""
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."""
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."""
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."""
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."
)
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}"
) 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(
}
}
- 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:
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"],
)
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
)
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":
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
**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"]
]
# 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")
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:
):
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
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)
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():
"""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):
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):
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
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
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
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
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
# 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():
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
) -> 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)
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,
) -> 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:
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)
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",
# 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
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()
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 = [
"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
+++ /dev/null
-"""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)
+++ /dev/null
-#!/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())
+++ /dev/null
-"""
-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)
+++ /dev/null
-#!/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 "$@"
--- /dev/null
+"""Music Assistant scripts."""
--- /dev/null
+"""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)
--- /dev/null
+"""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())
--- /dev/null
+"""
+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)
--- /dev/null
+#!/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 "$@"
"""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)
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"
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")
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
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