From: Marcel van der Veldt Date: Wed, 14 Aug 2024 22:20:06 +0000 (+0200) Subject: Add demo/template providers (#1566) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=f59d12dbe3ddcb6491b3f42a22f60dd3c7e8be64;p=music-assistant-server.git Add demo/template providers (#1566) --- diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 37305ea9..e7672453 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -2,10 +2,11 @@ Developer docs ================================== ## 📝 Prerequisites -* ffmpeg (minimum version 4, version 5 recommended), must be available in the path so install at OS level +* ffmpeg (minimum version 5, version 6 recommended), must be available in the path so install at OS level * Python 3.11 is minimal required (or check the pyproject for current required version) * [Python venv](https://docs.python.org/3/library/venv.html) +We recommend developing on a (recent) MacOS or Linux machine. It is recommended to use Visual Studio Code as your IDE, since launch files to start Music Assistant are provided as part of the repository. Furthermore, the current code base is not verified to work on a native Windows machine. If you would like to develop on a Windows machine, install [WSL2](https://code.visualstudio.com/blogs/2019/09/03/wsl2) to increase your swag-level 🤘. ## 🚀 Setting up your development environment @@ -20,20 +21,20 @@ It is recommended to use Visual Studio Code as your IDE, since launch files to s ### Manually With this repository cloned locally, execute the following commands in a terminal from the root of your repository: -* `python -m venv .venv` (create a new separate virtual environment to nicely separate the project dependencies) -* `source .venv/bin/activate` (activate the virtual environment) -* `pip install -e .[test]` (install the project's dev and test dependencies) -* Hit (Fn +) F5 to start Music Assistant locally +* Run our development setup script to setup the development environment: +* `scripts/setup.sh` (creates a new separate virtual environment to nicely separate the project dependencies) +* The setup script will create a separate virtual environment (if needed), install all the project/test dependencies and configure pre-commit for linting and testing. +* Debug: Hit (Fn +) F5 to start Music Assistant locally * The pre-compiled UI of Music Assistant will be available at `localhost:8095` 🎉 -All code is linted and verified using [pre-commit](https://pre-commit.com/). To make sure that all these checks are executed successfully *before* you push your code: -* `pre-commit install` -This ensures that the pre-commit checks kick in when you create a commit locally. The Music Assistant server is fully built in Python. The Python language has no real supported for multi-threading. This is why Music Assistant heavily relies on asyncio to handle blocking IO. It is important to get a good understanding of asynchronous programming before building your first provider. [This](https://www.youtube.com/watch?v=M-UcUs7IMIM) video is an excellent first step in the world of asyncio. -## 🎵 Building your own Music Provider -A Music Provider is one of the provider types that adds support for a 'source of music' to Music Assistant. Spotify and Youtube Music are examples of a Music Provider, but also Filesystem and SMB can be put in the Music Provider category. All Music Providers can be found in the `music_assistant/server/providers` folder. +## Building a new Music Provider +A Music Provider is the provider type that adds support for a 'source of music' to Music Assistant. Spotify and Youtube Music are examples of a Music Provider, but also Filesystem and SMB can be put in the Music Provider category. All Providers (of all types) can be found in the `music_assistant/server/providers` folder. + +TIP: We have created a template/stub provider in `music_assistant/server/providers/_template_music_provider` to get you started fast! + **Adding the necessary files for a new Music Provider** @@ -60,7 +61,10 @@ Create a file called `__init__.py` inside the folder of your provider. This file ## ▶️ Building your own Player Provider -Will follow soon™ +A Player Provider is the provider type that adds support for a 'target of playback' to Music Assistant. Sonos, Chromecast and Airplay are examples of a Player Provider. +All Providers (of all types) can be found in the `music_assistant/server/providers` folder. + +TIP: We have created a template/stub provider in `music_assistant/server/providers/_template_player_provider` to get you started fast! ## 💽 Building your own Metadata Provider Will follow soon™ diff --git a/music_assistant/server/helpers/util.py b/music_assistant/server/helpers/util.py index b1be4202..31656ef9 100644 --- a/music_assistant/server/helpers/util.py +++ b/music_assistant/server/helpers/util.py @@ -19,12 +19,15 @@ from typing import TYPE_CHECKING, Self import ifaddr import memory_tempfile +from zeroconf import IPVersion from music_assistant.server.helpers.process import check_output if TYPE_CHECKING: from collections.abc import Iterator + from zeroconf.asyncio import AsyncServiceInfo + from music_assistant.server import MusicAssistant from music_assistant.server.models import ProviderModuleType @@ -135,6 +138,19 @@ def divide_chunks(data: bytes, chunk_size: int) -> Iterator[bytes]: yield data[i : i + chunk_size] +def get_primary_ip_address_from_zeroconf(discovery_info: AsyncServiceInfo) -> str | None: + """Get primary IP address from zeroconf discovery info.""" + for address in discovery_info.parsed_addresses(IPVersion.V4Only): + if address.startswith("127"): + # filter out loopback address + continue + if address.startswith("169.254"): + # filter out APIPA address + continue + return address + return None + + class TaskManager: """ Helper class to run many tasks at once. diff --git a/music_assistant/server/models/provider.py b/music_assistant/server/models/provider.py index 3e635863..a0e86503 100644 --- a/music_assistant/server/models/provider.py +++ b/music_assistant/server/models/provider.py @@ -47,6 +47,7 @@ class Provider: @property def lookup_key(self) -> str: """Return instance_id if multi_instance capable or domain otherwise.""" + # should not be overridden in normal circumstances return self.instance_id if self.manifest.multi_instance else self.domain async def loaded_in_mass(self) -> None: diff --git a/music_assistant/server/providers/_template_music_provider/__init__.py b/music_assistant/server/providers/_template_music_provider/__init__.py new file mode 100644 index 00000000..ee8a0cee --- /dev/null +++ b/music_assistant/server/providers/_template_music_provider/__init__.py @@ -0,0 +1,460 @@ +""" +DEMO/TEMPLATE Music Provider for Music Assistant. + +This is an empty music provider with no actual implementation. +Its meant to get started developing a new music provider for Music Assistant. + +Use it as a reference to discover what methods exists and what they should return. +Also it is good to look at existing music providers to get a better understanding, +due to the fact that providers may be flexible and support different features. + +If you are relying on a third-party library to interact with the music source, +you can then reference your library in the manifest in the requirements section, +which is a list of (versioned!) python modules (pip syntax) that should be installed +when the provider is selected by the user. + +Please keep in mind that Music Assistant is a fully async application and all +methods should be implemented as async methods. If you are not familiar with +async programming in Python, we recommend you to read up on it first. +If you are using a third-party library that is not async, you can need to use the several +helper methods such as asyncio.to_thread or the create_task in the mass object to wrap +the calls to the library in a thread. + +To add a new provider to Music Assistant, you need to create a new folder +in the providers folder with the name of your provider (e.g. 'my_music_provider'). +In that folder you should create (at least) a __init__.py file and a manifest.json file. + +Optional is an icon.svg file that will be used as the icon for the provider in the UI, +but we also support that you specify a material design icon in the manifest.json file. + +IMPORTANT NOTE: +We strongly recommend developing on either MacOS or Linux and start your development +environment by running the setup.sh script in the scripts folder of the repository. +This will create a virtual environment and install all dependencies needed for development. +See also our general DEVELOPMENT.md guide in the repository for more information. + +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Sequence +from typing import TYPE_CHECKING + +from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType +from music_assistant.common.models.enums import ContentType, MediaType, ProviderFeature, StreamType +from music_assistant.common.models.media_items import ( + Album, + Artist, + AudioFormat, + ItemMapping, + MediaItemType, + Playlist, + ProviderMapping, + Radio, + SearchResults, + Track, +) +from music_assistant.common.models.streamdetails import StreamDetails +from music_assistant.server.models.music_provider import MusicProvider + +if TYPE_CHECKING: + from music_assistant.common.models.config_entries import ProviderConfig + 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 +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + # setup is called when the user wants to setup a new provider instance. + # you are free to do any preflight checks here and but you must return + # an instance of the provider. + return MyDemoMusicprovider(mass, manifest, config) + + +async def get_config_entries( + mass: MusicAssistant, + instance_id: str | None = None, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """ + Return Config entries to setup this provider. + + instance_id: id of an existing provider instance (None if new instance setup). + action: [optional] action key called from config entries UI. + values: the (intermediate) raw values for config entries sent with the action. + """ + # ruff: noqa: ARG001 + # Config Entries are used to configure the Music Provider if needed. + # See the models of ConfigEntry and ConfigValueType for more information what is supported. + # The ConfigEntry is a dataclass that represents a single configuration entry. + # The ConfigValueType is an Enum that represents the type of value that + # can be stored in a ConfigEntry. + # If your provider does not need any configuration, you can return an empty tuple. + + # We support flow-like configuration where you can have multiple steps of configuration + # using the 'action' parameter to distinguish between the different steps. + # The 'values' parameter contains the raw values of the config entries that were filled in + # by the user in the UI. This is a dictionary with the key being the config entry id + # and the value being the actual value filled in by the user. + + # For authentication flows where the user needs to be redirected to a login page + # or some other external service, we have a simple helper that can help you with those steps + # and a callback url that you can use to redirect the user back to the Music Assistant UI. + # See for example the Deezer provider for an example of how to use this. + return () + + +class MyDemoMusicprovider(MusicProvider): + """ + Example/demo Music provider. + + Note that this is always subclassed from MusicProvider, + which in turn is a subclass of the generic Provider model. + + The base implementation already takes care of some convenience methods, + such as the mass object and the logger. Take a look at the base class + for more information on what is available. + + Just like with any other subclass, make sure that if you override + any of the default methods (such as __init__), you call the super() method. + In most cases its not needed to override any of the builtin methods and you only + implement the abc methods with your actual implementation. + """ + + @property + def supported_features(self) -> tuple[ProviderFeature, ...]: + """Return the features supported by this Provider.""" + # MANDATORY + # you should return a tuple of provider-level features + # here that your player provider supports or an empty tuple if none. + # for example 'ProviderFeature.SYNC_PLAYERS' if you can sync players. + return ( + ProviderFeature.BROWSE, + ProviderFeature.SEARCH, + ProviderFeature.RECOMMENDATIONS, + ProviderFeature.LIBRARY_ARTISTS, + ProviderFeature.LIBRARY_ALBUMS, + ProviderFeature.LIBRARY_TRACKS, + ProviderFeature.LIBRARY_PLAYLISTS, + ProviderFeature.ARTIST_ALBUMS, + ProviderFeature.ARTIST_TOPTRACKS, + ProviderFeature.LIBRARY_ARTISTS_EDIT, + ProviderFeature.LIBRARY_ALBUMS_EDIT, + ProviderFeature.LIBRARY_TRACKS_EDIT, + ProviderFeature.LIBRARY_PLAYLISTS_EDIT, + ProviderFeature.SIMILAR_TRACKS, + # see the ProviderFeature enum for all available features + ) + + async def loaded_in_mass(self) -> None: + """Call after the provider has been loaded.""" + # OPTIONAL + # this is an optional method that you can implement if + # relevant or leave out completely if not needed. + # In most cases this can be omitted for music providers. + + async def unload(self) -> None: + """ + Handle unload/close of the provider. + + Called when provider is deregistered (e.g. MA exiting or config reloading). + """ + # OPTIONAL + # This is an optional method that you can implement if + # relevant or leave out completely if not needed. + # It will be called when the provider is unloaded from Music Assistant. + # for example to disconnect from a service or clean up resources. + + @property + def is_streaming_provider(self) -> bool: + """ + Return True if the provider is a streaming provider. + + This literally means that the catalog is not the same as the library contents. + For local based providers (files, plex), the catalog is the same as the library content. + It also means that data is if this provider is NOT a streaming provider, + data cross instances is unique, the catalog and library differs per instance. + + Setting this to True will only query one instance of the provider for search and lookups. + Setting this to False will query all instances of this provider for search and lookups. + """ + # For streaming providers return True here but for local file based providers return False. + return True + + async def search( + self, + search_query: str, + media_types: list[MediaType], + limit: int = 5, + ) -> SearchResults: + """Perform search on musicprovider. + + :param search_query: Search query. + :param media_types: A list of media_types to include. + :param limit: Number of items to return in the search (per type). + """ + # OPTIONAL + # Will only be called if you reported the SEARCH feature in the supported_features. + # It allows searching your provider for media items. + # See the model for SearchResults for more information on what to return, but + # in general you should return a list of MediaItems for each media type. + + async def get_library_artists(self) -> AsyncGenerator[Artist, None]: + """Retrieve library artists from the provider.""" + # OPTIONAL + # Will only be called if you reported the LIBRARY_ARTISTS feature + # in the supported_features and you did not override the default sync method. + # It allows retrieving the library/favorite artists from your provider. + # Warning: Async generator: + # You should yield Artist objects for each artist in the library. + yield Artist( + # A simple example of an artist object, + # you should replace this with actual data from your provider. + # Explore the Artist model for all options and descriptions. + item_id="123", + provider=self.instance_id, + name="Artist Name", + provider_mappings={ + ProviderMapping( + # A provider mapping is used to provide details about this item on this provider + # Music Assistant differentiates between domain and instance id to account for + # multiple instances of the same provider. + # The instance_id is auto generated by MA. + item_id="123", + provider_domain=self.domain, + provider_instance=self.instance_id, + # set 'available' to false if the item is (temporary) unavailable + available=True, + audio_format=AudioFormat( + # provide details here about sample rate etc. if known + content_type=ContentType.FLAC, + ), + ) + }, + ) + + async def get_library_albums(self) -> AsyncGenerator[Album, None]: + """Retrieve library albums from the provider.""" + # OPTIONAL + # Will only be called if you reported the LIBRARY_ALBUMS feature + # in the supported_features and you did not override the default sync method. + # It allows retrieving the library/favorite albums from your provider. + # Warning: Async generator: + # You should yield Album objects for each album in the library. + yield # type: ignore + + async def get_library_tracks(self) -> AsyncGenerator[Track, None]: + """Retrieve library tracks from the provider.""" + # OPTIONAL + # Will only be called if you reported the LIBRARY_TRACKS feature + # in the supported_features and you did not override the default sync method. + # It allows retrieving the library/favorite tracks from your provider. + # Warning: Async generator: + # You should yield Track objects for each track in the library. + yield # type: ignore + + async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: + """Retrieve library/subscribed playlists from the provider.""" + # OPTIONAL + # Will only be called if you reported the LIBRARY_PLAYLISTS feature + # in the supported_features and you did not override the default sync method. + # It allows retrieving the library/favorite playlists from your provider. + # Warning: Async generator: + # You should yield Playlist objects for each playlist in the library. + yield # type: ignore + + async def get_library_radios(self) -> AsyncGenerator[Radio, None]: + """Retrieve library/subscribed radio stations from the provider.""" + # OPTIONAL + # Will only be called if you reported the LIBRARY_RADIOS feature + # in the supported_features and you did not override the default sync method. + # It allows retrieving the library/favorite radio stations from your provider. + # Warning: Async generator: + # You should yield Radio objects for each radio station in the library. + yield + + async def get_artist(self, prov_artist_id: str) -> Artist: + """Get full artist details by id.""" + # Get full details of a single Artist. + # Mandatory only if you reported LIBRARY_ARTISTS in the supported_features. + + async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: + """Get a list of all albums for the given artist.""" + # Get a list of all albums for the given artist. + # Mandatory only if you reported ARTIST_ALBUMS in the supported_features. + + async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: + """Get a list of most popular tracks for the given artist.""" + # Get a list of most popular tracks for the given artist. + # Mandatory only if you reported ARTIST_TOPTRACKS in the supported_features. + # Note that (local) file based providers will simply return all artist tracks here. + + async def get_album(self, prov_album_id: str) -> Album: # type: ignore[return] + """Get full album details by id.""" + # Get full details of a single Album. + # Mandatory only if you reported LIBRARY_ALBUMS in the supported_features. + + async def get_track(self, prov_track_id: str) -> Track: # type: ignore[return] + """Get full track details by id.""" + # Get full details of a single Track. + # Mandatory only if you reported LIBRARY_TRACKS in the supported_features. + + async def get_playlist(self, prov_playlist_id: str) -> Playlist: # type: ignore[return] + """Get full playlist details by id.""" + # Get full details of a single Playlist. + # Mandatory only if you reported LIBRARY_PLAYLISTS in the supported + + async def get_radio(self, prov_radio_id: str) -> Radio: # type: ignore[return] + """Get full radio details by id.""" + # Get full details of a single Radio station. + # Mandatory only if you reported LIBRARY_RADIOS in the supported_features. + + async def get_album_tracks( + self, + prov_album_id: str, # type: ignore[return] + ) -> list[Track]: + """Get album tracks for given album id.""" + # Get all tracks for a given album. + # Mandatory only if you reported ARTIST_ALBUMS in the supported_features. + + async def get_playlist_tracks( + self, + prov_playlist_id: str, + page: int = 0, + ) -> list[Track]: + """Get all playlist tracks for given playlist id.""" + # Get all tracks for a given playlist. + # Mandatory only if you reported LIBRARY_PLAYLISTS in the supported_features. + + async def library_add(self, item: MediaItemType) -> bool: + """Add item to provider's library. Return true on success.""" + # Add an item to your provider's library. + # This is only called if the provider supports the EDIT feature for the media type. + return True + + async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: + """Remove item from provider's library. Return true on success.""" + # Remove an item from your provider's library. + # This is only called if the provider supports the EDIT feature for the media type. + return True + + async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None: + """Add track(s) to playlist.""" + # Add track(s) to a playlist. + # This is only called if the provider supports the PLAYLIST_TRACKS_EDIT feature. + + async def remove_playlist_tracks( + self, prov_playlist_id: str, positions_to_remove: tuple[int, ...] + ) -> None: + """Remove track(s) from playlist.""" + # Remove track(s) from a playlist. + # This is only called if the provider supports the EDPLAYLIST_TRACKS_EDITIT feature. + + async def create_playlist(self, name: str) -> Playlist: # type: ignore[return] + """Create a new playlist on provider with given name.""" + # Create a new playlist on the provider. + # This is only called if the provider supports the PLAYLIST_CREATE feature. + + async def get_similar_tracks( # type: ignore[return] + self, prov_track_id: str, limit: int = 25 + ) -> list[Track]: + """Retrieve a dynamic list of similar tracks based on the provided track.""" + # Get a list of similar tracks based on the provided track. + # This is only called if the provider supports the SIMILAR_TRACKS feature. + + async def get_stream_details(self, item_id: str) -> StreamDetails: + """Get streamdetails for a track/radio.""" + # Get stream details for a track or radio. + # Implementing this method is MANDATORY to allow playback. + # The StreamDetails contain info how Music Assistant can play the track. + # item_id will always be a track or radio id. Later, when/if MA supports + # podcasts or audiobooks, this may as well be an episode or chapter id. + # You should return a StreamDetails object here with the info as accurate as possible + # to allow Music Assistant to process the audio using ffmpeg. + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=AudioFormat( + # provide details here about sample rate etc. if known + # set content type to unknown to let ffmpeg guess the codec/container + content_type=ContentType.UNKNOWN, + ), + media_type=MediaType.TRACK, + # streamtype defines how the stream is provided + # for most providers this will be HTTP but you can also use CUSTOM + # to provide a custom stream generator in get_audio_stream. + stream_type=StreamType.HTTP, + # explore the StreamDetails model and StreamType enum for more options + # but the above should be the mandatory fields to set. + ) + + async def get_audio_stream( # type: ignore[return] + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """ + Return the (custom) audio stream for the provider item. + + Will only be called when the stream_type is set to CUSTOM. + """ + # this is an async generator that should yield raw audio bytes + # for the given streamdetails. You can use this to provide a custom + # stream generator for the audio stream. This is only called when the + # stream_type is set to CUSTOM in the get_stream_details method. + yield # type: ignore + + async def on_streamed(self, streamdetails: StreamDetails, seconds_streamed: int) -> None: + """Handle callback when an item completed streaming.""" + # This is OPTIONAL callback that is called when an item has been streamed. + # You can use this e.g. for playback reporting or statistics. + + async def resolve_image(self, path: str) -> str | bytes: + """ + Resolve an image from an image path. + + This either returns (a generator to get) raw bytes of the image or + a string with an http(s) URL or local path that is accessible from the server. + """ + # This is an OPTIONAL method that you can implement to resolve image paths. + # This is used to resolve image paths that are returned in the MediaItems. + # You can return a URL to an image or a generator that yields the raw bytes of the image. + # This will only be called when you set 'remotely_accessible' + # to false in a MediaItemImage object. + return path + + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping]: + """Browse this provider's items. + + :param path: The path to browse, (e.g. provider_id://artists). + """ + # Browse your provider's recommendations/media items. + # This is only called if you reported the BROWSE feature in the supported_features. + # You should return a list of MediaItems or ItemMappings for the given path. + # Note that you can return nested levels with BrowseFolder items. + + # The MusicProvider base model has a default implementation of this method + # that will call the get_library_* methods if you did not override it. + return [] + + async def recommendations(self) -> list[MediaItemType]: + """Get this provider's recommendations. + + Returns a actual and personalised list of Media items with recommendations + form this provider for the user/account. It may return nested levels with + BrowseFolder items. + """ + # Get this provider's recommendations. + # This is only called if you reported the RECOMMENDATIONS feature in the supported_features. + return [] + + async def sync_library(self, media_types: tuple[MediaType, ...]) -> None: + """Run library sync for this provider.""" + # Run a full sync of the library for the given media types. + # This is called by the music controller to sync items from your provider to the library. + # As a generic rule of thumb the default implementation within the MusicProvider + # base model should be sufficient for most (streaming) providers. + # If you need to do some custom sync logic, you can override this method. + # For example the filesystem provider in MA, overrides this method to scan the filesystem. diff --git a/music_assistant/server/providers/_template_music_provider/icon.svg b/music_assistant/server/providers/_template_music_provider/icon.svg new file mode 100644 index 00000000..845920ca --- /dev/null +++ b/music_assistant/server/providers/_template_music_provider/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/music_assistant/server/providers/_template_music_provider/manifest.json b/music_assistant/server/providers/_template_music_provider/manifest.json new file mode 100644 index 00000000..15d6b83a --- /dev/null +++ b/music_assistant/server/providers/_template_music_provider/manifest.json @@ -0,0 +1,10 @@ +{ + "type": "music", + "domain": "template_player_provider", + "name": "Name of the Player provider goes here", + "description": "Short description of the player provider goes here", + "codeowners": ["@yourgithubusername"], + "requirements": [], + "documentation": "Link to the documentation on the music-assistant.io helppage (may be added later).", + "mdns_discovery": ["_optional_mdns_service_type._tcp.local."] +} diff --git a/music_assistant/server/providers/_template_player_provider/__init__.py b/music_assistant/server/providers/_template_player_provider/__init__.py new file mode 100644 index 00000000..268247bf --- /dev/null +++ b/music_assistant/server/providers/_template_player_provider/__init__.py @@ -0,0 +1,389 @@ +""" +DEMO/TEMPLATE Player Provider for Music Assistant. + +This is an empty player provider with no actual implementation. +Its meant to get started developing a new player provider for Music Assistant. + +Use it as a reference to discover what methods exists and what they should return. +Also it is good to look at existing player providers to get a better understanding, +due to the fact that providers may be flexible and support different features and/or +ways to discover players on the network. + +In general, the actual device communication should reside in a separate library. +You can then reference your library in the manifest in the requirements section, +which is a list of (versioned!) python modules (pip syntax) that should be installed +when the provider is selected by the user. + +To add a new player provider to Music Assistant, you need to create a new folder +in the providers folder with the name of your provider (e.g. 'my_player_provider'). +In that folder you should create (at least) a __init__.py file and a manifest.json file. + +Optional is an icon.svg file that will be used as the icon for the provider in the UI, +but we also support that you specify a material design icon in the manifest.json file. + +IMPORTANT NOTE: +We strongly recommend developing on either MacOS or Linux and start your development +environment by running the setup.sh scripts in the scripts folder of the repository. +This will create a virtual environment and install all dependencies needed for development. +See also our general DEVELOPMENT.md guide in the repository for more information. + +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from zeroconf import ServiceStateChange + +from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType, PlayerConfig +from music_assistant.common.models.enums import PlayerFeature, PlayerType, ProviderFeature +from music_assistant.common.models.player import DeviceInfo, Player, PlayerMedia +from music_assistant.server.helpers.util import get_primary_ip_address_from_zeroconf +from music_assistant.server.models.player_provider import PlayerProvider + +if TYPE_CHECKING: + from zeroconf.asyncio import AsyncServiceInfo + + from music_assistant.common.models.config_entries import ProviderConfig + 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 +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + # setup is called when the user wants to setup a new provider instance. + # you are free to do any preflight checks here and but you must return + # an instance of the provider. + return MyDemoPlayerprovider(mass, manifest, config) + + +async def get_config_entries( + mass: MusicAssistant, + instance_id: str | None = None, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """ + Return Config entries to setup this provider. + + instance_id: id of an existing provider instance (None if new instance setup). + action: [optional] action key called from config entries UI. + values: the (intermediate) raw values for config entries sent with the action. + """ + # ruff: noqa: ARG001 + # Config Entries are used to configure the Player Provider if needed. + # See the models of ConfigEntry and ConfigValueType for more information what is supported. + # The ConfigEntry is a dataclass that represents a single configuration entry. + # The ConfigValueType is an Enum that represents the type of value that + # can be stored in a ConfigEntry. + # If your provider does not need any configuration, you can return an empty tuple. + return () + + +class MyDemoPlayerprovider(PlayerProvider): + """ + Example/demo Player provider. + + Note that this is always subclassed from PlayerProvider, + which in turn is a subclass of the generic Provider model. + + The base implementation already takes care of some convenience methods, + such as the mass object and the logger. Take a look at the base class + for more information on what is available. + + Just like with any other subclass, make sure that if you override + any of the default methods (such as __init__), you call the super() method. + In most cases its not needed to override any of the builtin methods and you only + implement the abc methods with your actual implementation. + """ + + @property + def supported_features(self) -> tuple[ProviderFeature, ...]: + """Return the features supported by this Provider.""" + # MANDATORY + # you should return a tuple of provider-level features + # here that your player provider supports or an empty tuple if none. + # for example 'ProviderFeature.SYNC_PLAYERS' if you can sync players. + return (ProviderFeature.SYNC_PLAYERS,) + + async def loaded_in_mass(self) -> None: + """Call after the provider has been loaded.""" + # OPTIONAL + # this is an optional method that you can implement if + # relevant or leave out completely if not needed. + # it will be called after the provider has been fully loaded into Music Assistant. + # you can use this for instance to trigger custom (non-mdns) discovery of players + # or any other logic that needs to run after the provider is fully loaded. + + async def unload(self) -> None: + """ + Handle unload/close of the provider. + + Called when provider is deregistered (e.g. MA exiting or config reloading). + """ + # OPTIONAL + # this is an optional method that you can implement if + # relevant or leave out completely if not needed. + # it will be called when the provider is unloaded from Music Assistant. + # this means also when the provider is getting reloaded + + async def on_mdns_service_state_change( + self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None + ) -> None: + """Handle MDNS service state callback.""" + # MANDATORY IF YOU WANT TO USE MDNS DISCOVERY + # OPTIONAL if you dont use mdns for discovery of players + # If you specify a mdns service type in the manifest.json, this method will be called + # automatically on mdns changes for the specified service type. + + # If no mdns service type is specified, this method is omitted and you + # can completely remove it from your provider implementation. + + # NOTE: If you do not use mdns for discovery of players on the network, + # you must implement your own discovery mechanism and logic to add new players + # and update them on state changes when needed. + # Below is a bit of example implementation but we advise to look at existing + # player providers for more inspiration. + name = name.split("@", 1)[1] if "@" in name else name + player_id = info.decoded_properties["uuid"] # this is just an example! + # handle removed player + if state_change == ServiceStateChange.Removed: + # check if the player manager has an existing entry for this player + if mass_player := self.mass.players.get(player_id): + # the player has become unavailable + self.logger.debug("Player offline: %s", mass_player.display_name) + mass_player.available = False + self.mass.players.update(player_id) + return + # handle update for existing device + # (state change is either updated or added) + # check if we have an existing player in the player manager + # note that you can use this point to update the player connection info + # if that changed (e.g. ip address) + if mass_player := self.mass.players.get(player_id): + # existing player found in the player manager, + # this is an existing player that has been updated/reconnected + # or simply a re-announcement on mdns. + cur_address = get_primary_ip_address_from_zeroconf(info) + if cur_address and cur_address != mass_player.device_info.address: + self.logger.debug( + "Address updated to %s for player %s", cur_address, mass_player.display_name + ) + mass_player.device_info = DeviceInfo( + model=mass_player.device_info.model, + manufacturer=mass_player.device_info.manufacturer, + address=str(cur_address), + ) + if not mass_player.available: + # if the player was marked offline and you now receive an mdns update + # it means the player is back online and we should try to connect to it + self.logger.debug("Player back online: %s", mass_player.display_name) + # you can try to connect to the player here if needed + mass_player.available = True + # inform the player manager of any changes to the player object + # note that you would normally call this from some other callback from + # the player's native api/library which informs you of changes in the player state. + # as a last resort you can also choose to let the player manager + # poll the player for state changes + self.mass.players.update(player_id) + return + # handle new player + self.logger.debug("Discovered device %s on %s", name, cur_address) + # your own connection logic will probably be implemented here where + # you connect to the player etc. using your device/provider specific library. + + # Instantiate the MA Player object and register it with the player manager + mass_player = Player( + player_id=player_id, + provider=self.instance_id, + type=PlayerType.PLAYER, + name=name, + available=True, + powered=False, + device_info=DeviceInfo( + model="Model XYX", + manufacturer="Super Brand", + address=cur_address, + ), + # set the supported features for this player only with + # the ones the player actually supports + supported_features=( + PlayerFeature.POWER, # if the player can be turned on/off + PlayerFeature.VOLUME_SET, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.PLAY_ANNOUNCEMENT, # see play_announcement method + PlayerFeature.ENQUEUE_NEXT, # see play_media/enqueue_next_media methods + ), + ) + # register the player with the player manager + self.mass.players.register(mass_player) + + # once the player is registered, you can either instruct the player manager to + # poll the player for state changes or you can implement your own logic to + # listen for state changes from the player and update the player object accordingly. + # in any case, you need to call the update method on the player manager: + self.mass.players.update(player_id) + + 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).""" + # OPTIONAL + # this method is optional and should be implemented if you need player specific + # configuration entries. If you do not need player specific configuration entries, + # you can leave this method out completely to accept the default implementation. + # Please note that you need to call the super() method to get the default entries. + return () + + def on_player_config_changed(self, config: PlayerConfig, changed_keys: set[str]) -> None: + """Call (by config manager) when the configuration of a player changes.""" + # OPTIONAL + # this callback will be called whenever a player config changes + # you can use this to react to changes in player configuration + # but this is completely optional and you can leave it out if not needed. + + def on_player_config_removed(self, player_id: str) -> None: + """Call (by config manager) when the configuration of a player is removed.""" + # OPTIONAL + # ensure that any group players get removed + # this callback will be called whenever a player config is removed + # you can use this to react to changes in player configuration + # but this is completely optional and you can leave it out if not needed. + + async def cmd_stop(self, player_id: str) -> None: + """Send STOP command to given player.""" + # MANDATORY + # this method is mandatory and should be implemented. + # this method should send a stop command to the given player. + + async def cmd_play(self, player_id: str) -> None: + """Send PLAY command to given player.""" + # MANDATORY + # this method is mandatory and should be implemented. + # this method should send a play command to the given player. + + async def cmd_pause(self, player_id: str) -> None: + """Send PAUSE command to given player.""" + # OPTIONAL - required only if you specified PlayerFeature.PAUSE + # this method should send a pause command to the given player. + + async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + # OPTIONAL - required only if you specified PlayerFeature.VOLUME_SET + # this method should send a volume set command to the given player. + + async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: + """Send VOLUME MUTE command to given player.""" + # OPTIONAL - required only if you specified PlayerFeature.VOLUME_MUTE + # this method should send a volume mute command to the given player. + + async def cmd_seek(self, player_id: str, position: int) -> None: + """Handle SEEK command for given queue. + + - player_id: player_id of the player to handle the command. + - position: position in seconds to seek to in the current playing item. + """ + # OPTIONAL - required only if you specified PlayerFeature.SEEK + # this method should handle the seek command for the given player. + # the position is the position in seconds to seek to in the current playing item. + + async def play_media( + self, + player_id: str, + media: PlayerMedia, + ) -> None: + """Handle PLAY MEDIA on given player. + + This is called by the Players controller to start playing a mediaitem on the given player. + The provider's own implementation should work out how to handle this request. + + - player_id: player_id of the player to handle the command. + - media: Details of the item that needs to be played on the player. + """ + # MANDATORY + # this method is mandatory and should be implemented. + # this method should handle the play_media command for the given player. + # It will be called when media needs to be played on the player. + # The media object contains all the details needed to play the item. + + # In 99% of the cases this will be called by the Queue controller to play + # a single item from the queue on the player and the uri within the media + # object will then contain the URL to play that single queue item. + + # If your player provider does not support enqueuing of items, + # the queue controller will simply call this play_media method for + # each item in the queue to play them one by one. + + # In order to support true gapless and/or crossfade, we offer the option of + # 'flow_mode' playback. In that case the queue controller will stitch together + # all songs in the playback queue into a single stream and send that to the player. + # In that case the URI (and metadata) received here is that of the 'flow mode' stream. + + # Examples of player providers that use flow mode for playback by default are Airplay, + # SnapCast and Fully Kiosk. + + # Examples of player providers that optionally use 'flow mode' are Google Cast and + # Home Assistant. They provide a config entry to enable flow mode playback. + + # Examples of player providers that natively support enqueuing of items are Sonos, + # Slimproto and Google Cast. + + async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: + """ + Handle enqueuing of the next (queue) item on the player. + + Only called if the player supports PlayerFeature.ENQUE_NEXT. + Called about 1 second after a new track started playing. + Called about 15 seconds before the end of the current track. + + A PlayerProvider implementation is in itself responsible for handling this + so that the queue items keep playing until its empty or the player stopped. + + This will NOT be called if the end of the queue is reached (and repeat disabled). + This will NOT be called if the player is using flow mode to playback the queue. + """ + # OPTIONAL - required only if you specified PlayerFeature.ENQUEUE_NEXT + # this method should handle the enqueuing of the next queue item on the player. + + async def cmd_sync(self, player_id: str, target_player: str) -> None: + """Handle SYNC command for given player. + + Join/add the given player(id) to the given (master) player/sync group. + + - player_id: player_id of the player to handle the command. + - target_player: player_id of the syncgroup master or group player. + """ + # OPTIONAL - required only if you specified ProviderFeature.SYNC_PLAYERS + # this method should handle the sync command for the given player. + # you should join the given player to the target_player/syncgroup. + + async def cmd_unsync(self, player_id: str) -> None: + """Handle UNSYNC command for given player. + + Remove the given player from any syncgroups it currently is synced to. + + - player_id: player_id of the player to handle the command. + """ + sonos_player = self.sonos_players[player_id] + await sonos_player.client.player.leave_group() + + async def play_announcement( + self, player_id: str, announcement: PlayerMedia, volume_level: int | None = None + ) -> None: + """Handle (provider native) playback of an announcement on given player.""" + # OPTIONAL - required only if you specified PlayerFeature.PLAY_ANNOUNCEMENT + # This method should handle the playback of an announcement on the given player. + # The announcement object contains all the details needed to play the announcement. + # The volume_level is optional and can be used to set the volume level for the announcement. + # If you do not use the announcement playerfeature, the default behavior is to play the + # announcement as a regular media item using the play_media method and the MA player manager + # will take care of setting the volume level for the announcement and resuming etc. + + async def poll_player(self, player_id: str) -> None: + """Poll player for state updates.""" + # OPTIONAL + # This method is optional and should be implemented if you specified 'needs_poll' + # on the Player object. This method should poll the player for state changes + # and update the player object in the player manager if needed. + # This method will be called at the interval specified in the poll_interval attribute. diff --git a/music_assistant/server/providers/_template_player_provider/icon.svg b/music_assistant/server/providers/_template_player_provider/icon.svg new file mode 100644 index 00000000..845920ca --- /dev/null +++ b/music_assistant/server/providers/_template_player_provider/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/music_assistant/server/providers/_template_player_provider/manifest.json b/music_assistant/server/providers/_template_player_provider/manifest.json new file mode 100644 index 00000000..15d6b83a --- /dev/null +++ b/music_assistant/server/providers/_template_player_provider/manifest.json @@ -0,0 +1,10 @@ +{ + "type": "music", + "domain": "template_player_provider", + "name": "Name of the Player provider goes here", + "description": "Short description of the player provider goes here", + "codeowners": ["@yourgithubusername"], + "requirements": [], + "documentation": "Link to the documentation on the music-assistant.io helppage (may be added later).", + "mdns_discovery": ["_optional_mdns_service_type._tcp.local."] +} diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index e052de0e..51334ef8 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -658,6 +658,8 @@ class MusicAssistant: async with TaskManager(self) as tg: for dir_str in os.listdir(PROVIDERS_PATH): + if dir_str.startswith(("_", ".")): + continue dir_path = os.path.join(PROVIDERS_PATH, dir_str) if dir_str == "test" and not ENABLE_DEBUG: continue