Add nugs.net music provider (#1974)
authorbrian10048 <brian@mcmpest.com>
Thu, 24 Apr 2025 22:31:43 +0000 (18:31 -0400)
committerGitHub <noreply@github.com>
Thu, 24 Apr 2025 22:31:43 +0000 (00:31 +0200)
music_assistant/providers/nugs/__init__.py [new file with mode: 0644]
music_assistant/providers/nugs/icon.svg [new file with mode: 0644]
music_assistant/providers/nugs/manifest.json [new file with mode: 0644]

diff --git a/music_assistant/providers/nugs/__init__.py b/music_assistant/providers/nugs/__init__.py
new file mode 100644 (file)
index 0000000..51a59fc
--- /dev/null
@@ -0,0 +1,497 @@
+"""Nugs.net musicprovider support for MusicAssistant."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+from datetime import UTC, datetime
+from time import time
+from typing import TYPE_CHECKING, Any
+
+from aiohttp import ClientTimeout
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
+from music_assistant_models.enums import (
+    ConfigEntryType,
+    ContentType,
+    ImageType,
+    MediaType,
+    ProviderFeature,
+    StreamType,
+)
+from music_assistant_models.errors import (
+    InvalidDataError,
+    LoginFailed,
+    MediaNotFoundError,
+    ResourceTemporarilyUnavailable,
+)
+from music_assistant_models.media_items import (
+    Album,
+    Artist,
+    AudioFormat,
+    ItemMapping,
+    MediaItemImage,
+    MediaItemMetadata,
+    Playlist,
+    ProviderMapping,
+    Track,
+    UniqueList,
+)
+from music_assistant_models.streamdetails import StreamDetails
+
+from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
+from music_assistant.helpers.json import json_loads
+from music_assistant.models.music_provider import MusicProvider
+
+if TYPE_CHECKING:
+    from music_assistant_models.config_entries import ProviderConfig
+    from music_assistant_models.provider import ProviderManifest
+
+    from music_assistant.mass import MusicAssistant
+    from music_assistant.models import ProviderInstanceType
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = NugsProvider(mass, manifest, config)
+    await prov.handle_async_init()
+    return prov
+
+
+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
+    return (
+        ConfigEntry(
+            key=CONF_USERNAME,
+            type=ConfigEntryType.STRING,
+            label="Username",
+            required=True,
+        ),
+        ConfigEntry(
+            key=CONF_PASSWORD,
+            type=ConfigEntryType.SECURE_STRING,
+            label="Password",
+            required=True,
+        ),
+    )
+
+
+class NugsProvider(MusicProvider):
+    """Provider implementation for Nugs.net."""
+
+    _auth_token: str | None = None
+    _token_expiry: float = 0
+
+    @property
+    def supported_features(self) -> set[ProviderFeature]:
+        """Return the features supported by this Provider."""
+        return {
+            ProviderFeature.BROWSE,
+            ProviderFeature.LIBRARY_ARTISTS,
+            ProviderFeature.LIBRARY_ALBUMS,
+            ProviderFeature.LIBRARY_PLAYLISTS,
+            ProviderFeature.ARTIST_ALBUMS,
+        }
+
+    async def handle_async_init(self) -> None:
+        """Handle async initialization of the provider."""
+        await self.login()
+
+    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
+        """Retrieve library artists from nugs.net."""
+        artist_data = await self._get_all_items("stash", "artists/favorite/")
+        for item in artist_data:
+            if item and item["id"]:
+                yield self._parse_artist(item)
+
+    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
+        """Retrieve library albums from the provider."""
+        album_data = await self._get_all_items("stash", "releases/favorite")
+        for item in album_data:
+            if item and item["id"]:
+                yield self._parse_album(item)
+
+    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
+        """Retrieve playlists from the provider."""
+        playlist_data = await self._get_all_items("stash", "playlists/")
+        for item in playlist_data:
+            if item and item["id"]:
+                yield self._parse_playlist(item)
+
+    async def get_artist(self, prov_artist_id: str) -> Artist:
+        """Get artist details by id."""
+        endpoint = f"/releases/recent?limit=1&artistIds={prov_artist_id}"
+        artist_response = await self._get_data("catalog", endpoint)
+        artist_data = artist_response["items"][0]["artist"]
+        return self._parse_artist(artist_data)
+
+    async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
+        """Get a list of all albums for the given artist."""
+        params = {
+            "artistIds": prov_artist_id,
+            "contentType": "any",
+        }
+        return [
+            self._parse_album(item)
+            for item in await self._get_all_items("catalog", "releases/recent", **params)
+            if (item and item["id"])
+        ]
+
+    async def get_album(self, prov_album_id: str) -> Album:
+        """Get album details by id."""
+        endpoint = f"shows/{prov_album_id}"
+        response = await self._get_data("catalog", endpoint)
+        return self._parse_album(response["Response"])
+
+    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
+        """Get full playlist details by id."""
+        endpoint = f"playlists/{prov_playlist_id}"
+        response = await self._get_data("stash", endpoint)
+        return self._parse_playlist(response["items"])
+
+    async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
+        """Get all album tracks for given album id."""
+        endpoint = f"shows/{prov_album_id}"
+        response = await self._get_data("catalog", endpoint)
+        album_data = response["Response"]
+        artist = await self.get_artist(album_data["artistID"])
+        album = self._get_item_mapping(
+            MediaType.ALBUM, album_data["containerID"], album_data["containerInfo"]
+        )
+        image = f"https://api.livedownloads.com{album_data['img']['url']}"
+        return [
+            self._parse_track(item, artist=artist, album=album, image_url=image)
+            for item in album_data["tracks"]
+            if item["trackID"]
+        ]
+
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
+        """Get playlist tracks."""
+        result: list[Track] = []
+        if page > 0:
+            # paging not yet supported
+            return []
+        endpoint = f"/playlists/{prov_playlist_id}/playlist-tracks/all"
+        nugs_result = await self._get_data("stash", endpoint)
+        for index, item in enumerate(nugs_result["items"], 1):
+            track = self._parse_track(item)
+            track.position = index
+            result.append(track)
+        return result
+
+    async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
+        """Return the content details for the given track when it will be streamed."""
+        stream_url = await self._get_stream_url(item_id)
+        return StreamDetails(
+            item_id=item_id,
+            provider=self.lookup_key,
+            audio_format=AudioFormat(
+                content_type=ContentType.UNKNOWN,
+            ),
+            stream_type=StreamType.HTTP,
+            path=stream_url,
+        )
+
+    def _parse_artist(self, artist_obj: dict[str, Any]) -> Artist:
+        """Parse nugs artist object to generic layout."""
+        artist_id = artist_obj.get("artistID") or artist_obj.get("id")
+        artist_name = artist_obj.get("artistName") or artist_obj.get("name")
+        artist = Artist(
+            item_id=str(artist_id),
+            provider=self.lookup_key,
+            name=str(artist_name),
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(artist_id),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=f"https://catalog.nugs.net/api/v1/artists?ids={artist_id}",
+                )
+            },
+        )
+        if artist_obj.get("avatarImage"):
+            artist.metadata.add_image(
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=artist_obj["avatarImage"]["url"],
+                    provider=self.lookup_key,
+                    remotely_accessible=True,
+                )
+            )
+        return artist
+
+    def _parse_album(self, album_obj: dict[str, Any]) -> Album:
+        """Parse nugs release/show/album object to generic album layout."""
+        item_id = album_obj.get("releaseId") or album_obj.get("id") or album_obj.get("containerID")
+        title = album_obj.get("title") or album_obj.get("containerInfo")
+        album = Album(
+            item_id=str(item_id),
+            provider=self.lookup_key,
+            name=str(title),
+            # version=album_obj["type"],
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(item_id),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
+        )
+
+        artist_obj = album_obj.get("artist", False) or {
+            "id": album_obj["artistID"],
+            "name": album_obj["artistName"],
+        }
+        if artist_obj.get("name") and artist_obj.get("id"):
+            album.artists.append(self._parse_artist(artist_obj))
+
+        path: str | None = None
+        if album_obj.get("image"):
+            path = album_obj["image"]["url"]
+        if album_obj.get("img"):
+            path = f"https://api.livedownloads.com{album_obj['img']['url']}"
+        if path:
+            album.metadata.add_image(
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=path,
+                    provider=self.lookup_key,
+                    remotely_accessible=True,
+                )
+            )
+        year = album_obj.get("performanceDateYear", False)
+        if not year:
+            date = album_obj.get("performanceDate", False) or album_obj.get(
+                "albumreleaseDate", False
+            )
+            if date:
+                year = date.split("-")[0]
+        if year:
+            album.year = int(year)
+
+        return album
+
+    def _parse_playlist(self, playlist_obj: dict[str, Any]) -> Playlist:
+        """Parse nugs playlist object to generic layout."""
+        return Playlist(
+            item_id=playlist_obj["id"],
+            provider=self.lookup_key,
+            name=playlist_obj["name"],
+            provider_mappings={
+                ProviderMapping(
+                    item_id=playlist_obj["id"],
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
+            metadata=MediaItemMetadata(
+                images=UniqueList(
+                    [
+                        MediaItemImage(
+                            type=ImageType.THUMB,
+                            path=playlist_obj["imageUrl"],
+                            provider=self.lookup_key,
+                            remotely_accessible=True,
+                        )
+                    ]
+                ),
+            ),
+            is_editable=False,
+        )
+
+    def _parse_track(
+        self,
+        track_obj: dict[str, Any],
+        artist: Artist | None = None,
+        album: Album | ItemMapping | None = None,
+        image_url: str | None = None,
+    ) -> Track:
+        """Parse response from inconsistent nugs.net APIs to a Track model object."""
+        track_id = (
+            track_obj.get("trackId") or track_obj.get("trackID") or track_obj.get("trackLabel")
+        )
+        track_name = track_obj.get("name") or track_obj.get("songTitle")
+
+        track = Track(
+            item_id=str(track_id),
+            provider=self.lookup_key,
+            name=str(track_name),
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(track_id),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    available=True,
+                )
+            },
+        )
+
+        if artist:
+            track.artists.append(artist)
+        if (
+            track_obj.get("artist")
+            and isinstance(track_obj.get("artist"), dict)
+            and track_obj["artist"].get("id")
+        ):
+            track.artists.append(
+                self._get_item_mapping(
+                    MediaType.ARTIST, track_obj["artist"]["id"], track_obj["artist"]["name"]
+                )
+            )
+        if not track.artists:
+            msg = "Track is missing artists"
+            raise InvalidDataError(msg)
+
+        if album:
+            track.album = album
+        if image_url is None and track_obj.get("image"):
+            image_url = track_obj["image"]["url"]
+        if image_url:
+            track.metadata.add_image(
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=image_url,
+                    provider=self.lookup_key,
+                    remotely_accessible=True,
+                )
+            )
+        duration = track_obj.get("durationSeconds") or track_obj.get("totalRunningTime")
+        if duration:
+            track.duration = int(duration)
+        return track
+
+    async def _get_stream_url(self, item_id: str) -> Any:
+        subscription_info = await self._get_data("subscription", "")
+        dt_start = datetime.strptime(subscription_info["startedAt"], "%m/%d/%Y %H:%M:%S").replace(
+            tzinfo=UTC
+        )
+        dt_end = datetime.strptime(subscription_info["endsAt"], "%m/%d/%Y %H:%M:%S").replace(
+            tzinfo=UTC
+        )
+        user_info = await self._get_data("user", "")
+        url = "https://streamapi.nugs.net/bigriver/subplayer.aspx"
+        timeout = ClientTimeout(total=120)
+        params = {
+            "platformID": -1,
+            "app": 1,
+            "HLS": 1,
+            "orgn": "websdk",
+            "method": "subPlayer",
+            "trackId": item_id,
+            "subCostplanIDAccessList": subscription_info["plan"]["id"],
+            "startDateStamp": int(dt_start.timestamp()),
+            "endDateStamp": int(dt_end.timestamp()),
+            "nn_userID": user_info["userId"],
+            "subscriptionID": subscription_info["legacySubscriptionId"],
+        }
+        async with (
+            self.mass.http_session.get(url, params=params, ssl=True, timeout=timeout) as response,
+        ):
+            response.raise_for_status()
+            content = await response.text()
+            stream = json_loads(content)
+            if not stream.get("streamLink"):
+                raise MediaNotFoundError("No stream found for song %s.", item_id)
+            return stream["streamLink"]
+
+    def _get_item_mapping(self, media_type: MediaType, key: str, name: str) -> ItemMapping:
+        return ItemMapping(
+            media_type=media_type,
+            item_id=key,
+            provider=self.lookup_key,
+            name=name,
+        )
+
+    async def login(self) -> Any:
+        """Login to nugs.net and return the token."""
+        if self._auth_token and (self._token_expiry > time()):
+            return self._auth_token
+        if not self.config.get_value(CONF_USERNAME) or not self.config.get_value(CONF_PASSWORD):
+            msg = "Invalid login credentials"
+            raise LoginFailed(msg)
+        login_data = {
+            "username": self.config.get_value(CONF_USERNAME),
+            "password": self.config.get_value(CONF_PASSWORD),
+            "scope": "offline_access nugsnet:api nugsnet:legacyapi openid profile email",
+            "grant_type": "password",
+            "client_id": "Eg7HuH873H65r5rt325UytR5429",
+        }
+        token = None
+        url = "https://id.nugs.net/connect/token"
+        timeout = ClientTimeout(total=120)
+        async with (
+            self.mass.http_session.post(
+                url, data=login_data, ssl=True, timeout=timeout
+            ) as response,
+        ):
+            # Handle errors
+            if response.status == 401:
+                raise LoginFailed("Invalid Nugs.net username or password")
+            # handle temporary server error
+            if response.status in (502, 503):
+                raise ResourceTemporarilyUnavailable(backoff_time=30)
+            response.raise_for_status()
+            token = await response.json()
+            self._auth_token = token["access_token"]
+            self._token_expiry = time() + token["expires_in"]
+        return token["access_token"]
+
+    async def _get_data(self, nugs_api: str, endpoint: str, **kwargs: Any) -> Any:
+        """Return the requested data from one of various nugs.net API."""
+        headers = {}
+        url: str | None = None
+        timeout = ClientTimeout(total=120)
+        if nugs_api in ("stash", "subscription", "user"):
+            tokeninfo = kwargs.pop("tokeninfo", None)
+            if tokeninfo is None:
+                tokeninfo = await self.login()
+            headers = {"Authorization": f"Bearer {tokeninfo}"}
+        if nugs_api == "catalog":
+            url = f"https://catalog.nugs.net/api/v1/{endpoint}"
+        if nugs_api == "stash":
+            url = f"https://stash.nugs.net/api/v1/me/{endpoint}"
+        if nugs_api == "subscription":
+            url = "https://subscriptions.nugs.net/api/v1/me/subscriptions"
+        if nugs_api == "user":
+            url = "https://stash.nugs.net/api/v1/stash"
+        if not url:
+            raise MediaNotFoundError(f"{nugs_api} not found")
+        async with (
+            self.mass.http_session.get(
+                url, headers=headers, params=kwargs, ssl=True, timeout=timeout
+            ) as response,
+        ):
+            if response.status == 404:
+                raise MediaNotFoundError(f"{url} not found")
+            response.raise_for_status()
+            return await response.json()
+
+    async def _get_all_items(
+        self, nugs_api: str, endpoint: str, **kwargs: Any
+    ) -> list[dict[str, Any]]:
+        limit = 100
+        offset = 0
+        total = 0
+        all_items = []
+        while True:
+            kwargs["limit"] = limit
+            kwargs["offset"] = offset
+            result = await self._get_data(nugs_api, endpoint, **kwargs)
+            total = result["total"]
+            all_items += result["items"]
+            if total <= offset + limit:
+                break
+            offset += limit
+        return all_items
diff --git a/music_assistant/providers/nugs/icon.svg b/music_assistant/providers/nugs/icon.svg
new file mode 100644 (file)
index 0000000..b48bc75
--- /dev/null
@@ -0,0 +1,136 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   width="200"
+   height="200"
+   viewBox="0 0 200 200"
+   fill="none"
+   version="1.1"
+   id="svg20"
+   sodipodi:docname="icon.svg"
+   inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs20" />
+  <sodipodi:namedview
+     id="namedview20"
+     pagecolor="#ffffff"
+     bordercolor="#111111"
+     borderopacity="1"
+     inkscape:showpageshadow="0"
+     inkscape:pageopacity="0"
+     inkscape:pagecheckerboard="1"
+     inkscape:deskcolor="#d1d1d1"
+     inkscape:zoom="1.1748311"
+     inkscape:cx="0"
+     inkscape:cy="3.8303379"
+     inkscape:window-width="1536"
+     inkscape:window-height="792"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg20" />
+  <mask
+     id="mask0_813_105"
+     maskUnits="userSpaceOnUse"
+     x="0"
+     y="0"
+     width="148"
+     height="35">
+    <path
+       fill-rule="evenodd"
+       clip-rule="evenodd"
+       d="M 0,34.4854 H 148 V 0 H 0 Z"
+       fill="#ffffff"
+       id="path1" />
+  </mask>
+  <g
+     mask="url(#mask0_813_105)"
+     id="g20"
+     transform="matrix(5.2340353,0,0,5.2340353,12.93915,9.5049533)"
+     inkscape:transform-center-x="22.832585"
+     inkscape:transform-center-y="-7.2393385">
+    <mask
+       id="mask1_813_105"
+       maskUnits="userSpaceOnUse"
+       x="0"
+       y="0"
+       width="34"
+       height="35">
+      <path
+         fill-rule="evenodd"
+         clip-rule="evenodd"
+         d="M 0,0.204056 H 33.2672 V 34.3754 H 0 Z"
+         fill="#ffffff"
+         id="path2" />
+    </mask>
+    <g
+       mask="url(#mask1_813_105)"
+       id="g3">
+      <path
+         fill-rule="evenodd"
+         clip-rule="evenodd"
+         d="m 0,17.2903 c 0,9.4363 7.44629,17.0852 16.6326,17.0852 9.1867,0 16.6346,-7.6489 16.6346,-17.0852 C 33.2672,7.8537 25.8193,0.204056 16.6326,0.204056 7.44629,0.204056 0,7.8537 0,17.2903 Z"
+         fill="#ff0000"
+         id="path3" />
+    </g>
+    <path
+       fill-rule="evenodd"
+       clip-rule="evenodd"
+       d="M 26.5848,9.52843 C 26.3701,7.92496 25.4683,6.47596 24.2031,5.65872 22.9674,4.85841 21.0915,5.04043 19.7249,5.17776 19.0467,5.24571 18.3296,5.40018 17.6138,5.6026 16.5045,5.62239 15.2633,6.0854 14.2466,6.41189 13.436,6.6741 12.0526,7.05058 11.368,7.55521 10.8966,7.90353 10.607,8.32532 10.428,8.79261 9.4762,10.4626 9.49825,11.9155 9.38144,14.0789 c -0.04073,0.7462 -1.20347,3.836 0.30196,4.378 0.15674,0.0553 0.3224,0.1024 0.4943,0.1349 0.1803,0.4471 0.4012,0.8758 0.6758,1.2743 0.0272,0.0424 0.0618,0.0775 0.0904,0.1179 0.1549,0.4039 0.0177,0.0094 0.1549,0.4039 0.6153,1.7755 -1.15337,2.2813 1.7476,2.4784 0.8151,0.0551 1.5748,0.028 2.2796,-0.0465 1.8324,-0.0647 3.6454,-0.4599 4.4923,0.9885 0.3574,2.1642 -1.3573,2.6778 -3.4684,3.0324 -1.3189,0.1545 -2.9052,0.2347 -4.8218,0.2347 H 2.99976 l 1.21499,1.6231 h 7.11335 c 11.0603,0 12.4801,-2.8003 13.5049,-7.4089 l 0.0191,-0.0898 V 21.077 c 0.8435,-1.3772 2.9266,-2.5379 2.9916,-4.3646 -0.6691,0.6701 -1.2349,-7.00216 -1.2589,-7.18397 z"
+       fill="#000000"
+       id="path4" />
+    <path
+       fill-rule="evenodd"
+       clip-rule="evenodd"
+       d="m 23.7345,24.0365 c 0.0194,-0.1188 0.0178,-0.2445 -0.003,-0.3706 -0.0046,0.1231 -0.0046,0.2471 0.003,0.3706 z"
+       fill="#000000"
+       id="path5" />
+    <path
+       fill-rule="evenodd"
+       clip-rule="evenodd"
+       d="m 9.23315,14.351 c 1.11605,-0.2462 2.30765,-0.5846 3.21845,-1.36 0.4102,-0.3487 1.1479,-1.2533 1.7408,-1.1594 1.0857,0.171 0.4395,1.4708 -0.2296,1.7208 -0.4575,0.1695 -0.9899,0.041 -1.4576,0.1436 -0.461,0.1014 -0.8945,0.3445 -1.3381,0.5177 -0.5541,0.2159 -1.35069,0.6477 -1.93395,0.3261"
+       fill="#ffffff"
+       id="path6" />
+    <path
+       fill-rule="evenodd"
+       clip-rule="evenodd"
+       d="M 15.8761,4.80792 C 15.12,4.97933 14.5892,5.42927 14.2141,6.28019 l 0.0068,0.13182 c 2.2355,-0.11387 6.092,0.91886 7.271,3.4967 l 1.484,-0.71604 C 21.694,6.38772 18.4394,5.12992 15.8761,4.80792 Z"
+       fill="#a8a8a8"
+       id="path7" />
+    <path
+       fill-rule="evenodd"
+       clip-rule="evenodd"
+       d="m 28.8575,13.3662 c 0,2.6592 -1.954,4.8138 -4.3653,4.8138 -2.4107,0 -4.3645,-2.1546 -4.3645,-4.8138 0,-2.6583 1.9538,-4.81371 4.3645,-4.81371 2.4113,0 4.3653,2.15541 4.3653,4.81371 z"
+       fill="#ffffff"
+       id="path8" />
+    <path
+       fill-rule="evenodd"
+       clip-rule="evenodd"
+       d="m 24.4935,9.03566 c -2.1473,0 -3.8943,1.94264 -3.8943,4.33044 0,2.3879 1.747,4.3305 3.8943,4.3305 2.1477,0 3.8947,-1.9426 3.8947,-4.3305 0,-2.3878 -1.747,-4.33044 -3.8947,-4.33044 z m 3e-4,9.62734 c -2.6662,0 -4.8351,-2.3763 -4.8351,-5.2971 0,-2.9209 2.1689,-5.2969 4.8351,-5.2969 2.6664,0 4.8357,2.376 4.8357,5.2969 0,2.9208 -2.1693,5.2971 -4.8357,5.2971 z"
+       fill="#000000"
+       id="path9" />
+    <path
+       fill-rule="evenodd"
+       clip-rule="evenodd"
+       d="m 26.9848,13.4421 c 0,0.8811 -0.6484,1.5961 -1.4474,1.5961 -0.7988,0 -1.4471,-0.715 -1.4471,-1.5961 0,-0.8814 0.6483,-1.5956 1.4471,-1.5956 0.799,0 1.4474,0.7142 1.4474,1.5956 z"
+       fill="#000000"
+       id="path10" />
+    <mask
+       id="mask2_813_105"
+       maskUnits="userSpaceOnUse"
+       x="0"
+       y="0"
+       width="151"
+       height="35">
+      <path
+         fill-rule="evenodd"
+         clip-rule="evenodd"
+         d="m 0,34.4853 150.714,-0.11 V -9.15527e-5 H 0 Z"
+         fill="#ffffff"
+         id="path15" />
+    </mask>
+  </g>
+</svg>
diff --git a/music_assistant/providers/nugs/manifest.json b/music_assistant/providers/nugs/manifest.json
new file mode 100644 (file)
index 0000000..b6f07fb
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "type": "music",
+  "domain": "nugs",
+  "name": "Nugs.net",
+  "description": "Nugs.net support for Music Assistant: Live Music provider.",
+  "codeowners": ["@brian10048"],
+  "requirements": [],
+  "documentation": "https://music-assistant.io/music-providers/nugs/",
+  "multi_instance": true
+}