From 9e4e5b9c902351508ecadbb4e34c16a7252d64d4 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Fri, 8 May 2026 11:37:33 +1000 Subject: [PATCH 01/40] Comment out overscroll-behavior property Comment out the overscroll-behavior property in _layout.scss. --- app/src/global/redesign/base/_layout.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/global/redesign/base/_layout.scss b/app/src/global/redesign/base/_layout.scss index 8746b50..93ae9ca 100644 --- a/app/src/global/redesign/base/_layout.scss +++ b/app/src/global/redesign/base/_layout.scss @@ -190,7 +190,7 @@ main { @include v.content-spacing($type: "padding"); overflow-x: hidden; transform: translateZ(0px); - overscroll-behavior: contain; + //overscroll-behavior: contain; padding-block-start: calc(var(--top-bar-height) + 2vh) !important; @media screen and (min-width: 640px) { From 82248c80549cfe040598436658301e67a50c5438 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Fri, 8 May 2026 12:02:49 +1000 Subject: [PATCH 02/40] Update _layout.scss --- app/src/global/redesign/base/_layout.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/global/redesign/base/_layout.scss b/app/src/global/redesign/base/_layout.scss index 93ae9ca..038aa91 100644 --- a/app/src/global/redesign/base/_layout.scss +++ b/app/src/global/redesign/base/_layout.scss @@ -181,7 +181,7 @@ html { height: 100%; // overflow-y: auto; max-width: 100%; - overscroll-behavior: contain; + //overscroll-behavior: contain; } main { margin: 0 auto; From 0733eb7816bc5df438167c97611787f334958222 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 07:58:17 +1000 Subject: [PATCH 03/40] Add Music Assistant Beatbump provider manifest --- music_assistant_provider/beatbump/manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 music_assistant_provider/beatbump/manifest.json diff --git a/music_assistant_provider/beatbump/manifest.json b/music_assistant_provider/beatbump/manifest.json new file mode 100644 index 0000000..5c85776 --- /dev/null +++ b/music_assistant_provider/beatbump/manifest.json @@ -0,0 +1,8 @@ +{ + "type": "music", + "domain": "beatbump", + "name": "Beatbump", + "description": "Use a self-hosted Beatbump server as a Music Assistant music source.", + "codeowners": ["@aaronh86"], + "requirements": [] +} From cd2000c4f5e6ff70a086bcc9c7bc64b20a76d47a Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 07:58:21 +1000 Subject: [PATCH 04/40] Add Beatbump provider setup flow --- .../beatbump/setup_flow.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 music_assistant_provider/beatbump/setup_flow.py diff --git a/music_assistant_provider/beatbump/setup_flow.py b/music_assistant_provider/beatbump/setup_flow.py new file mode 100644 index 0000000..4563dfe --- /dev/null +++ b/music_assistant_provider/beatbump/setup_flow.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType + +if TYPE_CHECKING: + from music_assistant.models.setup_flow import SetupSession + +CONF_BASE_URL = "base_url" +DEFAULT_BASE_URL = "http://beatbump:8080" + + +async def run_setup(session: SetupSession) -> None: + """Collect the URL of the Beatbump server.""" + prefill = ( + session.context.setup_data.get(CONF_BASE_URL) + or session.context.values.get(CONF_BASE_URL) + or DEFAULT_BASE_URL + ) + values = await session.form( + [ + ConfigEntry( + key=CONF_BASE_URL, + type=ConfigEntryType.STRING, + required=True, + default_value=DEFAULT_BASE_URL, + value=str(prefill), + ) + ], + step_id="user", + last_step=True, + ) + await session.finish({CONF_BASE_URL: str(values[CONF_BASE_URL]).rstrip("/")}) From acaeb25848a631ac3b4e9ff20f2015264e57d33e Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 07:58:34 +1000 Subject: [PATCH 05/40] Implement initial Music Assistant Beatbump provider --- music_assistant_provider/beatbump/__init__.py | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 music_assistant_provider/beatbump/__init__.py diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py new file mode 100644 index 0000000..820f5c6 --- /dev/null +++ b/music_assistant_provider/beatbump/__init__.py @@ -0,0 +1,223 @@ +"""Beatbump music provider for Music Assistant. + +Initial MVP: catalogue search, track lookup and playback through Beatbump's +/api/v1/search.json and /api/v1/player.json endpoints. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType +from music_assistant_models.media_items import ( + AudioFormat, + ItemMapping, + ProviderMapping, + SearchResults, + Track, + UniqueList, +) +from music_assistant_models.streamdetails import StreamDetails + +from music_assistant.models.music_provider import MusicProvider + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + from music_assistant.models import ProviderInstanceType + from music_assistant_models.config_entries import ProviderConfig + from music_assistant_models.provider import ProviderManifest + +CONF_BASE_URL = "base_url" +SUPPORTED_FEATURES = {ProviderFeature.SEARCH} + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + return BeatbumpProvider(mass, manifest, config, SUPPORTED_FEATURES) + + +class BeatbumpProvider(MusicProvider): + """Music Assistant provider backed by a Beatbump server.""" + + @property + def is_streaming_provider(self) -> bool: + return True + + @property + def supported_media_types(self) -> set[MediaType]: + return {MediaType.TRACK} + + @property + def base_url(self) -> str: + return str(self.get_setup_value(CONF_BASE_URL)).rstrip("/") + + async def _get_json(self, path: str, **params: str) -> dict[str, Any]: + async with self.mass.http_session.get( + f"{self.base_url}{path}", params=params, timeout=20 + ) as response: + response.raise_for_status() + data = await response.json(content_type=None) + if not isinstance(data, dict): + raise ValueError("Beatbump returned an unexpected response") + return data + + async def search( + self, + search_query: str, + media_types: list[MediaType], + limit: int = 5, + ) -> SearchResults: + if MediaType.TRACK not in media_types: + return SearchResults() + payload = await self._get_json( + "/api/v1/search.json", q=search_query, filter="songs" + ) + tracks: list[Track] = [] + seen: set[str] = set() + for candidate in _walk_dicts(payload.get("results", payload)): + video_id = _find_string(candidate, "videoId", "video_id") + if not video_id or video_id in seen: + continue + title = _extract_title(candidate) + if not title: + continue + seen.add(video_id) + tracks.append(self._track_from_search(video_id, title, candidate)) + if len(tracks) >= limit: + break + return SearchResults(tracks=UniqueList(tracks)) + + def _track_from_search( + self, video_id: str, title: str, data: dict[str, Any] + ) -> Track: + artist = _extract_artist(data) + duration = _extract_duration(data) + artists = UniqueList( + [ItemMapping(media_type=MediaType.ARTIST, item_id=artist, provider=self.instance_id, name=artist)] + ) if artist else UniqueList() + return Track( + item_id=video_id, + provider=self.instance_id, + name=title, + duration=duration, + artists=artists, + provider_mappings={ + ProviderMapping( + item_id=video_id, + provider_domain=self.domain, + provider_instance=self.instance_id, + available=True, + audio_format=AudioFormat(content_type=ContentType.UNKNOWN), + ) + }, + ) + + async def get_track(self, prov_track_id: str) -> Track: + payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id) + details = payload.get("videoDetails") or {} + title = str(details.get("title") or prov_track_id) + artist = str(details.get("author") or "") + duration = _as_int(details.get("lengthSeconds")) + return self._track_from_search( + prov_track_id, + title, + {"artist": artist, "duration": duration}, + ) + + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + payload = await self._get_json("/api/v1/player.json", videoId=item_id) + formats = (payload.get("streamingData") or {}).get("adaptiveFormats") or [] + audio_formats = [ + item for item in formats + if isinstance(item, dict) + and item.get("url") + and "audio" in str(item.get("mimeType", "")).lower() + ] + if not audio_formats: + raise ValueError(f"Beatbump returned no audio stream for {item_id}") + selected = max(audio_formats, key=lambda item: _as_int(item.get("bitrate")) or 0) + mime = str(selected.get("mimeType", "")).lower() + content_type = ContentType.UNKNOWN + if "audio/mp4" in mime or "m4a" in mime: + content_type = ContentType.M4A + elif "audio/webm" in mime: + content_type = ContentType.WEBM + duration_ms = _as_int(selected.get("approxDurationMs")) + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + media_type=MediaType.TRACK, + stream_type=StreamType.HTTP, + path=str(selected["url"]), + audio_format=AudioFormat(content_type=content_type), + duration=(duration_ms / 1000) if duration_ms else None, + can_seek=True, + allow_seek=True, + ) + + +def _walk_dicts(value: Any): + if isinstance(value, dict): + yield value + for child in value.values(): + yield from _walk_dicts(child) + elif isinstance(value, list): + for child in value: + yield from _walk_dicts(child) + + +def _find_string(data: dict[str, Any], *keys: str) -> str | None: + for key in keys: + value = data.get(key) + if isinstance(value, str) and value: + return value + for value in data.values(): + if isinstance(value, dict): + found = _find_string(value, *keys) + if found: + return found + return None + + +def _extract_title(data: dict[str, Any]) -> str | None: + for key in ("title", "name"): + value = data.get(key) + if isinstance(value, str) and value: + return value + if isinstance(value, dict): + runs = value.get("runs") + if isinstance(runs, list) and runs and isinstance(runs[0], dict): + text = runs[0].get("text") + if isinstance(text, str) and text: + return text + return _find_string(data, "title") + + +def _extract_artist(data: dict[str, Any]) -> str: + for key in ("artist", "author"): + value = data.get(key) + if isinstance(value, str): + return value + artists = data.get("artists") + if isinstance(artists, list) and artists: + first = artists[0] + if isinstance(first, str): + return first + if isinstance(first, dict): + return str(first.get("name") or first.get("text") or "") + return "" + + +def _extract_duration(data: dict[str, Any]) -> int | None: + for key in ("duration", "lengthSeconds"): + value = _as_int(data.get(key)) + if value is not None: + return value + return None + + +def _as_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None From 813f934160fe0cb49a4c00c7d8e9b1229a015ef3 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 07:58:40 +1000 Subject: [PATCH 06/40] Document Music Assistant provider installation --- music_assistant_provider/README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 music_assistant_provider/README.md diff --git a/music_assistant_provider/README.md b/music_assistant_provider/README.md new file mode 100644 index 0000000..4131437 --- /dev/null +++ b/music_assistant_provider/README.md @@ -0,0 +1,30 @@ +# Beatbump provider for Music Assistant + +Experimental Music Assistant music provider backed by this Beatbump server. + +## Current MVP + +- Search Beatbump's YouTube Music catalogue for tracks. +- Return tracks to Music Assistant. +- Resolve a selected track through Beatbump `/api/v1/player.json`. +- Hand Music Assistant the highest-bitrate audio-only HTTP stream returned by Beatbump. + +## Install for development + +Copy the `beatbump` directory into the Music Assistant server source tree as: + +`music_assistant/providers/beatbump/` + +Restart Music Assistant, then add **Beatbump** under **Settings -> Music Sources**. Enter the URL that the Music Assistant server/container can use to reach Beatbump, for example: + +`http://192.168.1.50:8080` + +or, if both containers share a Docker network: + +`http://beatbump:8080` + +## Notes + +This is intentionally a small first implementation. It currently exposes tracks only. Albums, artists, playlists, browse, recommendations and library/favourites can be layered on after search/playback is proven against the live Beatbump and Music Assistant instances. + +The player endpoint returns signed/temporary upstream stream URLs. Music Assistant therefore resolves the URL at playback time rather than storing it from search results. From 78903fbb6a2fb52959f9611ff3071dde1303d4f2 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 08:10:21 +1000 Subject: [PATCH 07/40] Add Music Assistant provider installer --- music_assistant_provider/install.sh | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 music_assistant_provider/install.sh diff --git a/music_assistant_provider/install.sh b/music_assistant_provider/install.sh new file mode 100644 index 0000000..b467a4b --- /dev/null +++ b/music_assistant_provider/install.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -eu + +REPO="aaronh86/Beatbump" +REF="feature/music-assistant-provider" +RAW="https://raw.githubusercontent.com/${REPO}/${REF}/music_assistant_provider/beatbump" + +# Locate the installed Music Assistant Python package in the current container. +MA_DIR="$(python - <<'PY' +import os +import music_assistant +print(os.path.dirname(music_assistant.__file__)) +PY +)" + +PROVIDER_DIR="${MA_DIR}/providers/beatbump" +echo "Music Assistant package: ${MA_DIR}" +echo "Installing Beatbump provider to: ${PROVIDER_DIR}" +mkdir -p "${PROVIDER_DIR}" + +fetch() { + src="$1" + dst="$2" + if command -v curl >/dev/null 2>&1; then + curl -fL --retry 3 --connect-timeout 10 "$src" -o "$dst" + elif command -v wget >/dev/null 2>&1; then + wget -O "$dst" "$src" + else + echo "ERROR: curl or wget is required." >&2 + exit 1 + fi +} + +for file in __init__.py constants.py provider.py setup_flow.py manifest.json; do + echo "Downloading ${file}..." + fetch "${RAW}/${file}" "${PROVIDER_DIR}/${file}" +done + +python -m compileall -q "${PROVIDER_DIR}" + +echo +echo "Beatbump provider installed successfully." +echo "Restart the Music Assistant container, then go to:" +echo "Settings -> Music Sources -> Add a music source -> Beatbump" +echo +echo "NOTE: this modifies the running container filesystem. A Music Assistant image update/recreation may remove it; rerun this installer after an update until the provider is packaged upstream or bind-mounted persistently." From e66e2602346cf6c6dc645b51e9b13543b67cf6a4 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 08:15:17 +1000 Subject: [PATCH 08/40] Fix Music Assistant installer file list --- music_assistant_provider/install.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/music_assistant_provider/install.sh b/music_assistant_provider/install.sh index b467a4b..11fd1f0 100644 --- a/music_assistant_provider/install.sh +++ b/music_assistant_provider/install.sh @@ -2,10 +2,9 @@ set -eu REPO="aaronh86/Beatbump" -REF="feature/music-assistant-provider" +REF="ma-provider" RAW="https://raw.githubusercontent.com/${REPO}/${REF}/music_assistant_provider/beatbump" -# Locate the installed Music Assistant Python package in the current container. MA_DIR="$(python - <<'PY' import os import music_assistant @@ -31,11 +30,16 @@ fetch() { fi } -for file in __init__.py constants.py provider.py setup_flow.py manifest.json; do +# The MVP provider implementation is intentionally contained in __init__.py. +# setup_flow.py handles configuration and manifest.json describes the provider. +for file in __init__.py setup_flow.py manifest.json; do echo "Downloading ${file}..." fetch "${RAW}/${file}" "${PROVIDER_DIR}/${file}" done +# Remove files from any earlier incomplete installer attempt. +rm -f "${PROVIDER_DIR}/constants.py" "${PROVIDER_DIR}/provider.py" + python -m compileall -q "${PROVIDER_DIR}" echo @@ -43,4 +47,4 @@ echo "Beatbump provider installed successfully." echo "Restart the Music Assistant container, then go to:" echo "Settings -> Music Sources -> Add a music source -> Beatbump" echo -echo "NOTE: this modifies the running container filesystem. A Music Assistant image update/recreation may remove it; rerun this installer after an update until the provider is packaged upstream or bind-mounted persistently." +echo "NOTE: this modifies the running container filesystem. A Music Assistant image update/recreation may remove it." From 34d7d9876142ae76cc6e0d71bd898f26fb6128d4 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 08:33:17 +1000 Subject: [PATCH 09/40] Fix Music Assistant parsing of Beatbump song search results --- music_assistant_provider/beatbump/__init__.py | 118 +++++++++--------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index 820f5c6..10490aa 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -74,26 +74,43 @@ async def search( ) tracks: list[Track] = [] seen: set[str] = set() - for candidate in _walk_dicts(payload.get("results", payload)): - video_id = _find_string(candidate, "videoId", "video_id") - if not video_id or video_id in seen: - continue - title = _extract_title(candidate) - if not title: + + # Beatbump's filtered search response is a list of shelves: + # {"results": [{"header": {"title": "Songs"}, "contents": [...] }]} + # Parse the normalized contents directly instead of recursively walking the + # raw YouTube response, which is also included elsewhere in the payload. + for shelf in payload.get("results") or []: + if not isinstance(shelf, dict): continue - seen.add(video_id) - tracks.append(self._track_from_search(video_id, title, candidate)) - if len(tracks) >= limit: - break + for candidate in shelf.get("contents") or []: + if not isinstance(candidate, dict): + continue + if candidate.get("type") not in (None, "songs"): + continue + video_id = candidate.get("videoId") + title = candidate.get("title") + if not isinstance(video_id, str) or not video_id or video_id in seen: + continue + if not isinstance(title, str) or not title: + continue + seen.add(video_id) + tracks.append(self._track_from_search(video_id, title, candidate)) + if len(tracks) >= limit: + return SearchResults(tracks=UniqueList(tracks)) return SearchResults(tracks=UniqueList(tracks)) def _track_from_search( self, video_id: str, title: str, data: dict[str, Any] ) -> Track: - artist = _extract_artist(data) + artist, artist_id = _extract_artist(data) duration = _extract_duration(data) artists = UniqueList( - [ItemMapping(media_type=MediaType.ARTIST, item_id=artist, provider=self.instance_id, name=artist)] + [ItemMapping( + media_type=MediaType.ARTIST, + item_id=artist_id or artist, + provider=self.instance_id, + name=artist, + )] ) if artist else UniqueList() return Track( item_id=video_id, @@ -156,56 +173,23 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea ) -def _walk_dicts(value: Any): - if isinstance(value, dict): - yield value - for child in value.values(): - yield from _walk_dicts(child) - elif isinstance(value, list): - for child in value: - yield from _walk_dicts(child) - - -def _find_string(data: dict[str, Any], *keys: str) -> str | None: - for key in keys: - value = data.get(key) - if isinstance(value, str) and value: - return value - for value in data.values(): - if isinstance(value, dict): - found = _find_string(value, *keys) - if found: - return found - return None - +def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: + # Beatbump exposes normalized artist metadata here. + artist_info = data.get("artistInfo") + if isinstance(artist_info, dict): + artists = artist_info.get("artist") + if isinstance(artists, list) and artists and isinstance(artists[0], dict): + first = artists[0] + name = first.get("text") + browse_id = first.get("browseId") + if isinstance(name, str) and name: + return name, browse_id if isinstance(browse_id, str) else None -def _extract_title(data: dict[str, Any]) -> str | None: - for key in ("title", "name"): - value = data.get(key) - if isinstance(value, str) and value: - return value - if isinstance(value, dict): - runs = value.get("runs") - if isinstance(runs, list) and runs and isinstance(runs[0], dict): - text = runs[0].get("text") - if isinstance(text, str) and text: - return text - return _find_string(data, "title") - - -def _extract_artist(data: dict[str, Any]) -> str: for key in ("artist", "author"): value = data.get(key) - if isinstance(value, str): - return value - artists = data.get("artists") - if isinstance(artists, list) and artists: - first = artists[0] - if isinstance(first, str): - return first - if isinstance(first, dict): - return str(first.get("name") or first.get("text") or "") - return "" + if isinstance(value, str) and value: + return value, None + return "", None def _extract_duration(data: dict[str, Any]) -> int | None: @@ -213,6 +197,22 @@ def _extract_duration(data: dict[str, Any]) -> int | None: value = _as_int(data.get(key)) if value is not None: return value + + # Search results expose duration as the final subtitle entry, e.g. "8:26". + subtitle = data.get("subtitle") + if isinstance(subtitle, list): + for entry in reversed(subtitle): + if not isinstance(entry, dict): + continue + text = entry.get("text") + if not isinstance(text, str): + continue + parts = text.split(":") + if len(parts) in (2, 3) and all(part.isdigit() for part in parts): + seconds = 0 + for part in parts: + seconds = seconds * 60 + int(part) + return seconds return None From b255260b06f77986e41c47a1cc8f68977ce34b75 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 08:38:37 +1000 Subject: [PATCH 10/40] Prefer AAC itag 140 for Music Assistant playback --- music_assistant_provider/beatbump/__init__.py | 101 ++++-------------- 1 file changed, 21 insertions(+), 80 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index 10490aa..c0c9e62 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -8,16 +8,8 @@ from typing import TYPE_CHECKING, Any from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType -from music_assistant_models.media_items import ( - AudioFormat, - ItemMapping, - ProviderMapping, - SearchResults, - Track, - UniqueList, -) +from music_assistant_models.media_items import AudioFormat, ItemMapping, ProviderMapping, SearchResults, Track, UniqueList from music_assistant_models.streamdetails import StreamDetails - from music_assistant.models.music_provider import MusicProvider if TYPE_CHECKING: @@ -29,16 +21,10 @@ CONF_BASE_URL = "base_url" SUPPORTED_FEATURES = {ProviderFeature.SEARCH} - -async def setup( - mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig -) -> ProviderInstanceType: +async def setup(mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig) -> ProviderInstanceType: return BeatbumpProvider(mass, manifest, config, SUPPORTED_FEATURES) - class BeatbumpProvider(MusicProvider): - """Music Assistant provider backed by a Beatbump server.""" - @property def is_streaming_provider(self) -> bool: return True @@ -52,40 +38,24 @@ def base_url(self) -> str: return str(self.get_setup_value(CONF_BASE_URL)).rstrip("/") async def _get_json(self, path: str, **params: str) -> dict[str, Any]: - async with self.mass.http_session.get( - f"{self.base_url}{path}", params=params, timeout=20 - ) as response: + async with self.mass.http_session.get(f"{self.base_url}{path}", params=params, timeout=20) as response: response.raise_for_status() data = await response.json(content_type=None) if not isinstance(data, dict): raise ValueError("Beatbump returned an unexpected response") return data - async def search( - self, - search_query: str, - media_types: list[MediaType], - limit: int = 5, - ) -> SearchResults: + async def search(self, search_query: str, media_types: list[MediaType], limit: int = 5) -> SearchResults: if MediaType.TRACK not in media_types: return SearchResults() - payload = await self._get_json( - "/api/v1/search.json", q=search_query, filter="songs" - ) + payload = await self._get_json("/api/v1/search.json", q=search_query, filter="songs") tracks: list[Track] = [] seen: set[str] = set() - - # Beatbump's filtered search response is a list of shelves: - # {"results": [{"header": {"title": "Songs"}, "contents": [...] }]} - # Parse the normalized contents directly instead of recursively walking the - # raw YouTube response, which is also included elsewhere in the payload. for shelf in payload.get("results") or []: if not isinstance(shelf, dict): continue for candidate in shelf.get("contents") or []: - if not isinstance(candidate, dict): - continue - if candidate.get("type") not in (None, "songs"): + if not isinstance(candidate, dict) or candidate.get("type") not in (None, "songs"): continue video_id = candidate.get("videoId") title = candidate.get("title") @@ -99,34 +69,17 @@ async def search( return SearchResults(tracks=UniqueList(tracks)) return SearchResults(tracks=UniqueList(tracks)) - def _track_from_search( - self, video_id: str, title: str, data: dict[str, Any] - ) -> Track: + def _track_from_search(self, video_id: str, title: str, data: dict[str, Any]) -> Track: artist, artist_id = _extract_artist(data) duration = _extract_duration(data) - artists = UniqueList( - [ItemMapping( - media_type=MediaType.ARTIST, - item_id=artist_id or artist, - provider=self.instance_id, - name=artist, - )] - ) if artist else UniqueList() + artists = UniqueList([ItemMapping(media_type=MediaType.ARTIST, item_id=artist_id or artist, provider=self.instance_id, name=artist)]) if artist else UniqueList() return Track( item_id=video_id, provider=self.instance_id, name=title, duration=duration, artists=artists, - provider_mappings={ - ProviderMapping( - item_id=video_id, - provider_domain=self.domain, - provider_instance=self.instance_id, - available=True, - audio_format=AudioFormat(content_type=ContentType.UNKNOWN), - ) - }, + provider_mappings={ProviderMapping(item_id=video_id, provider_domain=self.domain, provider_instance=self.instance_id, available=True, audio_format=AudioFormat(content_type=ContentType.UNKNOWN))}, ) async def get_track(self, prov_track_id: str) -> Track: @@ -135,31 +88,26 @@ async def get_track(self, prov_track_id: str) -> Track: title = str(details.get("title") or prov_track_id) artist = str(details.get("author") or "") duration = _as_int(details.get("lengthSeconds")) - return self._track_from_search( - prov_track_id, - title, - {"artist": artist, "duration": duration}, - ) + return self._track_from_search(prov_track_id, title, {"artist": artist, "duration": duration}) async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: payload = await self._get_json("/api/v1/player.json", videoId=item_id) formats = (payload.get("streamingData") or {}).get("adaptiveFormats") or [] - audio_formats = [ - item for item in formats - if isinstance(item, dict) - and item.get("url") - and "audio" in str(item.get("mimeType", "")).lower() - ] + audio_formats = [item for item in formats if isinstance(item, dict) and item.get("url") and "audio" in str(item.get("mimeType", "")).lower()] if not audio_formats: raise ValueError(f"Beatbump returned no audio stream for {item_id}") - selected = max(audio_formats, key=lambda item: _as_int(item.get("bitrate")) or 0) + + # Prefer YouTube's AAC-LC/M4A stream (itag 140). It is broadly supported + # by Music Assistant/ffmpeg and avoids relying on WebM/Opus handling. + aac_140 = [item for item in audio_formats if _as_int(item.get("itag")) == 140] + selected = aac_140[0] if aac_140 else max(audio_formats, key=lambda item: _as_int(item.get("bitrate")) or 0) mime = str(selected.get("mimeType", "")).lower() - content_type = ContentType.UNKNOWN - if "audio/mp4" in mime or "m4a" in mime: - content_type = ContentType.M4A - elif "audio/webm" in mime: - content_type = ContentType.WEBM + content_type = ContentType.M4A if "audio/mp4" in mime or "m4a" in mime else ContentType.WEBM if "audio/webm" in mime else ContentType.UNKNOWN duration_ms = _as_int(selected.get("approxDurationMs")) + self.logger.debug( + "Beatbump stream %s: itag=%s mime=%s bitrate=%s duration_ms=%s", + item_id, selected.get("itag"), selected.get("mimeType"), selected.get("bitrate"), duration_ms, + ) return StreamDetails( provider=self.instance_id, item_id=item_id, @@ -172,9 +120,7 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea allow_seek=True, ) - def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: - # Beatbump exposes normalized artist metadata here. artist_info = data.get("artistInfo") if isinstance(artist_info, dict): artists = artist_info.get("artist") @@ -184,21 +130,17 @@ def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: browse_id = first.get("browseId") if isinstance(name, str) and name: return name, browse_id if isinstance(browse_id, str) else None - for key in ("artist", "author"): value = data.get(key) if isinstance(value, str) and value: return value, None return "", None - def _extract_duration(data: dict[str, Any]) -> int | None: for key in ("duration", "lengthSeconds"): value = _as_int(data.get(key)) if value is not None: return value - - # Search results expose duration as the final subtitle entry, e.g. "8:26". subtitle = data.get("subtitle") if isinstance(subtitle, list): for entry in reversed(subtitle): @@ -215,7 +157,6 @@ def _extract_duration(data: dict[str, Any]) -> int | None: return seconds return None - def _as_int(value: Any) -> int | None: try: return int(value) From b49fb497459e767263a75c378d959eab2e2d611c Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 08:57:48 +1000 Subject: [PATCH 11/40] Build out Beatbump Music Assistant catalog provider --- music_assistant_provider/beatbump/__init__.py | 418 +++++++++++++++--- 1 file changed, 366 insertions(+), 52 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index c0c9e62..07b6ded 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -1,14 +1,22 @@ -"""Beatbump music provider for Music Assistant. - -Initial MVP: catalogue search, track lookup and playback through Beatbump's -/api/v1/search.json and /api/v1/player.json endpoints. -""" +"""Beatbump music provider for Music Assistant.""" from __future__ import annotations +import time from typing import TYPE_CHECKING, Any +from urllib.parse import parse_qs, urlparse from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType -from music_assistant_models.media_items import AudioFormat, ItemMapping, ProviderMapping, SearchResults, Track, UniqueList +from music_assistant_models.media_items import ( + Album, + Artist, + AudioFormat, + ItemMapping, + Playlist, + ProviderMapping, + SearchResults, + Track, + UniqueList, +) from music_assistant_models.streamdetails import StreamDetails from music_assistant.models.music_provider import MusicProvider @@ -21,121 +29,415 @@ CONF_BASE_URL = "base_url" SUPPORTED_FEATURES = {ProviderFeature.SEARCH} -async def setup(mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig) -> ProviderInstanceType: + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: return BeatbumpProvider(mass, manifest, config, SUPPORTED_FEATURES) + class BeatbumpProvider(MusicProvider): + """Music Assistant provider backed by a self-hosted Beatbump server.""" + @property def is_streaming_provider(self) -> bool: return True @property def supported_media_types(self) -> set[MediaType]: - return {MediaType.TRACK} + return {MediaType.ARTIST, MediaType.ALBUM, MediaType.TRACK, MediaType.PLAYLIST} @property def base_url(self) -> str: return str(self.get_setup_value(CONF_BASE_URL)).rstrip("/") async def _get_json(self, path: str, **params: str) -> dict[str, Any]: - async with self.mass.http_session.get(f"{self.base_url}{path}", params=params, timeout=20) as response: + async with self.mass.http_session.get( + f"{self.base_url}{path}", params=params, timeout=30 + ) as response: response.raise_for_status() data = await response.json(content_type=None) if not isinstance(data, dict): raise ValueError("Beatbump returned an unexpected response") return data - async def search(self, search_query: str, media_types: list[MediaType], limit: int = 5) -> SearchResults: - if MediaType.TRACK not in media_types: - return SearchResults() - payload = await self._get_json("/api/v1/search.json", q=search_query, filter="songs") - tracks: list[Track] = [] - seen: set[str] = set() + async def _search_filter(self, query: str, filter_name: str) -> list[dict[str, Any]]: + payload = await self._get_json("/api/v1/search.json", q=query, filter=filter_name) + items: list[dict[str, Any]] = [] for shelf in payload.get("results") or []: if not isinstance(shelf, dict): continue for candidate in shelf.get("contents") or []: - if not isinstance(candidate, dict) or candidate.get("type") not in (None, "songs"): - continue - video_id = candidate.get("videoId") - title = candidate.get("title") - if not isinstance(video_id, str) or not video_id or video_id in seen: - continue - if not isinstance(title, str) or not title: - continue - seen.add(video_id) - tracks.append(self._track_from_search(video_id, title, candidate)) - if len(tracks) >= limit: - return SearchResults(tracks=UniqueList(tracks)) - return SearchResults(tracks=UniqueList(tracks)) - - def _track_from_search(self, video_id: str, title: str, data: dict[str, Any]) -> Track: - artist, artist_id = _extract_artist(data) - duration = _extract_duration(data) - artists = UniqueList([ItemMapping(media_type=MediaType.ARTIST, item_id=artist_id or artist, provider=self.instance_id, name=artist)]) if artist else UniqueList() - return Track( + if isinstance(candidate, dict): + items.append(candidate) + return items + + async def search( + self, search_query: str, media_types: list[MediaType], limit: int = 5 + ) -> SearchResults: + """Search each Beatbump filter separately; Beatbump's unfiltered search is unreliable.""" + result = SearchResults() + requested = set(media_types) + + if MediaType.TRACK in requested: + items = await self._search_filter(search_query, "songs") + result.tracks = [ + track + for item in items[:limit] + if (track := self._parse_track(item)) is not None + ] + + if MediaType.ARTIST in requested: + items = await self._search_filter(search_query, "artists") + result.artists = [ + artist + for item in items[:limit] + if (artist := self._parse_artist_search(item)) is not None + ] + + if MediaType.ALBUM in requested: + items = await self._search_filter(search_query, "albums") + result.albums = [ + album + for item in items[:limit] + if (album := self._parse_album_search(item)) is not None + ] + + if MediaType.PLAYLIST in requested: + items = await self._search_filter(search_query, "all_playlists") + result.playlists = [ + playlist + for item in items[:limit] + if (playlist := self._parse_playlist_search(item)) is not None + ] + return result + + def _mapping(self, media_type: MediaType, item_id: str, name: str) -> ItemMapping: + return ItemMapping( + media_type=media_type, + item_id=item_id, + provider=self.instance_id, + name=name, + ) + + def _provider_mapping(self, item_id: str, audio: bool = False) -> ProviderMapping: + return ProviderMapping( + item_id=item_id, + provider_domain=self.domain, + provider_instance=self.instance_id, + available=True, + audio_format=AudioFormat(content_type=ContentType.M4A) if audio else None, + ) + + def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: + video_id = _first_string(data, "videoId") + title = _title(data) + if not video_id or not title: + return None + artist_name, artist_id = _extract_artist(data) + if not artist_name: + # Music Assistant expects tracks to have an artist. YouTube occasionally omits it. + artist_name = "YouTube Music" + artist_id = "youtube-music" + track = Track( item_id=video_id, provider=self.instance_id, name=title, - duration=duration, - artists=artists, - provider_mappings={ProviderMapping(item_id=video_id, provider_domain=self.domain, provider_instance=self.instance_id, available=True, audio_format=AudioFormat(content_type=ContentType.UNKNOWN))}, + duration=_extract_duration(data), + artists=UniqueList([ + self._mapping(MediaType.ARTIST, artist_id or artist_name, artist_name) + ]), + provider_mappings={self._provider_mapping(video_id, audio=True)}, + position=position or None, ) + album = data.get("album") + if isinstance(album, dict): + album_name = _first_string(album, "text", "title", "name") + album_id = _first_string(album, "browseId", "id") + if album_name and album_id: + track.album = self._mapping(MediaType.ALBUM, album_id, album_name) + return track + + def _parse_artist_search(self, data: dict[str, Any]) -> Artist | None: + name = _title(data) + artist_id = _browse_id(data) + if not name or not artist_id: + return None + return Artist( + item_id=artist_id, + provider=self.instance_id, + name=name, + provider_mappings={self._provider_mapping(artist_id)}, + ) + + def _parse_album_search(self, data: dict[str, Any]) -> Album | None: + name = _title(data) + album_id = _browse_id(data) + if not name or not album_id: + return None + album = Album( + item_id=album_id, + provider=self.instance_id, + name=name, + provider_mappings={self._provider_mapping(album_id)}, + ) + artist_name, artist_id = _extract_artist(data) + if artist_name: + album.artists = UniqueList([ + self._mapping(MediaType.ARTIST, artist_id or artist_name, artist_name) + ]) + return album + + def _parse_playlist_search(self, data: dict[str, Any]) -> Playlist | None: + name = _title(data) + playlist_id = _first_string(data, "playlistId") or _browse_id(data) + if not name or not playlist_id: + return None + return Playlist( + item_id=playlist_id, + provider=self.instance_id, + name=name, + provider_mappings={self._provider_mapping(playlist_id)}, + is_editable=False, + ) + + async def get_artist(self, prov_artist_id: str) -> Artist: + """Resolve an artist mapping created by search/track parsing.""" + if prov_artist_id == "youtube-music": + return Artist( + item_id=prov_artist_id, + provider=self.instance_id, + name="YouTube Music", + provider_mappings={self._provider_mapping(prov_artist_id)}, + ) + payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") + header = payload.get("header") or {} + name = _first_string(header, "name") or prov_artist_id + artist = Artist( + item_id=prov_artist_id, + provider=self.instance_id, + name=name, + provider_mappings={self._provider_mapping(prov_artist_id)}, + ) + if isinstance(header, dict) and isinstance(header.get("description"), str): + artist.metadata.description = header["description"] + return artist + + async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: + payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") + candidates: list[dict[str, Any]] = [] + songs = payload.get("songs") + if isinstance(songs, dict): + candidates.extend(x for x in songs.get("contents") or [] if isinstance(x, dict)) + for carousel in payload.get("carousels") or []: + if not isinstance(carousel, dict): + continue + header = carousel.get("header") or {} + if "song" not in str(header.get("title", "")).lower(): + continue + candidates.extend(x for x in carousel.get("contents") or [] if isinstance(x, dict)) + return [track for item in candidates if (track := self._parse_track(item)) is not None][:25] + + async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: + payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") + albums: list[Album] = [] + for carousel in payload.get("carousels") or []: + if not isinstance(carousel, dict): + continue + header = carousel.get("header") or {} + title = str(header.get("title", "")).lower() + if "album" not in title and "single" not in title: + continue + for item in carousel.get("contents") or []: + if isinstance(item, dict) and (album := self._parse_album_search(item)): + albums.append(album) + return albums + + async def _playlist_payload(self, item_id: str) -> dict[str, Any]: + return await self._get_json("/api/v1/playlist.json", list=item_id) + + async def get_album(self, prov_album_id: str) -> Album: + payload = await self._playlist_payload(prov_album_id) + header = payload.get("header") or {} + title = _header_title(header) or prov_album_id + album = Album( + item_id=prov_album_id, + provider=self.instance_id, + name=title, + provider_mappings={self._provider_mapping(prov_album_id)}, + ) + tracks = payload.get("tracks") or [] + for item in tracks: + if isinstance(item, dict): + artist_name, artist_id = _extract_artist(item) + if artist_name: + album.artists = UniqueList([ + self._mapping(MediaType.ARTIST, artist_id or artist_name, artist_name) + ]) + break + return album + + async def get_album_tracks(self, prov_album_id: str) -> list[Track]: + payload = await self._playlist_payload(prov_album_id) + result: list[Track] = [] + for index, item in enumerate(payload.get("tracks") or [], 1): + if isinstance(item, dict) and (track := self._parse_track(item, index)): + track.album = self._mapping( + MediaType.ALBUM, + prov_album_id, + _header_title(payload.get("header") or {}) or prov_album_id, + ) + result.append(track) + return result + + async def get_playlist(self, prov_playlist_id: str) -> Playlist: + payload = await self._playlist_payload(prov_playlist_id) + header = payload.get("header") or {} + return Playlist( + item_id=prov_playlist_id, + provider=self.instance_id, + name=_header_title(header) or prov_playlist_id, + provider_mappings={self._provider_mapping(prov_playlist_id)}, + is_editable=False, + ) + + async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]: + if page > 0: + return [] + payload = await self._playlist_payload(prov_playlist_id) + return [ + track + for index, item in enumerate(payload.get("tracks") or [], 1) + if isinstance(item, dict) and (track := self._parse_track(item, index)) is not None + ] async def get_track(self, prov_track_id: str) -> Track: payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id) details = payload.get("videoDetails") or {} title = str(details.get("title") or prov_track_id) - artist = str(details.get("author") or "") + artist = str(details.get("author") or "YouTube Music") duration = _as_int(details.get("lengthSeconds")) - return self._track_from_search(prov_track_id, title, {"artist": artist, "duration": duration}) + return self._parse_track({ + "videoId": prov_track_id, + "title": title, + "artist": artist, + "duration": duration, + }) or Track( + item_id=prov_track_id, + provider=self.instance_id, + name=title, + provider_mappings={self._provider_mapping(prov_track_id, audio=True)}, + ) async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + """Resolve a fresh signed YouTube audio URL through Beatbump for every playback.""" payload = await self._get_json("/api/v1/player.json", videoId=item_id) + status = (payload.get("playabilityStatus") or {}).get("status") + if status and status != "OK": + raise ValueError(f"Beatbump reports {item_id} as not playable: {status}") + formats = (payload.get("streamingData") or {}).get("adaptiveFormats") or [] - audio_formats = [item for item in formats if isinstance(item, dict) and item.get("url") and "audio" in str(item.get("mimeType", "")).lower()] + audio_formats = [ + item for item in formats + if isinstance(item, dict) + and item.get("url") + and "audio" in str(item.get("mimeType", "")).lower() + ] if not audio_formats: raise ValueError(f"Beatbump returned no audio stream for {item_id}") - # Prefer YouTube's AAC-LC/M4A stream (itag 140). It is broadly supported - # by Music Assistant/ffmpeg and avoids relying on WebM/Opus handling. + # Prefer AAC-LC/M4A itag 140 for maximum MA/player compatibility. aac_140 = [item for item in audio_formats if _as_int(item.get("itag")) == 140] - selected = aac_140[0] if aac_140 else max(audio_formats, key=lambda item: _as_int(item.get("bitrate")) or 0) + selected = aac_140[0] if aac_140 else max( + audio_formats, key=lambda item: _as_int(item.get("bitrate")) or 0 + ) + url = str(selected["url"]) mime = str(selected.get("mimeType", "")).lower() - content_type = ContentType.M4A if "audio/mp4" in mime or "m4a" in mime else ContentType.WEBM if "audio/webm" in mime else ContentType.UNKNOWN + if "audio/mp4" in mime or "m4a" in mime: + content_type = ContentType.M4A + elif "audio/webm" in mime: + content_type = ContentType.WEBM + else: + content_type = ContentType.UNKNOWN + + expiration = 3600 + query = parse_qs(urlparse(url).query) + if expire := query.get("expire", [None])[0]: + try: + expiration = max(60, int(expire) - int(time.time())) + except (TypeError, ValueError): + pass duration_ms = _as_int(selected.get("approxDurationMs")) - self.logger.debug( - "Beatbump stream %s: itag=%s mime=%s bitrate=%s duration_ms=%s", - item_id, selected.get("itag"), selected.get("mimeType"), selected.get("bitrate"), duration_ms, + self.logger.info( + "Beatbump playback %s -> itag=%s mime=%s bitrate=%s expires_in=%ss", + item_id, + selected.get("itag"), + selected.get("mimeType"), + selected.get("bitrate"), + expiration, ) return StreamDetails( provider=self.instance_id, item_id=item_id, media_type=MediaType.TRACK, stream_type=StreamType.HTTP, - path=str(selected["url"]), + path=url, audio_format=AudioFormat(content_type=content_type), duration=(duration_ms / 1000) if duration_ms else None, can_seek=True, allow_seek=True, + expiration=expiration, + # YouTube serves adaptive streams at approximately playback pace. + is_realtime=True, ) + +def _title(data: dict[str, Any]) -> str | None: + return _first_string(data, "title", "name", "text") + + +def _first_string(data: dict[str, Any], *keys: str) -> str | None: + for key in keys: + value = data.get(key) + if isinstance(value, str) and value: + return value + return None + + +def _browse_id(data: dict[str, Any]) -> str | None: + if value := _first_string(data, "browseId", "id"): + return value + endpoint = data.get("endpoint") + if isinstance(endpoint, dict): + return _first_string(endpoint, "browseId", "browseID") + return None + + def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: artist_info = data.get("artistInfo") if isinstance(artist_info, dict): artists = artist_info.get("artist") if isinstance(artists, list) and artists and isinstance(artists[0], dict): first = artists[0] - name = first.get("text") - browse_id = first.get("browseId") - if isinstance(name, str) and name: - return name, browse_id if isinstance(browse_id, str) else None + name = _first_string(first, "text", "name") + if name: + return name, _first_string(first, "browseId", "id") for key in ("artist", "author"): value = data.get(key) if isinstance(value, str) and value: return value, None + subtitle = data.get("subtitle") + if isinstance(subtitle, list): + for entry in subtitle: + if not isinstance(entry, dict): + continue + if "ARTIST" in str(entry.get("pageType", "")): + name = _first_string(entry, "text", "name") + if name: + return name, _first_string(entry, "browseId", "id") return "", None + def _extract_duration(data: dict[str, Any]) -> int | None: for key in ("duration", "lengthSeconds"): value = _as_int(data.get(key)) @@ -157,6 +459,18 @@ def _extract_duration(data: dict[str, Any]) -> int | None: return seconds return None + +def _header_title(header: Any) -> str | None: + if not isinstance(header, dict): + return None + value = header.get("title") + if isinstance(value, str): + return value + if isinstance(value, list): + return next((x for x in value if isinstance(x, str) and x), None) + return None + + def _as_int(value: Any) -> int | None: try: return int(value) From 8c7aca22f24f390e36aa7429dbb15733123356db Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 08:58:10 +1000 Subject: [PATCH 12/40] Install Beatbump icon with Music Assistant provider --- music_assistant_provider/install.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/music_assistant_provider/install.sh b/music_assistant_provider/install.sh index 11fd1f0..d59a6a3 100644 --- a/music_assistant_provider/install.sh +++ b/music_assistant_provider/install.sh @@ -4,6 +4,7 @@ set -eu REPO="aaronh86/Beatbump" REF="ma-provider" RAW="https://raw.githubusercontent.com/${REPO}/${REF}/music_assistant_provider/beatbump" +LOGO_RAW="https://raw.githubusercontent.com/${REPO}/main/app/static/logo.svg" MA_DIR="$(python - <<'PY' import os @@ -30,21 +31,20 @@ fetch() { fi } -# The MVP provider implementation is intentionally contained in __init__.py. -# setup_flow.py handles configuration and manifest.json describes the provider. for file in __init__.py setup_flow.py manifest.json; do echo "Downloading ${file}..." fetch "${RAW}/${file}" "${PROVIDER_DIR}/${file}" done -# Remove files from any earlier incomplete installer attempt. -rm -f "${PROVIDER_DIR}/constants.py" "${PROVIDER_DIR}/provider.py" +# Music Assistant natively supports icon.svg in the provider directory. +# Reuse Beatbump's own logo asset rather than maintaining a duplicate copy. +echo "Downloading Beatbump icon..." +fetch "${LOGO_RAW}" "${PROVIDER_DIR}/icon.svg" +rm -f "${PROVIDER_DIR}/constants.py" "${PROVIDER_DIR}/provider.py" python -m compileall -q "${PROVIDER_DIR}" echo echo "Beatbump provider installed successfully." -echo "Restart the Music Assistant container, then go to:" -echo "Settings -> Music Sources -> Add a music source -> Beatbump" -echo -echo "NOTE: this modifies the running container filesystem. A Music Assistant image update/recreation may remove it." +echo "Restart Music Assistant, then add/configure Beatbump under Music Sources." +echo "NOTE: for TrueNAS, use a persistent host-path mount for this provider directory." From 4b4d414a45cfc3376c73a42b044e22ed3df93384 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:05:55 +1000 Subject: [PATCH 13/40] Fix artist IDs resolved from Beatbump player metadata --- music_assistant_provider/beatbump/__init__.py | 399 ++++-------------- 1 file changed, 76 insertions(+), 323 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index 07b6ded..59acce7 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -6,17 +6,7 @@ from urllib.parse import parse_qs, urlparse from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType -from music_assistant_models.media_items import ( - Album, - Artist, - AudioFormat, - ItemMapping, - Playlist, - ProviderMapping, - SearchResults, - Track, - UniqueList, -) +from music_assistant_models.media_items import Artist, AudioFormat, ItemMapping, ProviderMapping, SearchResults, Track, UniqueList from music_assistant_models.streamdetails import StreamDetails from music_assistant.models.music_provider import MusicProvider @@ -29,97 +19,48 @@ CONF_BASE_URL = "base_url" SUPPORTED_FEATURES = {ProviderFeature.SEARCH} - -async def setup( - mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig -) -> ProviderInstanceType: +async def setup(mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig) -> ProviderInstanceType: return BeatbumpProvider(mass, manifest, config, SUPPORTED_FEATURES) - class BeatbumpProvider(MusicProvider): - """Music Assistant provider backed by a self-hosted Beatbump server.""" - @property def is_streaming_provider(self) -> bool: return True @property def supported_media_types(self) -> set[MediaType]: - return {MediaType.ARTIST, MediaType.ALBUM, MediaType.TRACK, MediaType.PLAYLIST} + # Keep the first production version deliberately track-centric. Artist mappings are + # still attached to tracks, but we only expose an artist when we have a real browse id. + return {MediaType.TRACK} @property def base_url(self) -> str: return str(self.get_setup_value(CONF_BASE_URL)).rstrip("/") async def _get_json(self, path: str, **params: str) -> dict[str, Any]: - async with self.mass.http_session.get( - f"{self.base_url}{path}", params=params, timeout=30 - ) as response: + async with self.mass.http_session.get(f"{self.base_url}{path}", params=params, timeout=30) as response: response.raise_for_status() data = await response.json(content_type=None) if not isinstance(data, dict): raise ValueError("Beatbump returned an unexpected response") return data - async def _search_filter(self, query: str, filter_name: str) -> list[dict[str, Any]]: - payload = await self._get_json("/api/v1/search.json", q=query, filter=filter_name) + async def _search_songs(self, query: str) -> list[dict[str, Any]]: + payload = await self._get_json("/api/v1/search.json", q=query, filter="songs") items: list[dict[str, Any]] = [] for shelf in payload.get("results") or []: - if not isinstance(shelf, dict): - continue - for candidate in shelf.get("contents") or []: - if isinstance(candidate, dict): - items.append(candidate) + if isinstance(shelf, dict): + items.extend(x for x in shelf.get("contents") or [] if isinstance(x, dict)) return items - async def search( - self, search_query: str, media_types: list[MediaType], limit: int = 5 - ) -> SearchResults: - """Search each Beatbump filter separately; Beatbump's unfiltered search is unreliable.""" + async def search(self, search_query: str, media_types: list[MediaType], limit: int = 5) -> SearchResults: result = SearchResults() - requested = set(media_types) - - if MediaType.TRACK in requested: - items = await self._search_filter(search_query, "songs") - result.tracks = [ - track - for item in items[:limit] - if (track := self._parse_track(item)) is not None - ] - - if MediaType.ARTIST in requested: - items = await self._search_filter(search_query, "artists") - result.artists = [ - artist - for item in items[:limit] - if (artist := self._parse_artist_search(item)) is not None - ] - - if MediaType.ALBUM in requested: - items = await self._search_filter(search_query, "albums") - result.albums = [ - album - for item in items[:limit] - if (album := self._parse_album_search(item)) is not None - ] - - if MediaType.PLAYLIST in requested: - items = await self._search_filter(search_query, "all_playlists") - result.playlists = [ - playlist - for item in items[:limit] - if (playlist := self._parse_playlist_search(item)) is not None - ] + if MediaType.TRACK not in media_types: + return result + items = await self._search_songs(search_query) + result.tracks = [track for item in items[:limit] if (track := self._parse_track(item))] return result - def _mapping(self, media_type: MediaType, item_id: str, name: str) -> ItemMapping: - return ItemMapping( - media_type=media_type, - item_id=item_id, - provider=self.instance_id, - name=name, - ) - def _provider_mapping(self, item_id: str, audio: bool = False) -> ProviderMapping: return ProviderMapping( item_id=item_id, @@ -129,253 +70,102 @@ def _provider_mapping(self, item_id: str, audio: bool = False) -> ProviderMappin audio_format=AudioFormat(content_type=ContentType.M4A) if audio else None, ) + def _artist_mapping(self, name: str, artist_id: str | None) -> ItemMapping | None: + # A display name is NOT a valid Beatbump artist id. Never manufacture one. + if not artist_id: + return None + return ItemMapping(media_type=MediaType.ARTIST, item_id=artist_id, provider=self.instance_id, name=name) + def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: video_id = _first_string(data, "videoId") - title = _title(data) + title = _first_string(data, "title", "name", "text") if not video_id or not title: return None artist_name, artist_id = _extract_artist(data) - if not artist_name: - # Music Assistant expects tracks to have an artist. YouTube occasionally omits it. - artist_name = "YouTube Music" - artist_id = "youtube-music" + artists = UniqueList() + if artist_name and (mapping := self._artist_mapping(artist_name, artist_id)): + artists.append(mapping) track = Track( item_id=video_id, provider=self.instance_id, name=title, duration=_extract_duration(data), - artists=UniqueList([ - self._mapping(MediaType.ARTIST, artist_id or artist_name, artist_name) - ]), provider_mappings={self._provider_mapping(video_id, audio=True)}, position=position or None, ) - album = data.get("album") - if isinstance(album, dict): - album_name = _first_string(album, "text", "title", "name") - album_id = _first_string(album, "browseId", "id") - if album_name and album_id: - track.album = self._mapping(MediaType.ALBUM, album_id, album_name) + if artists: + track.artists = artists return track - def _parse_artist_search(self, data: dict[str, Any]) -> Artist | None: - name = _title(data) - artist_id = _browse_id(data) - if not name or not artist_id: - return None - return Artist( - item_id=artist_id, - provider=self.instance_id, - name=name, - provider_mappings={self._provider_mapping(artist_id)}, - ) - - def _parse_album_search(self, data: dict[str, Any]) -> Album | None: - name = _title(data) - album_id = _browse_id(data) - if not name or not album_id: - return None - album = Album( - item_id=album_id, - provider=self.instance_id, - name=name, - provider_mappings={self._provider_mapping(album_id)}, - ) - artist_name, artist_id = _extract_artist(data) - if artist_name: - album.artists = UniqueList([ - self._mapping(MediaType.ARTIST, artist_id or artist_name, artist_name) - ]) - return album - - def _parse_playlist_search(self, data: dict[str, Any]) -> Playlist | None: - name = _title(data) - playlist_id = _first_string(data, "playlistId") or _browse_id(data) - if not name or not playlist_id: - return None - return Playlist( - item_id=playlist_id, - provider=self.instance_id, - name=name, - provider_mappings={self._provider_mapping(playlist_id)}, - is_editable=False, - ) - async def get_artist(self, prov_artist_id: str) -> Artist: - """Resolve an artist mapping created by search/track parsing.""" - if prov_artist_id == "youtube-music": - return Artist( - item_id=prov_artist_id, - provider=self.instance_id, - name="YouTube Music", - provider_mappings={self._provider_mapping(prov_artist_id)}, - ) + # This method should now only receive genuine browse/channel ids from song search. + self.logger.info("Beatbump artist lookup: %s", prov_artist_id) payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") header = payload.get("header") or {} - name = _first_string(header, "name") or prov_artist_id - artist = Artist( + name = _first_string(header, "name", "title") or prov_artist_id + return Artist( item_id=prov_artist_id, provider=self.instance_id, name=name, provider_mappings={self._provider_mapping(prov_artist_id)}, ) - if isinstance(header, dict) and isinstance(header.get("description"), str): - artist.metadata.description = header["description"] - return artist - - async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: - payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") - candidates: list[dict[str, Any]] = [] - songs = payload.get("songs") - if isinstance(songs, dict): - candidates.extend(x for x in songs.get("contents") or [] if isinstance(x, dict)) - for carousel in payload.get("carousels") or []: - if not isinstance(carousel, dict): - continue - header = carousel.get("header") or {} - if "song" not in str(header.get("title", "")).lower(): - continue - candidates.extend(x for x in carousel.get("contents") or [] if isinstance(x, dict)) - return [track for item in candidates if (track := self._parse_track(item)) is not None][:25] - - async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: - payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") - albums: list[Album] = [] - for carousel in payload.get("carousels") or []: - if not isinstance(carousel, dict): - continue - header = carousel.get("header") or {} - title = str(header.get("title", "")).lower() - if "album" not in title and "single" not in title: - continue - for item in carousel.get("contents") or []: - if isinstance(item, dict) and (album := self._parse_album_search(item)): - albums.append(album) - return albums - - async def _playlist_payload(self, item_id: str) -> dict[str, Any]: - return await self._get_json("/api/v1/playlist.json", list=item_id) - - async def get_album(self, prov_album_id: str) -> Album: - payload = await self._playlist_payload(prov_album_id) - header = payload.get("header") or {} - title = _header_title(header) or prov_album_id - album = Album( - item_id=prov_album_id, - provider=self.instance_id, - name=title, - provider_mappings={self._provider_mapping(prov_album_id)}, - ) - tracks = payload.get("tracks") or [] - for item in tracks: - if isinstance(item, dict): - artist_name, artist_id = _extract_artist(item) - if artist_name: - album.artists = UniqueList([ - self._mapping(MediaType.ARTIST, artist_id or artist_name, artist_name) - ]) - break - return album - - async def get_album_tracks(self, prov_album_id: str) -> list[Track]: - payload = await self._playlist_payload(prov_album_id) - result: list[Track] = [] - for index, item in enumerate(payload.get("tracks") or [], 1): - if isinstance(item, dict) and (track := self._parse_track(item, index)): - track.album = self._mapping( - MediaType.ALBUM, - prov_album_id, - _header_title(payload.get("header") or {}) or prov_album_id, - ) - result.append(track) - return result - - async def get_playlist(self, prov_playlist_id: str) -> Playlist: - payload = await self._playlist_payload(prov_playlist_id) - header = payload.get("header") or {} - return Playlist( - item_id=prov_playlist_id, - provider=self.instance_id, - name=_header_title(header) or prov_playlist_id, - provider_mappings={self._provider_mapping(prov_playlist_id)}, - is_editable=False, - ) - - async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]: - if page > 0: - return [] - payload = await self._playlist_payload(prov_playlist_id) - return [ - track - for index, item in enumerate(payload.get("tracks") or [], 1) - if isinstance(item, dict) and (track := self._parse_track(item, index)) is not None - ] async def get_track(self, prov_track_id: str) -> Track: payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id) details = payload.get("videoDetails") or {} title = str(details.get("title") or prov_track_id) - artist = str(details.get("author") or "YouTube Music") duration = _as_int(details.get("lengthSeconds")) - return self._parse_track({ - "videoId": prov_track_id, - "title": title, - "artist": artist, - "duration": duration, - }) or Track( + + # player.videoDetails.author is a display string (often "Artist - Topic"), not a + # browse id. Do not turn it into an ItemMapping: doing so made MA request paths such + # as /api/v1/artist/Dire%20Straits%20-%20Topic. Search Beatbump for the exact track + # and recover the real artist browseId when possible. + artist_mapping: ItemMapping | None = None + try: + for candidate in await self._search_songs(title): + if _first_string(candidate, "videoId") != prov_track_id: + continue + artist_name, artist_id = _extract_artist(candidate) + if artist_name: + artist_mapping = self._artist_mapping(artist_name, artist_id) + break + except Exception as err: # metadata enrichment must never prevent playback + self.logger.debug("Could not enrich Beatbump track %s: %s", prov_track_id, err) + + track = Track( item_id=prov_track_id, provider=self.instance_id, name=title, + duration=duration, provider_mappings={self._provider_mapping(prov_track_id, audio=True)}, ) + if artist_mapping: + track.artists = UniqueList([artist_mapping]) + return track async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: - """Resolve a fresh signed YouTube audio URL through Beatbump for every playback.""" payload = await self._get_json("/api/v1/player.json", videoId=item_id) status = (payload.get("playabilityStatus") or {}).get("status") if status and status != "OK": raise ValueError(f"Beatbump reports {item_id} as not playable: {status}") - formats = (payload.get("streamingData") or {}).get("adaptiveFormats") or [] - audio_formats = [ - item for item in formats - if isinstance(item, dict) - and item.get("url") - and "audio" in str(item.get("mimeType", "")).lower() - ] + audio_formats = [x for x in formats if isinstance(x, dict) and x.get("url") and "audio" in str(x.get("mimeType", "")).lower()] if not audio_formats: raise ValueError(f"Beatbump returned no audio stream for {item_id}") - - # Prefer AAC-LC/M4A itag 140 for maximum MA/player compatibility. - aac_140 = [item for item in audio_formats if _as_int(item.get("itag")) == 140] - selected = aac_140[0] if aac_140 else max( - audio_formats, key=lambda item: _as_int(item.get("bitrate")) or 0 - ) + aac = [x for x in audio_formats if _as_int(x.get("itag")) == 140] + selected = aac[0] if aac else max(audio_formats, key=lambda x: _as_int(x.get("bitrate")) or 0) url = str(selected["url"]) mime = str(selected.get("mimeType", "")).lower() - if "audio/mp4" in mime or "m4a" in mime: - content_type = ContentType.M4A - elif "audio/webm" in mime: - content_type = ContentType.WEBM - else: - content_type = ContentType.UNKNOWN - + content_type = ContentType.M4A if "audio/mp4" in mime else ContentType.WEBM if "audio/webm" in mime else ContentType.UNKNOWN expiration = 3600 - query = parse_qs(urlparse(url).query) - if expire := query.get("expire", [None])[0]: + expire = parse_qs(urlparse(url).query).get("expire", [None])[0] + if expire: try: expiration = max(60, int(expire) - int(time.time())) except (TypeError, ValueError): pass duration_ms = _as_int(selected.get("approxDurationMs")) - self.logger.info( - "Beatbump playback %s -> itag=%s mime=%s bitrate=%s expires_in=%ss", - item_id, - selected.get("itag"), - selected.get("mimeType"), - selected.get("bitrate"), - expiration, - ) + self.logger.info("Beatbump playback %s -> itag=%s mime=%s", item_id, selected.get("itag"), selected.get("mimeType")) return StreamDetails( provider=self.instance_id, item_id=item_id, @@ -387,90 +177,53 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea can_seek=True, allow_seek=True, expiration=expiration, - # YouTube serves adaptive streams at approximately playback pace. is_realtime=True, ) - -def _title(data: dict[str, Any]) -> str | None: - return _first_string(data, "title", "name", "text") - - -def _first_string(data: dict[str, Any], *keys: str) -> str | None: +def _first_string(data: Any, *keys: str) -> str | None: + if not isinstance(data, dict): + return None for key in keys: value = data.get(key) if isinstance(value, str) and value: return value return None - -def _browse_id(data: dict[str, Any]) -> str | None: - if value := _first_string(data, "browseId", "id"): - return value - endpoint = data.get("endpoint") - if isinstance(endpoint, dict): - return _first_string(endpoint, "browseId", "browseID") - return None - - def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: - artist_info = data.get("artistInfo") - if isinstance(artist_info, dict): - artists = artist_info.get("artist") + info = data.get("artistInfo") + if isinstance(info, dict): + artists = info.get("artist") if isinstance(artists, list) and artists and isinstance(artists[0], dict): first = artists[0] name = _first_string(first, "text", "name") if name: return name, _first_string(first, "browseId", "id") - for key in ("artist", "author"): - value = data.get(key) - if isinstance(value, str) and value: - return value, None subtitle = data.get("subtitle") if isinstance(subtitle, list): for entry in subtitle: - if not isinstance(entry, dict): - continue - if "ARTIST" in str(entry.get("pageType", "")): + if isinstance(entry, dict) and "ARTIST" in str(entry.get("pageType", "")): name = _first_string(entry, "text", "name") if name: return name, _first_string(entry, "browseId", "id") return "", None - def _extract_duration(data: dict[str, Any]) -> int | None: for key in ("duration", "lengthSeconds"): - value = _as_int(data.get(key)) - if value is not None: + if (value := _as_int(data.get(key))) is not None: return value subtitle = data.get("subtitle") if isinstance(subtitle, list): for entry in reversed(subtitle): - if not isinstance(entry, dict): - continue - text = entry.get("text") - if not isinstance(text, str): - continue - parts = text.split(":") - if len(parts) in (2, 3) and all(part.isdigit() for part in parts): - seconds = 0 - for part in parts: - seconds = seconds * 60 + int(part) - return seconds + text = entry.get("text") if isinstance(entry, dict) else None + if isinstance(text, str): + parts = text.split(":") + if len(parts) in (2, 3) and all(x.isdigit() for x in parts): + seconds = 0 + for part in parts: + seconds = seconds * 60 + int(part) + return seconds return None - -def _header_title(header: Any) -> str | None: - if not isinstance(header, dict): - return None - value = header.get("title") - if isinstance(value, str): - return value - if isinstance(value, list): - return next((x for x in value if isinstance(x, str) and x), None) - return None - - def _as_int(value: Any) -> int | None: try: return int(value) From 531d32c4d0fb4e0b0ba4c3b52b7cc1a5b3ebbd79 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:09:38 +1000 Subject: [PATCH 14/40] Add browsable Beatbump home catalogue to Music Assistant --- music_assistant_provider/beatbump/__init__.py | 86 ++++++++++--------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index 59acce7..bfa04c7 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations import time +from collections.abc import Sequence from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs, urlparse from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType -from music_assistant_models.media_items import Artist, AudioFormat, ItemMapping, ProviderMapping, SearchResults, Track, UniqueList +from music_assistant_models.media_items import Artist, AudioFormat, BrowseFolder, ItemMapping, MediaItemType, ProviderMapping, SearchResults, Track, UniqueList from music_assistant_models.streamdetails import StreamDetails from music_assistant.models.music_provider import MusicProvider @@ -17,7 +18,7 @@ from music_assistant_models.provider import ProviderManifest CONF_BASE_URL = "base_url" -SUPPORTED_FEATURES = {ProviderFeature.SEARCH} +SUPPORTED_FEATURES = {ProviderFeature.SEARCH, ProviderFeature.BROWSE} async def setup(mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig) -> ProviderInstanceType: return BeatbumpProvider(mass, manifest, config, SUPPORTED_FEATURES) @@ -29,8 +30,6 @@ def is_streaming_provider(self) -> bool: @property def supported_media_types(self) -> set[MediaType]: - # Keep the first production version deliberately track-centric. Artist mappings are - # still attached to tracks, but we only expose an artist when we have a real browse id. return {MediaType.TRACK} @property @@ -61,6 +60,43 @@ async def search(self, search_query: str, media_types: list[MediaType], limit: i result.tracks = [track for item in items[:limit] if (track := self._parse_track(item))] return result + async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: + """Expose Beatbump as a browsable provider, starting with its native Home feed.""" + base = f"{self.instance_id}://" + subpath = path.split("://", 1)[1].strip("/") if "://" in path else "" + if not subpath: + return [ + BrowseFolder( + item_id="home", + provider=self.domain, + path=f"{base}home", + name="Beatbump Home", + ) + ] + if subpath != "home": + return [] + + payload = await self._get_json("/api/v1/home.json") + result: list[MediaItemType | BrowseFolder] = [] + seen: set[str] = set() + # Beatbump Home is returned as carousels. For the first stable browse version, + # flatten playable song/video entries into MA rather than exposing unsupported + # album/playlist objects. This keeps the already-working playback path intact. + for carousel in payload.get("carousels") or []: + if not isinstance(carousel, dict): + continue + for item in carousel.get("contents") or []: + if not isinstance(item, dict): + continue + video_id = _first_string(item, "videoId") + if not video_id or video_id in seen: + continue + track = self._parse_track(item) + if track: + seen.add(video_id) + result.append(track) + return result + def _provider_mapping(self, item_id: str, audio: bool = False) -> ProviderMapping: return ProviderMapping( item_id=item_id, @@ -71,7 +107,8 @@ def _provider_mapping(self, item_id: str, audio: bool = False) -> ProviderMappin ) def _artist_mapping(self, name: str, artist_id: str | None) -> ItemMapping | None: - # A display name is NOT a valid Beatbump artist id. Never manufacture one. + # Never manufacture an artist id from a display name. Beatbump's artist route + # requires a real YouTube Music browse/channel id. if not artist_id: return None return ItemMapping(media_type=MediaType.ARTIST, item_id=artist_id, provider=self.instance_id, name=name) @@ -98,28 +135,16 @@ def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: return track async def get_artist(self, prov_artist_id: str) -> Artist: - # This method should now only receive genuine browse/channel ids from song search. - self.logger.info("Beatbump artist lookup: %s", prov_artist_id) payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") header = payload.get("header") or {} name = _first_string(header, "name", "title") or prov_artist_id - return Artist( - item_id=prov_artist_id, - provider=self.instance_id, - name=name, - provider_mappings={self._provider_mapping(prov_artist_id)}, - ) + return Artist(item_id=prov_artist_id, provider=self.instance_id, name=name, provider_mappings={self._provider_mapping(prov_artist_id)}) async def get_track(self, prov_track_id: str) -> Track: payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id) details = payload.get("videoDetails") or {} title = str(details.get("title") or prov_track_id) duration = _as_int(details.get("lengthSeconds")) - - # player.videoDetails.author is a display string (often "Artist - Topic"), not a - # browse id. Do not turn it into an ItemMapping: doing so made MA request paths such - # as /api/v1/artist/Dire%20Straits%20-%20Topic. Search Beatbump for the exact track - # and recover the real artist browseId when possible. artist_mapping: ItemMapping | None = None try: for candidate in await self._search_songs(title): @@ -129,16 +154,9 @@ async def get_track(self, prov_track_id: str) -> Track: if artist_name: artist_mapping = self._artist_mapping(artist_name, artist_id) break - except Exception as err: # metadata enrichment must never prevent playback + except Exception as err: self.logger.debug("Could not enrich Beatbump track %s: %s", prov_track_id, err) - - track = Track( - item_id=prov_track_id, - provider=self.instance_id, - name=title, - duration=duration, - provider_mappings={self._provider_mapping(prov_track_id, audio=True)}, - ) + track = Track(item_id=prov_track_id, provider=self.instance_id, name=title, duration=duration, provider_mappings={self._provider_mapping(prov_track_id, audio=True)}) if artist_mapping: track.artists = UniqueList([artist_mapping]) return track @@ -166,19 +184,7 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea pass duration_ms = _as_int(selected.get("approxDurationMs")) self.logger.info("Beatbump playback %s -> itag=%s mime=%s", item_id, selected.get("itag"), selected.get("mimeType")) - return StreamDetails( - provider=self.instance_id, - item_id=item_id, - media_type=MediaType.TRACK, - stream_type=StreamType.HTTP, - path=url, - audio_format=AudioFormat(content_type=content_type), - duration=(duration_ms / 1000) if duration_ms else None, - can_seek=True, - allow_seek=True, - expiration=expiration, - is_realtime=True, - ) + return StreamDetails(provider=self.instance_id, item_id=item_id, media_type=MediaType.TRACK, stream_type=StreamType.HTTP, path=url, audio_format=AudioFormat(content_type=content_type), duration=(duration_ms / 1000) if duration_ms else None, can_seek=True, allow_seek=True, expiration=expiration, is_realtime=True) def _first_string(data: Any, *keys: str) -> str | None: if not isinstance(data, dict): From dc1db949c8066594f3217fcac067d5110cac1664 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:10:06 +1000 Subject: [PATCH 15/40] Use provider instance for Beatbump browse folder --- music_assistant_provider/beatbump/__init__.py | 274 ++++++------------ 1 file changed, 84 insertions(+), 190 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index bfa04c7..7ca3365 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -1,237 +1,131 @@ """Beatbump music provider for Music Assistant.""" from __future__ import annotations - import time from collections.abc import Sequence from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs, urlparse - from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType from music_assistant_models.media_items import Artist, AudioFormat, BrowseFolder, ItemMapping, MediaItemType, ProviderMapping, SearchResults, Track, UniqueList from music_assistant_models.streamdetails import StreamDetails from music_assistant.models.music_provider import MusicProvider - if TYPE_CHECKING: from music_assistant.mass import MusicAssistant from music_assistant.models import ProviderInstanceType from music_assistant_models.config_entries import ProviderConfig from music_assistant_models.provider import ProviderManifest - CONF_BASE_URL = "base_url" SUPPORTED_FEATURES = {ProviderFeature.SEARCH, ProviderFeature.BROWSE} - async def setup(mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig) -> ProviderInstanceType: return BeatbumpProvider(mass, manifest, config, SUPPORTED_FEATURES) - class BeatbumpProvider(MusicProvider): @property - def is_streaming_provider(self) -> bool: - return True - + def is_streaming_provider(self) -> bool: return True @property - def supported_media_types(self) -> set[MediaType]: - return {MediaType.TRACK} - + def supported_media_types(self) -> set[MediaType]: return {MediaType.TRACK} @property - def base_url(self) -> str: - return str(self.get_setup_value(CONF_BASE_URL)).rstrip("/") - + def base_url(self) -> str: return str(self.get_setup_value(CONF_BASE_URL)).rstrip("/") async def _get_json(self, path: str, **params: str) -> dict[str, Any]: async with self.mass.http_session.get(f"{self.base_url}{path}", params=params, timeout=30) as response: - response.raise_for_status() - data = await response.json(content_type=None) - if not isinstance(data, dict): - raise ValueError("Beatbump returned an unexpected response") + response.raise_for_status(); data = await response.json(content_type=None) + if not isinstance(data, dict): raise ValueError("Beatbump returned an unexpected response") return data - async def _search_songs(self, query: str) -> list[dict[str, Any]]: - payload = await self._get_json("/api/v1/search.json", q=query, filter="songs") - items: list[dict[str, Any]] = [] + payload = await self._get_json("/api/v1/search.json", q=query, filter="songs"); items=[] for shelf in payload.get("results") or []: - if isinstance(shelf, dict): - items.extend(x for x in shelf.get("contents") or [] if isinstance(x, dict)) + if isinstance(shelf, dict): items.extend(x for x in shelf.get("contents") or [] if isinstance(x, dict)) return items - async def search(self, search_query: str, media_types: list[MediaType], limit: int = 5) -> SearchResults: - result = SearchResults() - if MediaType.TRACK not in media_types: - return result - items = await self._search_songs(search_query) - result.tracks = [track for item in items[:limit] if (track := self._parse_track(item))] + result=SearchResults() + if MediaType.TRACK in media_types: + items=await self._search_songs(search_query); result.tracks=[t for item in items[:limit] if (t:=self._parse_track(item))] return result - async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: - """Expose Beatbump as a browsable provider, starting with its native Home feed.""" - base = f"{self.instance_id}://" - subpath = path.split("://", 1)[1].strip("/") if "://" in path else "" + base=f"{self.instance_id}://"; subpath=path.split("://",1)[1].strip("/") if "://" in path else "" if not subpath: - return [ - BrowseFolder( - item_id="home", - provider=self.domain, - path=f"{base}home", - name="Beatbump Home", - ) - ] - if subpath != "home": - return [] - - payload = await self._get_json("/api/v1/home.json") - result: list[MediaItemType | BrowseFolder] = [] - seen: set[str] = set() - # Beatbump Home is returned as carousels. For the first stable browse version, - # flatten playable song/video entries into MA rather than exposing unsupported - # album/playlist objects. This keeps the already-working playback path intact. + return [BrowseFolder(item_id="home", provider=self.instance_id, path=f"{base}home", name="Beatbump Home")] + if subpath != "home": return [] + payload=await self._get_json("/api/v1/home.json"); result=[]; seen=set() for carousel in payload.get("carousels") or []: - if not isinstance(carousel, dict): - continue + if not isinstance(carousel,dict): continue for item in carousel.get("contents") or []: - if not isinstance(item, dict): - continue - video_id = _first_string(item, "videoId") - if not video_id or video_id in seen: - continue - track = self._parse_track(item) - if track: - seen.add(video_id) - result.append(track) + if not isinstance(item,dict): continue + vid=_first_string(item,"videoId") + if not vid or vid in seen: continue + track=self._parse_track(item) + if track: seen.add(vid); result.append(track) return result - - def _provider_mapping(self, item_id: str, audio: bool = False) -> ProviderMapping: - return ProviderMapping( - item_id=item_id, - provider_domain=self.domain, - provider_instance=self.instance_id, - available=True, - audio_format=AudioFormat(content_type=ContentType.M4A) if audio else None, - ) - - def _artist_mapping(self, name: str, artist_id: str | None) -> ItemMapping | None: - # Never manufacture an artist id from a display name. Beatbump's artist route - # requires a real YouTube Music browse/channel id. - if not artist_id: - return None - return ItemMapping(media_type=MediaType.ARTIST, item_id=artist_id, provider=self.instance_id, name=name) - - def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: - video_id = _first_string(data, "videoId") - title = _first_string(data, "title", "name", "text") - if not video_id or not title: - return None - artist_name, artist_id = _extract_artist(data) - artists = UniqueList() - if artist_name and (mapping := self._artist_mapping(artist_name, artist_id)): - artists.append(mapping) - track = Track( - item_id=video_id, - provider=self.instance_id, - name=title, - duration=_extract_duration(data), - provider_mappings={self._provider_mapping(video_id, audio=True)}, - position=position or None, - ) - if artists: - track.artists = artists + def _provider_mapping(self,item_id:str,audio:bool=False)->ProviderMapping: + return ProviderMapping(item_id=item_id,provider_domain=self.domain,provider_instance=self.instance_id,available=True,audio_format=AudioFormat(content_type=ContentType.M4A) if audio else None) + def _artist_mapping(self,name:str,artist_id:str|None)->ItemMapping|None: + if not artist_id:return None + return ItemMapping(media_type=MediaType.ARTIST,item_id=artist_id,provider=self.instance_id,name=name) + def _parse_track(self,data:dict[str,Any],position:int=0)->Track|None: + vid=_first_string(data,"videoId"); title=_first_string(data,"title","name","text") + if not vid or not title:return None + artist_name,artist_id=_extract_artist(data); artists=UniqueList(); mapping=self._artist_mapping(artist_name,artist_id) if artist_name else None + if mapping:artists.append(mapping) + track=Track(item_id=vid,provider=self.instance_id,name=title,duration=_extract_duration(data),provider_mappings={self._provider_mapping(vid,True)},position=position or None) + if artists:track.artists=artists return track - - async def get_artist(self, prov_artist_id: str) -> Artist: - payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") - header = payload.get("header") or {} - name = _first_string(header, "name", "title") or prov_artist_id - return Artist(item_id=prov_artist_id, provider=self.instance_id, name=name, provider_mappings={self._provider_mapping(prov_artist_id)}) - - async def get_track(self, prov_track_id: str) -> Track: - payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id) - details = payload.get("videoDetails") or {} - title = str(details.get("title") or prov_track_id) - duration = _as_int(details.get("lengthSeconds")) - artist_mapping: ItemMapping | None = None + async def get_artist(self,prov_artist_id:str)->Artist: + payload=await self._get_json(f"/api/v1/artist/{prov_artist_id}"); header=payload.get("header") or {}; name=_first_string(header,"name","title") or prov_artist_id + return Artist(item_id=prov_artist_id,provider=self.instance_id,name=name,provider_mappings={self._provider_mapping(prov_artist_id)}) + async def get_track(self,prov_track_id:str)->Track: + payload=await self._get_json("/api/v1/player.json",videoId=prov_track_id); details=payload.get("videoDetails") or {}; title=str(details.get("title") or prov_track_id); duration=_as_int(details.get("lengthSeconds")); artist_mapping=None try: for candidate in await self._search_songs(title): - if _first_string(candidate, "videoId") != prov_track_id: - continue - artist_name, artist_id = _extract_artist(candidate) - if artist_name: - artist_mapping = self._artist_mapping(artist_name, artist_id) - break - except Exception as err: - self.logger.debug("Could not enrich Beatbump track %s: %s", prov_track_id, err) - track = Track(item_id=prov_track_id, provider=self.instance_id, name=title, duration=duration, provider_mappings={self._provider_mapping(prov_track_id, audio=True)}) - if artist_mapping: - track.artists = UniqueList([artist_mapping]) + if _first_string(candidate,"videoId")!=prov_track_id:continue + artist_name,artist_id=_extract_artist(candidate); artist_mapping=self._artist_mapping(artist_name,artist_id) if artist_name else None; break + except Exception as err:self.logger.debug("Could not enrich Beatbump track %s: %s",prov_track_id,err) + track=Track(item_id=prov_track_id,provider=self.instance_id,name=title,duration=duration,provider_mappings={self._provider_mapping(prov_track_id,True)}) + if artist_mapping:track.artists=UniqueList([artist_mapping]) return track - - async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: - payload = await self._get_json("/api/v1/player.json", videoId=item_id) - status = (payload.get("playabilityStatus") or {}).get("status") - if status and status != "OK": - raise ValueError(f"Beatbump reports {item_id} as not playable: {status}") - formats = (payload.get("streamingData") or {}).get("adaptiveFormats") or [] - audio_formats = [x for x in formats if isinstance(x, dict) and x.get("url") and "audio" in str(x.get("mimeType", "")).lower()] - if not audio_formats: - raise ValueError(f"Beatbump returned no audio stream for {item_id}") - aac = [x for x in audio_formats if _as_int(x.get("itag")) == 140] - selected = aac[0] if aac else max(audio_formats, key=lambda x: _as_int(x.get("bitrate")) or 0) - url = str(selected["url"]) - mime = str(selected.get("mimeType", "")).lower() - content_type = ContentType.M4A if "audio/mp4" in mime else ContentType.WEBM if "audio/webm" in mime else ContentType.UNKNOWN - expiration = 3600 - expire = parse_qs(urlparse(url).query).get("expire", [None])[0] + async def get_stream_details(self,item_id:str,media_type:MediaType)->StreamDetails: + payload=await self._get_json("/api/v1/player.json",videoId=item_id); status=(payload.get("playabilityStatus") or {}).get("status") + if status and status!="OK":raise ValueError(f"Beatbump reports {item_id} as not playable: {status}") + formats=(payload.get("streamingData") or {}).get("adaptiveFormats") or []; audio=[x for x in formats if isinstance(x,dict) and x.get("url") and "audio" in str(x.get("mimeType","")).lower()] + if not audio:raise ValueError(f"Beatbump returned no audio stream for {item_id}") + aac=[x for x in audio if _as_int(x.get("itag"))==140]; selected=aac[0] if aac else max(audio,key=lambda x:_as_int(x.get("bitrate")) or 0); url=str(selected["url"]); mime=str(selected.get("mimeType","")).lower(); ctype=ContentType.M4A if "audio/mp4" in mime else ContentType.WEBM if "audio/webm" in mime else ContentType.UNKNOWN; expiration=3600; expire=parse_qs(urlparse(url).query).get("expire",[None])[0] if expire: - try: - expiration = max(60, int(expire) - int(time.time())) - except (TypeError, ValueError): - pass - duration_ms = _as_int(selected.get("approxDurationMs")) - self.logger.info("Beatbump playback %s -> itag=%s mime=%s", item_id, selected.get("itag"), selected.get("mimeType")) - return StreamDetails(provider=self.instance_id, item_id=item_id, media_type=MediaType.TRACK, stream_type=StreamType.HTTP, path=url, audio_format=AudioFormat(content_type=content_type), duration=(duration_ms / 1000) if duration_ms else None, can_seek=True, allow_seek=True, expiration=expiration, is_realtime=True) - -def _first_string(data: Any, *keys: str) -> str | None: - if not isinstance(data, dict): - return None + try:expiration=max(60,int(expire)-int(time.time())) + except (TypeError,ValueError):pass + duration_ms=_as_int(selected.get("approxDurationMs")); self.logger.info("Beatbump playback %s -> itag=%s mime=%s",item_id,selected.get("itag"),selected.get("mimeType")) + return StreamDetails(provider=self.instance_id,item_id=item_id,media_type=MediaType.TRACK,stream_type=StreamType.HTTP,path=url,audio_format=AudioFormat(content_type=ctype),duration=(duration_ms/1000) if duration_ms else None,can_seek=True,allow_seek=True,expiration=expiration,is_realtime=True) +def _first_string(data:Any,*keys:str)->str|None: + if not isinstance(data,dict):return None for key in keys: - value = data.get(key) - if isinstance(value, str) and value: - return value + value=data.get(key) + if isinstance(value,str) and value:return value return None - -def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: - info = data.get("artistInfo") - if isinstance(info, dict): - artists = info.get("artist") - if isinstance(artists, list) and artists and isinstance(artists[0], dict): - first = artists[0] - name = _first_string(first, "text", "name") - if name: - return name, _first_string(first, "browseId", "id") - subtitle = data.get("subtitle") - if isinstance(subtitle, list): +def _extract_artist(data:dict[str,Any])->tuple[str,str|None]: + info=data.get("artistInfo") + if isinstance(info,dict): + artists=info.get("artist") + if isinstance(artists,list) and artists and isinstance(artists[0],dict): + first=artists[0]; name=_first_string(first,"text","name") + if name:return name,_first_string(first,"browseId","id") + subtitle=data.get("subtitle") + if isinstance(subtitle,list): for entry in subtitle: - if isinstance(entry, dict) and "ARTIST" in str(entry.get("pageType", "")): - name = _first_string(entry, "text", "name") - if name: - return name, _first_string(entry, "browseId", "id") - return "", None - -def _extract_duration(data: dict[str, Any]) -> int | None: - for key in ("duration", "lengthSeconds"): - if (value := _as_int(data.get(key))) is not None: - return value - subtitle = data.get("subtitle") - if isinstance(subtitle, list): + if isinstance(entry,dict) and "ARTIST" in str(entry.get("pageType","")): + name=_first_string(entry,"text","name") + if name:return name,_first_string(entry,"browseId","id") + return "",None +def _extract_duration(data:dict[str,Any])->int|None: + for key in ("duration","lengthSeconds"): + value=_as_int(data.get(key)) + if value is not None:return value + subtitle=data.get("subtitle") + if isinstance(subtitle,list): for entry in reversed(subtitle): - text = entry.get("text") if isinstance(entry, dict) else None - if isinstance(text, str): - parts = text.split(":") - if len(parts) in (2, 3) and all(x.isdigit() for x in parts): - seconds = 0 - for part in parts: - seconds = seconds * 60 + int(part) + text=entry.get("text") if isinstance(entry,dict) else None + if isinstance(text,str): + parts=text.split(":") + if len(parts) in (2,3) and all(x.isdigit() for x in parts): + seconds=0 + for part in parts:seconds=seconds*60+int(part) return seconds return None - -def _as_int(value: Any) -> int | None: - try: - return int(value) - except (TypeError, ValueError): - return None +def _as_int(value:Any)->int|None: + try:return int(value) + except (TypeError,ValueError):return None From 39fb6ad95607107a410451213e76ed45b1e1f9d0 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:10:32 +1000 Subject: [PATCH 16/40] Format Beatbump browse provider --- music_assistant_provider/beatbump/__init__.py | 248 ++++++++++++------ 1 file changed, 165 insertions(+), 83 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index 7ca3365..9a2c3e5 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -1,131 +1,213 @@ """Beatbump music provider for Music Assistant.""" from __future__ import annotations + import time from collections.abc import Sequence from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs, urlparse + from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType -from music_assistant_models.media_items import Artist, AudioFormat, BrowseFolder, ItemMapping, MediaItemType, ProviderMapping, SearchResults, Track, UniqueList +from music_assistant_models.media_items import ( + Artist, AudioFormat, BrowseFolder, ItemMapping, MediaItemType, + ProviderMapping, SearchResults, Track, UniqueList, +) from music_assistant_models.streamdetails import StreamDetails from music_assistant.models.music_provider import MusicProvider + if TYPE_CHECKING: from music_assistant.mass import MusicAssistant from music_assistant.models import ProviderInstanceType from music_assistant_models.config_entries import ProviderConfig from music_assistant_models.provider import ProviderManifest + CONF_BASE_URL = "base_url" SUPPORTED_FEATURES = {ProviderFeature.SEARCH, ProviderFeature.BROWSE} + async def setup(mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig) -> ProviderInstanceType: return BeatbumpProvider(mass, manifest, config, SUPPORTED_FEATURES) + class BeatbumpProvider(MusicProvider): @property - def is_streaming_provider(self) -> bool: return True + def is_streaming_provider(self) -> bool: + return True + @property - def supported_media_types(self) -> set[MediaType]: return {MediaType.TRACK} + def supported_media_types(self) -> set[MediaType]: + return {MediaType.TRACK} + @property - def base_url(self) -> str: return str(self.get_setup_value(CONF_BASE_URL)).rstrip("/") + def base_url(self) -> str: + return str(self.get_setup_value(CONF_BASE_URL)).rstrip("/") + async def _get_json(self, path: str, **params: str) -> dict[str, Any]: async with self.mass.http_session.get(f"{self.base_url}{path}", params=params, timeout=30) as response: - response.raise_for_status(); data = await response.json(content_type=None) - if not isinstance(data, dict): raise ValueError("Beatbump returned an unexpected response") + response.raise_for_status() + data = await response.json(content_type=None) + if not isinstance(data, dict): + raise ValueError("Beatbump returned an unexpected response") return data + async def _search_songs(self, query: str) -> list[dict[str, Any]]: - payload = await self._get_json("/api/v1/search.json", q=query, filter="songs"); items=[] + payload = await self._get_json("/api/v1/search.json", q=query, filter="songs") + items: list[dict[str, Any]] = [] for shelf in payload.get("results") or []: - if isinstance(shelf, dict): items.extend(x for x in shelf.get("contents") or [] if isinstance(x, dict)) + if isinstance(shelf, dict): + items.extend(x for x in shelf.get("contents") or [] if isinstance(x, dict)) return items + async def search(self, search_query: str, media_types: list[MediaType], limit: int = 5) -> SearchResults: - result=SearchResults() + result = SearchResults() if MediaType.TRACK in media_types: - items=await self._search_songs(search_query); result.tracks=[t for item in items[:limit] if (t:=self._parse_track(item))] + items = await self._search_songs(search_query) + result.tracks = [track for item in items[:limit] if (track := self._parse_track(item))] return result + async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: - base=f"{self.instance_id}://"; subpath=path.split("://",1)[1].strip("/") if "://" in path else "" + """Browse Beatbump's native Home feed from Music Assistant.""" + base = f"{self.instance_id}://" + subpath = path.split("://", 1)[1].strip("/") if "://" in path else "" if not subpath: return [BrowseFolder(item_id="home", provider=self.instance_id, path=f"{base}home", name="Beatbump Home")] - if subpath != "home": return [] - payload=await self._get_json("/api/v1/home.json"); result=[]; seen=set() + if subpath != "home": + return [] + payload = await self._get_json("/api/v1/home.json") + result: list[MediaItemType | BrowseFolder] = [] + seen: set[str] = set() for carousel in payload.get("carousels") or []: - if not isinstance(carousel,dict): continue + if not isinstance(carousel, dict): + continue for item in carousel.get("contents") or []: - if not isinstance(item,dict): continue - vid=_first_string(item,"videoId") - if not vid or vid in seen: continue - track=self._parse_track(item) - if track: seen.add(vid); result.append(track) + if not isinstance(item, dict): + continue + video_id = _first_string(item, "videoId") + if not video_id or video_id in seen: + continue + track = self._parse_track(item) + if track: + seen.add(video_id) + result.append(track) return result - def _provider_mapping(self,item_id:str,audio:bool=False)->ProviderMapping: - return ProviderMapping(item_id=item_id,provider_domain=self.domain,provider_instance=self.instance_id,available=True,audio_format=AudioFormat(content_type=ContentType.M4A) if audio else None) - def _artist_mapping(self,name:str,artist_id:str|None)->ItemMapping|None: - if not artist_id:return None - return ItemMapping(media_type=MediaType.ARTIST,item_id=artist_id,provider=self.instance_id,name=name) - def _parse_track(self,data:dict[str,Any],position:int=0)->Track|None: - vid=_first_string(data,"videoId"); title=_first_string(data,"title","name","text") - if not vid or not title:return None - artist_name,artist_id=_extract_artist(data); artists=UniqueList(); mapping=self._artist_mapping(artist_name,artist_id) if artist_name else None - if mapping:artists.append(mapping) - track=Track(item_id=vid,provider=self.instance_id,name=title,duration=_extract_duration(data),provider_mappings={self._provider_mapping(vid,True)},position=position or None) - if artists:track.artists=artists + + def _provider_mapping(self, item_id: str, audio: bool = False) -> ProviderMapping: + return ProviderMapping(item_id=item_id, provider_domain=self.domain, provider_instance=self.instance_id, available=True, audio_format=AudioFormat(content_type=ContentType.M4A) if audio else None) + + def _artist_mapping(self, name: str, artist_id: str | None) -> ItemMapping | None: + if not artist_id: + return None + return ItemMapping(media_type=MediaType.ARTIST, item_id=artist_id, provider=self.instance_id, name=name) + + def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: + video_id = _first_string(data, "videoId") + title = _first_string(data, "title", "name", "text") + if not video_id or not title: + return None + artist_name, artist_id = _extract_artist(data) + artists = UniqueList() + if artist_name and (mapping := self._artist_mapping(artist_name, artist_id)): + artists.append(mapping) + track = Track(item_id=video_id, provider=self.instance_id, name=title, duration=_extract_duration(data), provider_mappings={self._provider_mapping(video_id, True)}, position=position or None) + if artists: + track.artists = artists return track - async def get_artist(self,prov_artist_id:str)->Artist: - payload=await self._get_json(f"/api/v1/artist/{prov_artist_id}"); header=payload.get("header") or {}; name=_first_string(header,"name","title") or prov_artist_id - return Artist(item_id=prov_artist_id,provider=self.instance_id,name=name,provider_mappings={self._provider_mapping(prov_artist_id)}) - async def get_track(self,prov_track_id:str)->Track: - payload=await self._get_json("/api/v1/player.json",videoId=prov_track_id); details=payload.get("videoDetails") or {}; title=str(details.get("title") or prov_track_id); duration=_as_int(details.get("lengthSeconds")); artist_mapping=None + + async def get_artist(self, prov_artist_id: str) -> Artist: + payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") + header = payload.get("header") or {} + name = _first_string(header, "name", "title") or prov_artist_id + return Artist(item_id=prov_artist_id, provider=self.instance_id, name=name, provider_mappings={self._provider_mapping(prov_artist_id)}) + + async def get_track(self, prov_track_id: str) -> Track: + payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id) + details = payload.get("videoDetails") or {} + title = str(details.get("title") or prov_track_id) + duration = _as_int(details.get("lengthSeconds")) + artist_mapping: ItemMapping | None = None try: for candidate in await self._search_songs(title): - if _first_string(candidate,"videoId")!=prov_track_id:continue - artist_name,artist_id=_extract_artist(candidate); artist_mapping=self._artist_mapping(artist_name,artist_id) if artist_name else None; break - except Exception as err:self.logger.debug("Could not enrich Beatbump track %s: %s",prov_track_id,err) - track=Track(item_id=prov_track_id,provider=self.instance_id,name=title,duration=duration,provider_mappings={self._provider_mapping(prov_track_id,True)}) - if artist_mapping:track.artists=UniqueList([artist_mapping]) + if _first_string(candidate, "videoId") != prov_track_id: + continue + artist_name, artist_id = _extract_artist(candidate) + artist_mapping = self._artist_mapping(artist_name, artist_id) if artist_name else None + break + except Exception as err: + self.logger.debug("Could not enrich Beatbump track %s: %s", prov_track_id, err) + track = Track(item_id=prov_track_id, provider=self.instance_id, name=title, duration=duration, provider_mappings={self._provider_mapping(prov_track_id, True)}) + if artist_mapping: + track.artists = UniqueList([artist_mapping]) return track - async def get_stream_details(self,item_id:str,media_type:MediaType)->StreamDetails: - payload=await self._get_json("/api/v1/player.json",videoId=item_id); status=(payload.get("playabilityStatus") or {}).get("status") - if status and status!="OK":raise ValueError(f"Beatbump reports {item_id} as not playable: {status}") - formats=(payload.get("streamingData") or {}).get("adaptiveFormats") or []; audio=[x for x in formats if isinstance(x,dict) and x.get("url") and "audio" in str(x.get("mimeType","")).lower()] - if not audio:raise ValueError(f"Beatbump returned no audio stream for {item_id}") - aac=[x for x in audio if _as_int(x.get("itag"))==140]; selected=aac[0] if aac else max(audio,key=lambda x:_as_int(x.get("bitrate")) or 0); url=str(selected["url"]); mime=str(selected.get("mimeType","")).lower(); ctype=ContentType.M4A if "audio/mp4" in mime else ContentType.WEBM if "audio/webm" in mime else ContentType.UNKNOWN; expiration=3600; expire=parse_qs(urlparse(url).query).get("expire",[None])[0] + + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + payload = await self._get_json("/api/v1/player.json", videoId=item_id) + status = (payload.get("playabilityStatus") or {}).get("status") + if status and status != "OK": + raise ValueError(f"Beatbump reports {item_id} as not playable: {status}") + formats = (payload.get("streamingData") or {}).get("adaptiveFormats") or [] + audio = [x for x in formats if isinstance(x, dict) and x.get("url") and "audio" in str(x.get("mimeType", "")).lower()] + if not audio: + raise ValueError(f"Beatbump returned no audio stream for {item_id}") + aac = [x for x in audio if _as_int(x.get("itag")) == 140] + selected = aac[0] if aac else max(audio, key=lambda x: _as_int(x.get("bitrate")) or 0) + url = str(selected["url"]) + mime = str(selected.get("mimeType", "")).lower() + content_type = ContentType.M4A if "audio/mp4" in mime else ContentType.WEBM if "audio/webm" in mime else ContentType.UNKNOWN + expiration = 3600 + expire = parse_qs(urlparse(url).query).get("expire", [None])[0] if expire: - try:expiration=max(60,int(expire)-int(time.time())) - except (TypeError,ValueError):pass - duration_ms=_as_int(selected.get("approxDurationMs")); self.logger.info("Beatbump playback %s -> itag=%s mime=%s",item_id,selected.get("itag"),selected.get("mimeType")) - return StreamDetails(provider=self.instance_id,item_id=item_id,media_type=MediaType.TRACK,stream_type=StreamType.HTTP,path=url,audio_format=AudioFormat(content_type=ctype),duration=(duration_ms/1000) if duration_ms else None,can_seek=True,allow_seek=True,expiration=expiration,is_realtime=True) -def _first_string(data:Any,*keys:str)->str|None: - if not isinstance(data,dict):return None + try: + expiration = max(60, int(expire) - int(time.time())) + except (TypeError, ValueError): + pass + duration_ms = _as_int(selected.get("approxDurationMs")) + self.logger.info("Beatbump playback %s -> itag=%s mime=%s", item_id, selected.get("itag"), selected.get("mimeType")) + return StreamDetails(provider=self.instance_id, item_id=item_id, media_type=MediaType.TRACK, stream_type=StreamType.HTTP, path=url, audio_format=AudioFormat(content_type=content_type), duration=(duration_ms / 1000) if duration_ms else None, can_seek=True, allow_seek=True, expiration=expiration, is_realtime=True) + +def _first_string(data: Any, *keys: str) -> str | None: + if not isinstance(data, dict): + return None for key in keys: - value=data.get(key) - if isinstance(value,str) and value:return value + value = data.get(key) + if isinstance(value, str) and value: + return value return None -def _extract_artist(data:dict[str,Any])->tuple[str,str|None]: - info=data.get("artistInfo") - if isinstance(info,dict): - artists=info.get("artist") - if isinstance(artists,list) and artists and isinstance(artists[0],dict): - first=artists[0]; name=_first_string(first,"text","name") - if name:return name,_first_string(first,"browseId","id") - subtitle=data.get("subtitle") - if isinstance(subtitle,list): + +def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: + info = data.get("artistInfo") + if isinstance(info, dict): + artists = info.get("artist") + if isinstance(artists, list) and artists and isinstance(artists[0], dict): + first = artists[0] + name = _first_string(first, "text", "name") + if name: + return name, _first_string(first, "browseId", "id") + subtitle = data.get("subtitle") + if isinstance(subtitle, list): for entry in subtitle: - if isinstance(entry,dict) and "ARTIST" in str(entry.get("pageType","")): - name=_first_string(entry,"text","name") - if name:return name,_first_string(entry,"browseId","id") - return "",None -def _extract_duration(data:dict[str,Any])->int|None: - for key in ("duration","lengthSeconds"): - value=_as_int(data.get(key)) - if value is not None:return value - subtitle=data.get("subtitle") - if isinstance(subtitle,list): + if isinstance(entry, dict) and "ARTIST" in str(entry.get("pageType", "")): + name = _first_string(entry, "text", "name") + if name: + return name, _first_string(entry, "browseId", "id") + return "", None + +def _extract_duration(data: dict[str, Any]) -> int | None: + for key in ("duration", "lengthSeconds"): + value = _as_int(data.get(key)) + if value is not None: + return value + subtitle = data.get("subtitle") + if isinstance(subtitle, list): for entry in reversed(subtitle): - text=entry.get("text") if isinstance(entry,dict) else None - if isinstance(text,str): - parts=text.split(":") - if len(parts) in (2,3) and all(x.isdigit() for x in parts): - seconds=0 - for part in parts:seconds=seconds*60+int(part) + text = entry.get("text") if isinstance(entry, dict) else None + if isinstance(text, str): + parts = text.split(":") + if len(parts) in (2, 3) and all(x.isdigit() for x in parts): + seconds = 0 + for part in parts: + seconds = seconds * 60 + int(part) return seconds return None -def _as_int(value:Any)->int|None: - try:return int(value) - except (TypeError,ValueError):return None + +def _as_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None From cee568fc9456c24da2bad4bd1e5aeed7a34dbda9 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:25:22 +1000 Subject: [PATCH 17/40] Expand Beatbump browse with Home, Trending and Explore --- music_assistant_provider/beatbump/__init__.py | 259 +++++++++++++++--- 1 file changed, 216 insertions(+), 43 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index 9a2c3e5..90d20e7 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -4,12 +4,19 @@ import time from collections.abc import Sequence from typing import TYPE_CHECKING, Any -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, quote, unquote, urlparse from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType from music_assistant_models.media_items import ( - Artist, AudioFormat, BrowseFolder, ItemMapping, MediaItemType, - ProviderMapping, SearchResults, Track, UniqueList, + Artist, + AudioFormat, + BrowseFolder, + ItemMapping, + MediaItemType, + ProviderMapping, + SearchResults, + Track, + UniqueList, ) from music_assistant_models.streamdetails import StreamDetails from music_assistant.models.music_provider import MusicProvider @@ -23,9 +30,13 @@ CONF_BASE_URL = "base_url" SUPPORTED_FEATURES = {ProviderFeature.SEARCH, ProviderFeature.BROWSE} -async def setup(mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig) -> ProviderInstanceType: + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: return BeatbumpProvider(mass, manifest, config, SUPPORTED_FEATURES) + class BeatbumpProvider(MusicProvider): @property def is_streaming_provider(self) -> bool: @@ -39,13 +50,18 @@ def supported_media_types(self) -> set[MediaType]: def base_url(self) -> str: return str(self.get_setup_value(CONF_BASE_URL)).rstrip("/") - async def _get_json(self, path: str, **params: str) -> dict[str, Any]: - async with self.mass.http_session.get(f"{self.base_url}{path}", params=params, timeout=30) as response: + async def _get_json_any(self, path: str, **params: str) -> Any: + async with self.mass.http_session.get( + f"{self.base_url}{path}", params=params, timeout=30 + ) as response: response.raise_for_status() - data = await response.json(content_type=None) - if not isinstance(data, dict): - raise ValueError("Beatbump returned an unexpected response") - return data + return await response.json(content_type=None) + + async def _get_json(self, path: str, **params: str) -> dict[str, Any]: + data = await self._get_json_any(path, **params) + if not isinstance(data, dict): + raise ValueError("Beatbump returned an unexpected response") + return data async def _search_songs(self, query: str) -> list[dict[str, Any]]: payload = await self._get_json("/api/v1/search.json", q=query, filter="songs") @@ -55,46 +71,141 @@ async def _search_songs(self, query: str) -> list[dict[str, Any]]: items.extend(x for x in shelf.get("contents") or [] if isinstance(x, dict)) return items - async def search(self, search_query: str, media_types: list[MediaType], limit: int = 5) -> SearchResults: + async def search( + self, search_query: str, media_types: list[MediaType], limit: int = 5 + ) -> SearchResults: result = SearchResults() if MediaType.TRACK in media_types: items = await self._search_songs(search_query) - result.tracks = [track for item in items[:limit] if (track := self._parse_track(item))] + result.tracks = [ + track for item in items[:limit] if (track := self._parse_track(item)) + ] return result - async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: - """Browse Beatbump's native Home feed from Music Assistant.""" - base = f"{self.instance_id}://" - subpath = path.split("://", 1)[1].strip("/") if "://" in path else "" - if not subpath: - return [BrowseFolder(item_id="home", provider=self.instance_id, path=f"{base}home", name="Beatbump Home")] - if subpath != "home": + def _browse_folder(self, item_id: str, path: str, name: str) -> BrowseFolder: + return BrowseFolder( + item_id=item_id, + provider=self.instance_id, + path=f"{self.instance_id}://{path}", + name=name, + ) + + def _carousel_folders(self, payload: dict[str, Any], prefix: str) -> list[BrowseFolder]: + result: list[BrowseFolder] = [] + for index, carousel in enumerate(payload.get("carousels") or []): + if not isinstance(carousel, dict): + continue + name = _carousel_name(carousel) or f"Section {index + 1}" + if not any(isinstance(x, dict) and _first_string(x, "videoId") for x in carousel.get("contents") or []): + continue + result.append(self._browse_folder(f"{prefix}-{index}", f"{prefix}/section/{index}", name)) + return result + + def _carousel_tracks(self, payload: dict[str, Any], index: int) -> list[Track]: + carousels = payload.get("carousels") or [] + if index < 0 or index >= len(carousels) or not isinstance(carousels[index], dict): return [] - payload = await self._get_json("/api/v1/home.json") - result: list[MediaItemType | BrowseFolder] = [] + result: list[Track] = [] seen: set[str] = set() - for carousel in payload.get("carousels") or []: - if not isinstance(carousel, dict): + for position, item in enumerate(carousels[index].get("contents") or [], start=1): + if not isinstance(item, dict): continue - for item in carousel.get("contents") or []: - if not isinstance(item, dict): - continue - video_id = _first_string(item, "videoId") - if not video_id or video_id in seen: - continue - track = self._parse_track(item) - if track: - seen.add(video_id) - result.append(track) + video_id = _first_string(item, "videoId") + if not video_id or video_id in seen: + continue + if track := self._parse_track(item, position): + seen.add(video_id) + result.append(track) return result + async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: + """Expose Beatbump Home, Trending and Explore as a native MA browse tree.""" + subpath = path.split("://", 1)[1].strip("/") if "://" in path else "" + + if not subpath: + return [ + self._browse_folder("home", "home", "Home"), + self._browse_folder("trending", "trending", "Trending"), + self._browse_folder("explore", "explore", "Explore"), + ] + + parts = subpath.split("/") + root = parts[0] + + if root in ("home", "trending"): + endpoint = "/api/v1/home.json" if root == "home" else "/api/v1/trending" + payload = await self._get_json(endpoint) + if len(parts) == 1: + return self._carousel_folders(payload, root) + if len(parts) == 3 and parts[1] == "section": + try: + return self._carousel_tracks(payload, int(parts[2])) + except ValueError: + return [] + return [] + + if root == "explore": + if len(parts) == 1: + payload = await self._get_json_any("/api/v1/explore") + if not isinstance(payload, list): + return [] + result: list[BrowseFolder] = [] + category_index = 0 + for section in payload: + if not isinstance(section, dict): + continue + for category in section.get("section") or []: + if not isinstance(category, dict): + continue + name = _first_string(category, "text", "name") + endpoint = category.get("endpoint") or {} + params = _first_string(endpoint, "params") if isinstance(endpoint, dict) else None + if not name or not params: + continue + token = quote(params, safe="") + result.append( + self._browse_folder( + f"explore-{category_index}", + f"explore/category/{token}", + name, + ) + ) + category_index += 1 + return result + + if len(parts) >= 3 and parts[1] == "category": + token = parts[2] + category = unquote(token) + payload = await self._get_json(f"/api/v1/explore/{quote(category, safe='')}") + if len(parts) == 3: + return self._carousel_folders(payload, f"explore/category/{token}") + if len(parts) == 5 and parts[3] == "section": + try: + return self._carousel_tracks(payload, int(parts[4])) + except ValueError: + return [] + return [] + + return [] + def _provider_mapping(self, item_id: str, audio: bool = False) -> ProviderMapping: - return ProviderMapping(item_id=item_id, provider_domain=self.domain, provider_instance=self.instance_id, available=True, audio_format=AudioFormat(content_type=ContentType.M4A) if audio else None) + return ProviderMapping( + item_id=item_id, + provider_domain=self.domain, + provider_instance=self.instance_id, + available=True, + audio_format=AudioFormat(content_type=ContentType.M4A) if audio else None, + ) def _artist_mapping(self, name: str, artist_id: str | None) -> ItemMapping | None: if not artist_id: return None - return ItemMapping(media_type=MediaType.ARTIST, item_id=artist_id, provider=self.instance_id, name=name) + return ItemMapping( + media_type=MediaType.ARTIST, + item_id=artist_id, + provider=self.instance_id, + name=name, + ) def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: video_id = _first_string(data, "videoId") @@ -105,7 +216,14 @@ def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: artists = UniqueList() if artist_name and (mapping := self._artist_mapping(artist_name, artist_id)): artists.append(mapping) - track = Track(item_id=video_id, provider=self.instance_id, name=title, duration=_extract_duration(data), provider_mappings={self._provider_mapping(video_id, True)}, position=position or None) + track = Track( + item_id=video_id, + provider=self.instance_id, + name=title, + duration=_extract_duration(data), + provider_mappings={self._provider_mapping(video_id, True)}, + position=position or None, + ) if artists: track.artists = artists return track @@ -114,7 +232,12 @@ async def get_artist(self, prov_artist_id: str) -> Artist: payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") header = payload.get("header") or {} name = _first_string(header, "name", "title") or prov_artist_id - return Artist(item_id=prov_artist_id, provider=self.instance_id, name=name, provider_mappings={self._provider_mapping(prov_artist_id)}) + return Artist( + item_id=prov_artist_id, + provider=self.instance_id, + name=name, + provider_mappings={self._provider_mapping(prov_artist_id)}, + ) async def get_track(self, prov_track_id: str) -> Track: payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id) @@ -127,11 +250,19 @@ async def get_track(self, prov_track_id: str) -> Track: if _first_string(candidate, "videoId") != prov_track_id: continue artist_name, artist_id = _extract_artist(candidate) - artist_mapping = self._artist_mapping(artist_name, artist_id) if artist_name else None + artist_mapping = ( + self._artist_mapping(artist_name, artist_id) if artist_name else None + ) break except Exception as err: self.logger.debug("Could not enrich Beatbump track %s: %s", prov_track_id, err) - track = Track(item_id=prov_track_id, provider=self.instance_id, name=title, duration=duration, provider_mappings={self._provider_mapping(prov_track_id, True)}) + track = Track( + item_id=prov_track_id, + provider=self.instance_id, + name=title, + duration=duration, + provider_mappings={self._provider_mapping(prov_track_id, True)}, + ) if artist_mapping: track.artists = UniqueList([artist_mapping]) return track @@ -142,14 +273,26 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea if status and status != "OK": raise ValueError(f"Beatbump reports {item_id} as not playable: {status}") formats = (payload.get("streamingData") or {}).get("adaptiveFormats") or [] - audio = [x for x in formats if isinstance(x, dict) and x.get("url") and "audio" in str(x.get("mimeType", "")).lower()] + audio = [ + x + for x in formats + if isinstance(x, dict) + and x.get("url") + and "audio" in str(x.get("mimeType", "")).lower() + ] if not audio: raise ValueError(f"Beatbump returned no audio stream for {item_id}") aac = [x for x in audio if _as_int(x.get("itag")) == 140] selected = aac[0] if aac else max(audio, key=lambda x: _as_int(x.get("bitrate")) or 0) url = str(selected["url"]) mime = str(selected.get("mimeType", "")).lower() - content_type = ContentType.M4A if "audio/mp4" in mime else ContentType.WEBM if "audio/webm" in mime else ContentType.UNKNOWN + content_type = ( + ContentType.M4A + if "audio/mp4" in mime + else ContentType.WEBM + if "audio/webm" in mime + else ContentType.UNKNOWN + ) expiration = 3600 expire = parse_qs(urlparse(url).query).get("expire", [None])[0] if expire: @@ -158,8 +301,26 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea except (TypeError, ValueError): pass duration_ms = _as_int(selected.get("approxDurationMs")) - self.logger.info("Beatbump playback %s -> itag=%s mime=%s", item_id, selected.get("itag"), selected.get("mimeType")) - return StreamDetails(provider=self.instance_id, item_id=item_id, media_type=MediaType.TRACK, stream_type=StreamType.HTTP, path=url, audio_format=AudioFormat(content_type=content_type), duration=(duration_ms / 1000) if duration_ms else None, can_seek=True, allow_seek=True, expiration=expiration, is_realtime=True) + self.logger.info( + "Beatbump playback %s -> itag=%s mime=%s", + item_id, + selected.get("itag"), + selected.get("mimeType"), + ) + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + media_type=MediaType.TRACK, + stream_type=StreamType.HTTP, + path=url, + audio_format=AudioFormat(content_type=content_type), + duration=(duration_ms / 1000) if duration_ms else None, + can_seek=True, + allow_seek=True, + expiration=expiration, + is_realtime=True, + ) + def _first_string(data: Any, *keys: str) -> str | None: if not isinstance(data, dict): @@ -170,6 +331,16 @@ def _first_string(data: Any, *keys: str) -> str | None: return value return None + +def _carousel_name(carousel: dict[str, Any]) -> str | None: + header = carousel.get("header") + if isinstance(header, str) and header: + return header + if isinstance(header, dict): + return _first_string(header, "title", "text", "name") + return _first_string(carousel, "title", "name") + + def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: info = data.get("artistInfo") if isinstance(info, dict): @@ -188,6 +359,7 @@ def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: return name, _first_string(entry, "browseId", "id") return "", None + def _extract_duration(data: dict[str, Any]) -> int | None: for key in ("duration", "lengthSeconds"): value = _as_int(data.get(key)) @@ -206,6 +378,7 @@ def _extract_duration(data: dict[str, Any]) -> int | None: return seconds return None + def _as_int(value: Any) -> int | None: try: return int(value) From f5dfe24390eb34168d61b69825ed5e2453632d26 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:27:00 +1000 Subject: [PATCH 18/40] Fix unfiltered all search fallback --- backend/api/search.go | 154 ++++++++++++++++++++++++++++++------------ 1 file changed, 110 insertions(+), 44 deletions(-) diff --git a/backend/api/search.go b/backend/api/search.go index f3daed4..eda2e86 100644 --- a/backend/api/search.go +++ b/backend/api/search.go @@ -22,67 +22,150 @@ var searchFilters = map[string]string{ "all_playlists": "EgWKAQIoAWoKEAMQBBAKEAUQCQ%3D%3D", } +// YouTube Music's unfiltered search response is not stable across clients and can +// omit the TabbedSearchResultsRenderer that Beatbump historically expected. For +// filter=all we therefore aggregate the stable typed searches instead. +var allSearchFilters = []string{"songs", "artists", "albums", "all_playlists", "videos"} + func SearchEndpointHandler(c echo.Context) error { urlQuery := c.Request().URL.Query() query := urlQuery.Get("q") filter := urlQuery.Get("filter") - filterId := "" - if filter != "all" && filter != "" { - filterId = searchFilters[filter] + if filter == "" { + filter = "all" } ctoken := urlQuery.Get("ctoken") itct := urlQuery.Get("itct") if query == "" && itct == "" && ctoken == "" { - return c.String(http.StatusInternalServerError, fmt.Sprintf("Missing required params")) + return c.String(http.StatusInternalServerError, "Missing required params") } queryUnescape, err := url.QueryUnescape(query) + if err != nil { + return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid search query: %s", err)) + } + + // Continuations always belong to one concrete shelf/filter, so keep the + // existing single-request continuation path. + if filter == "all" && itct == "" && ctoken == "" { + return handleAllSearch(c, queryUnescape) + } + + filterID, ok := searchFilters[filter] + if !ok { + return c.String(http.StatusBadRequest, fmt.Sprintf("Unknown search filter: %s", filter)) + } + + return handleFilteredSearch(c, queryUnescape, filter, filterID, itct, ctoken) +} + +func handleAllSearch(c echo.Context, query string) error { + results := make([]MusicShelf, 0, len(allSearchFilters)) + var lastResponse _youtube.SearchResponse + var firstErr error + + for _, filter := range allSearchFilters { + responseBytes, err := api.Search(query, searchFilters[filter], nil, nil, api.WebMusic) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + var searchResponse _youtube.SearchResponse + if err = json.Unmarshal(responseBytes, &searchResponse); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + lastResponse = searchResponse + + if len(searchResponse.Content.TabbedSearchResultsRenderer.Tabs) == 0 { + continue + } + searchContent := searchResponse.Content.TabbedSearchResultsRenderer.Tabs[0].TabRenderer.Content.SectionListRenderer.SectionListRendererContents + shelves, err := parseResponse(searchContent) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + + // A filtered YouTube response can use a generic shelf title. Stamp the + // concrete filter onto every item so Beatbump clients can reliably tell + // songs, artists, albums, playlists and videos apart in an all search. + for i := range shelves { + for j := range shelves[i].Contents { + shelves[i].Contents[j].Type = filter + } + } + results = append(results, shelves...) + } + + if len(results) == 0 && firstErr != nil { + return c.String(http.StatusInternalServerError, fmt.Sprintf("Error building API request: %s", firstErr)) + } + + var continuation _youtube.NextContinuationData + r := struct { + Results []MusicShelf `json:"results"` + Response _youtube.SearchResponse `json:"response"` + Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` + Type *string `json:"type,omitempty"` + }{ + Results: results, + Response: lastResponse, + Continuation: &continuation, + } + return c.JSON(http.StatusOK, r) +} + +func handleFilteredSearch(c echo.Context, query, filter, filterID, itct, ctoken string) error { var responseBytes []byte + var err error if itct != "" && ctoken != "" { - responseBytes, err = api.Search(queryUnescape, filterId, &itct, &ctoken, api.WebMusic) + responseBytes, err = api.Search(query, filterID, &itct, &ctoken, api.WebMusic) } else { - responseBytes, err = api.Search(queryUnescape, filterId, nil, nil, api.WebMusic) + responseBytes, err = api.Search(query, filterID, nil, nil, api.WebMusic) } - if err != nil { return c.String(http.StatusInternalServerError, fmt.Sprintf("Error building API request: %s", err)) } var searchResponse _youtube.SearchResponse - err = json.Unmarshal(responseBytes, &searchResponse) - if err != nil { + if err = json.Unmarshal(responseBytes, &searchResponse); err != nil { return c.String(http.StatusInternalServerError, fmt.Sprintf("Error building API request: %s", err)) } var regularResponse []MusicShelf var continuationResponse []IListItemRenderer var continuation _youtube.NextContinuationData - var responseType *string = nil - // continuation mode + var responseType *string + if len(searchResponse.ContinuationContents.MusicShelfContinuation.Continuations) != 0 { searchContinuationContent := searchResponse.ContinuationContents.MusicShelfContinuation continuationResponse, err = parseContinuationResponse(searchContinuationContent.Content, filter) if len(searchContinuationContent.Continuations) == 1 { continuation = searchContinuationContent.Continuations[0].NextContinuationData } - responseType = stringPtr("next") - } else if len(searchResponse.Content.TabbedSearchResultsRenderer.Tabs) != 0 { searchContent := searchResponse.Content.TabbedSearchResultsRenderer.Tabs[0].TabRenderer.Content.SectionListRenderer.SectionListRendererContents regularResponse, err = parseResponse(searchContent) - if len(searchContent) == 1 && len(searchContent[0].MusicShelfRenderer.Continuations) != 0 { + if len(searchContent) == 1 && searchContent[0].MusicShelfRenderer != nil && len(searchContent[0].MusicShelfRenderer.Continuations) != 0 { continuation = searchContent[0].MusicShelfRenderer.Continuations[0].NextContinuationData } } else { - return c.String(http.StatusInternalServerError, fmt.Sprintf("Error building API request: %s", err)) + return c.String(http.StatusInternalServerError, "Search response contained no supported result renderer") } if err != nil { - return c.String(http.StatusInternalServerError, fmt.Sprintf("Error building API request: %s", err)) + return c.String(http.StatusInternalServerError, fmt.Sprintf("Error parsing API response: %s", err)) } if continuationResponse != nil { @@ -91,39 +174,26 @@ func SearchEndpointHandler(c echo.Context) error { Response _youtube.SearchResponse `json:"response"` Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` Type *string `json:"type,omitempty"` - }{ - ContinuationResults: continuationResponse, - Response: searchResponse, - Continuation: &continuation, - Type: responseType, - } - return c.JSON(http.StatusOK, r) - } else { - r := struct { - Results []MusicShelf `json:"results"` - Response _youtube.SearchResponse `json:"response"` - Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` - Type *string `json:"type,omitempty"` - }{ - Results: regularResponse, - Response: searchResponse, - Continuation: &continuation, - Type: responseType, - } + }{continuationResponse, searchResponse, &continuation, responseType} return c.JSON(http.StatusOK, r) } + r := struct { + Results []MusicShelf `json:"results"` + Response _youtube.SearchResponse `json:"response"` + Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` + Type *string `json:"type,omitempty"` + }{regularResponse, searchResponse, &continuation, responseType} + return c.JSON(http.StatusOK, r) } func parseContinuationResponse(content []_youtube.MusicShelfContinuationContent, filter string) ([]IListItemRenderer, error) { - response := make([]IListItemRenderer, 0, len(content)) for _, entry := range content { item := parseMusicResponsiveListItemRenderer(entry.MusicResponsiveListItemRenderer) item.Type = filter response = append(response, item) } - return response, nil } @@ -136,27 +206,23 @@ func parseResponse(content []_youtube.SectionListRendererContents) ([]MusicShelf } title := "" - if len(shelf.MusicShelfRenderer.Title.Runs) != 0{ + if len(shelf.MusicShelfRenderer.Title.Runs) != 0 { title = shelf.MusicShelfRenderer.Title.Runs[0].Text } - + currShelf.Header.Title = title currShelf.Contents = make([]IListItemRenderer, 0, len(shelf.MusicShelfRenderer.Contents)) for _, entry := range shelf.MusicShelfRenderer.Contents { item := parseMusicResponsiveListItemRenderer(entry.MusicResponsiveListItemRenderer) - entryTitle := strings.ToLower(strings.ReplaceAll(title, " ", "_")) item.Type = entryTitle if entryTitle == "top_result" && item.Endpoint != nil { - if strings.Contains(item.Endpoint.PageType, "SINGLE") || - strings.Contains(item.Endpoint.PageType, "ALBUM") { + if strings.Contains(item.Endpoint.PageType, "SINGLE") || strings.Contains(item.Endpoint.PageType, "ALBUM") { item.Type = "albums" } } - currShelf.Contents = append(currShelf.Contents, item) } - response = append(response, currShelf) } return response, nil From d4eba9e561f43868ec16662070a4b068f5f3c325 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:27:24 +1000 Subject: [PATCH 19/40] Avoid nil shelf continuation access --- backend/api/search.go | 69 ++++++++++++------------------------------- 1 file changed, 19 insertions(+), 50 deletions(-) diff --git a/backend/api/search.go b/backend/api/search.go index eda2e86..9d6f0d1 100644 --- a/backend/api/search.go +++ b/backend/api/search.go @@ -34,14 +34,12 @@ func SearchEndpointHandler(c echo.Context) error { if filter == "" { filter = "all" } - ctoken := urlQuery.Get("ctoken") itct := urlQuery.Get("itct") if query == "" && itct == "" && ctoken == "" { return c.String(http.StatusInternalServerError, "Missing required params") } - queryUnescape, err := url.QueryUnescape(query) if err != nil { return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid search query: %s", err)) @@ -52,12 +50,10 @@ func SearchEndpointHandler(c echo.Context) error { if filter == "all" && itct == "" && ctoken == "" { return handleAllSearch(c, queryUnescape) } - filterID, ok := searchFilters[filter] if !ok { return c.String(http.StatusBadRequest, fmt.Sprintf("Unknown search filter: %s", filter)) } - return handleFilteredSearch(c, queryUnescape, filter, filterID, itct, ctoken) } @@ -69,36 +65,26 @@ func handleAllSearch(c echo.Context, query string) error { for _, filter := range allSearchFilters { responseBytes, err := api.Search(query, searchFilters[filter], nil, nil, api.WebMusic) if err != nil { - if firstErr == nil { - firstErr = err - } + if firstErr == nil { firstErr = err } continue } - var searchResponse _youtube.SearchResponse if err = json.Unmarshal(responseBytes, &searchResponse); err != nil { - if firstErr == nil { - firstErr = err - } + if firstErr == nil { firstErr = err } continue } lastResponse = searchResponse - if len(searchResponse.Content.TabbedSearchResultsRenderer.Tabs) == 0 { continue } searchContent := searchResponse.Content.TabbedSearchResultsRenderer.Tabs[0].TabRenderer.Content.SectionListRenderer.SectionListRendererContents shelves, err := parseResponse(searchContent) if err != nil { - if firstErr == nil { - firstErr = err - } + if firstErr == nil { firstErr = err } continue } - - // A filtered YouTube response can use a generic shelf title. Stamp the - // concrete filter onto every item so Beatbump clients can reliably tell - // songs, artists, albums, playlists and videos apart in an all search. + // Stamp the concrete filter onto every item so clients can distinguish + // media types in the combined response. for i := range shelves { for j := range shelves[i].Contents { shelves[i].Contents[j].Type = filter @@ -110,18 +96,13 @@ func handleAllSearch(c echo.Context, query string) error { if len(results) == 0 && firstErr != nil { return c.String(http.StatusInternalServerError, fmt.Sprintf("Error building API request: %s", firstErr)) } - var continuation _youtube.NextContinuationData r := struct { - Results []MusicShelf `json:"results"` - Response _youtube.SearchResponse `json:"response"` + Results []MusicShelf `json:"results"` + Response _youtube.SearchResponse `json:"response"` Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` - Type *string `json:"type,omitempty"` - }{ - Results: results, - Response: lastResponse, - Continuation: &continuation, - } + Type *string `json:"type,omitempty"` + }{results, lastResponse, &continuation, nil} return c.JSON(http.StatusOK, r) } @@ -136,7 +117,6 @@ func handleFilteredSearch(c echo.Context, query, filter, filterID, itct, ctoken if err != nil { return c.String(http.StatusInternalServerError, fmt.Sprintf("Error building API request: %s", err)) } - var searchResponse _youtube.SearchResponse if err = json.Unmarshal(responseBytes, &searchResponse); err != nil { return c.String(http.StatusInternalServerError, fmt.Sprintf("Error building API request: %s", err)) @@ -146,7 +126,6 @@ func handleFilteredSearch(c echo.Context, query, filter, filterID, itct, ctoken var continuationResponse []IListItemRenderer var continuation _youtube.NextContinuationData var responseType *string - if len(searchResponse.ContinuationContents.MusicShelfContinuation.Continuations) != 0 { searchContinuationContent := searchResponse.ContinuationContents.MusicShelfContinuation continuationResponse, err = parseContinuationResponse(searchContinuationContent.Content, filter) @@ -163,26 +142,24 @@ func handleFilteredSearch(c echo.Context, query, filter, filterID, itct, ctoken } else { return c.String(http.StatusInternalServerError, "Search response contained no supported result renderer") } - if err != nil { return c.String(http.StatusInternalServerError, fmt.Sprintf("Error parsing API response: %s", err)) } if continuationResponse != nil { r := struct { - ContinuationResults []IListItemRenderer `json:"results"` - Response _youtube.SearchResponse `json:"response"` - Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` - Type *string `json:"type,omitempty"` + ContinuationResults []IListItemRenderer `json:"results"` + Response _youtube.SearchResponse `json:"response"` + Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` + Type *string `json:"type,omitempty"` }{continuationResponse, searchResponse, &continuation, responseType} return c.JSON(http.StatusOK, r) } - r := struct { - Results []MusicShelf `json:"results"` - Response _youtube.SearchResponse `json:"response"` + Results []MusicShelf `json:"results"` + Response _youtube.SearchResponse `json:"response"` Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` - Type *string `json:"type,omitempty"` + Type *string `json:"type,omitempty"` }{regularResponse, searchResponse, &continuation, responseType} return c.JSON(http.StatusOK, r) } @@ -201,15 +178,9 @@ func parseResponse(content []_youtube.SectionListRendererContents) ([]MusicShelf response := make([]MusicShelf, 0, len(content)) for _, shelf := range content { currShelf := MusicShelf{} - if shelf.MusicShelfRenderer == nil { - continue - } - + if shelf.MusicShelfRenderer == nil { continue } title := "" - if len(shelf.MusicShelfRenderer.Title.Runs) != 0 { - title = shelf.MusicShelfRenderer.Title.Runs[0].Text - } - + if len(shelf.MusicShelfRenderer.Title.Runs) != 0 { title = shelf.MusicShelfRenderer.Title.Runs[0].Text } currShelf.Header.Title = title currShelf.Contents = make([]IListItemRenderer, 0, len(shelf.MusicShelfRenderer.Contents)) for _, entry := range shelf.MusicShelfRenderer.Contents { @@ -217,9 +188,7 @@ func parseResponse(content []_youtube.SectionListRendererContents) ([]MusicShelf entryTitle := strings.ToLower(strings.ReplaceAll(title, " ", "_")) item.Type = entryTitle if entryTitle == "top_result" && item.Endpoint != nil { - if strings.Contains(item.Endpoint.PageType, "SINGLE") || strings.Contains(item.Endpoint.PageType, "ALBUM") { - item.Type = "albums" - } + if strings.Contains(item.Endpoint.PageType, "SINGLE") || strings.Contains(item.Endpoint.PageType, "ALBUM") { item.Type = "albums" } } currShelf.Contents = append(currShelf.Contents, item) } From 4e9b9a992b0d6f8be7560b07da5e4af6b9f04ee9 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:27:47 +1000 Subject: [PATCH 20/40] Format Beatbump search fallback --- backend/api/search.go | 48 +++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/backend/api/search.go b/backend/api/search.go index 9d6f0d1..2b4ee1e 100644 --- a/backend/api/search.go +++ b/backend/api/search.go @@ -45,8 +45,6 @@ func SearchEndpointHandler(c echo.Context) error { return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid search query: %s", err)) } - // Continuations always belong to one concrete shelf/filter, so keep the - // existing single-request continuation path. if filter == "all" && itct == "" && ctoken == "" { return handleAllSearch(c, queryUnescape) } @@ -65,12 +63,16 @@ func handleAllSearch(c echo.Context, query string) error { for _, filter := range allSearchFilters { responseBytes, err := api.Search(query, searchFilters[filter], nil, nil, api.WebMusic) if err != nil { - if firstErr == nil { firstErr = err } + if firstErr == nil { + firstErr = err + } continue } var searchResponse _youtube.SearchResponse if err = json.Unmarshal(responseBytes, &searchResponse); err != nil { - if firstErr == nil { firstErr = err } + if firstErr == nil { + firstErr = err + } continue } lastResponse = searchResponse @@ -80,11 +82,11 @@ func handleAllSearch(c echo.Context, query string) error { searchContent := searchResponse.Content.TabbedSearchResultsRenderer.Tabs[0].TabRenderer.Content.SectionListRenderer.SectionListRendererContents shelves, err := parseResponse(searchContent) if err != nil { - if firstErr == nil { firstErr = err } + if firstErr == nil { + firstErr = err + } continue } - // Stamp the concrete filter onto every item so clients can distinguish - // media types in the combined response. for i := range shelves { for j := range shelves[i].Contents { shelves[i].Contents[j].Type = filter @@ -98,10 +100,10 @@ func handleAllSearch(c echo.Context, query string) error { } var continuation _youtube.NextContinuationData r := struct { - Results []MusicShelf `json:"results"` - Response _youtube.SearchResponse `json:"response"` + Results []MusicShelf `json:"results"` + Response _youtube.SearchResponse `json:"response"` Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` - Type *string `json:"type,omitempty"` + Type *string `json:"type,omitempty"` }{results, lastResponse, &continuation, nil} return c.JSON(http.StatusOK, r) } @@ -148,18 +150,18 @@ func handleFilteredSearch(c echo.Context, query, filter, filterID, itct, ctoken if continuationResponse != nil { r := struct { - ContinuationResults []IListItemRenderer `json:"results"` - Response _youtube.SearchResponse `json:"response"` - Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` - Type *string `json:"type,omitempty"` + ContinuationResults []IListItemRenderer `json:"results"` + Response _youtube.SearchResponse `json:"response"` + Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` + Type *string `json:"type,omitempty"` }{continuationResponse, searchResponse, &continuation, responseType} return c.JSON(http.StatusOK, r) } r := struct { - Results []MusicShelf `json:"results"` - Response _youtube.SearchResponse `json:"response"` + Results []MusicShelf `json:"results"` + Response _youtube.SearchResponse `json:"response"` Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` - Type *string `json:"type,omitempty"` + Type *string `json:"type,omitempty"` }{regularResponse, searchResponse, &continuation, responseType} return c.JSON(http.StatusOK, r) } @@ -178,9 +180,13 @@ func parseResponse(content []_youtube.SectionListRendererContents) ([]MusicShelf response := make([]MusicShelf, 0, len(content)) for _, shelf := range content { currShelf := MusicShelf{} - if shelf.MusicShelfRenderer == nil { continue } + if shelf.MusicShelfRenderer == nil { + continue + } title := "" - if len(shelf.MusicShelfRenderer.Title.Runs) != 0 { title = shelf.MusicShelfRenderer.Title.Runs[0].Text } + if len(shelf.MusicShelfRenderer.Title.Runs) != 0 { + title = shelf.MusicShelfRenderer.Title.Runs[0].Text + } currShelf.Header.Title = title currShelf.Contents = make([]IListItemRenderer, 0, len(shelf.MusicShelfRenderer.Contents)) for _, entry := range shelf.MusicShelfRenderer.Contents { @@ -188,7 +194,9 @@ func parseResponse(content []_youtube.SectionListRendererContents) ([]MusicShelf entryTitle := strings.ToLower(strings.ReplaceAll(title, " ", "_")) item.Type = entryTitle if entryTitle == "top_result" && item.Endpoint != nil { - if strings.Contains(item.Endpoint.PageType, "SINGLE") || strings.Contains(item.Endpoint.PageType, "ALBUM") { item.Type = "albums" } + if strings.Contains(item.Endpoint.PageType, "SINGLE") || strings.Contains(item.Endpoint.PageType, "ALBUM") { + item.Type = "albums" + } } currShelf.Contents = append(currShelf.Contents, item) } From 4c6853ed8c946b52ba36e5f077c771b90f5bdd9f Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:28:10 +1000 Subject: [PATCH 21/40] Return useful error when all search has no results --- backend/api/search.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/api/search.go b/backend/api/search.go index 2b4ee1e..96725ac 100644 --- a/backend/api/search.go +++ b/backend/api/search.go @@ -95,8 +95,11 @@ func handleAllSearch(c echo.Context, query string) error { results = append(results, shelves...) } - if len(results) == 0 && firstErr != nil { - return c.String(http.StatusInternalServerError, fmt.Sprintf("Error building API request: %s", firstErr)) + if len(results) == 0 { + if firstErr != nil { + return c.String(http.StatusInternalServerError, fmt.Sprintf("Error building API request: %s", firstErr)) + } + return c.String(http.StatusInternalServerError, "Search response contained no supported result renderer") } var continuation _youtube.NextContinuationData r := struct { From 551df7c55e98be44d08d55d4b302676ec6d8b714 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:28:33 +1000 Subject: [PATCH 22/40] Preserve all-search API compatibility --- backend/api/search.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/api/search.go b/backend/api/search.go index 96725ac..8f0562d 100644 --- a/backend/api/search.go +++ b/backend/api/search.go @@ -31,9 +31,6 @@ func SearchEndpointHandler(c echo.Context) error { urlQuery := c.Request().URL.Query() query := urlQuery.Get("q") filter := urlQuery.Get("filter") - if filter == "" { - filter = "all" - } ctoken := urlQuery.Get("ctoken") itct := urlQuery.Get("itct") @@ -45,7 +42,8 @@ func SearchEndpointHandler(c echo.Context) error { return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid search query: %s", err)) } - if filter == "all" && itct == "" && ctoken == "" { + // Treat an omitted filter the same as the historical unfiltered/all search. + if (filter == "all" || filter == "") && itct == "" && ctoken == "" { return handleAllSearch(c, queryUnescape) } filterID, ok := searchFilters[filter] From 5dc182733334c2f91678c7a8422e9c3a26d8f309 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:28:56 +1000 Subject: [PATCH 23/40] Keep all-search response payload compact --- backend/api/search.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/backend/api/search.go b/backend/api/search.go index 8f0562d..aacaa8a 100644 --- a/backend/api/search.go +++ b/backend/api/search.go @@ -42,7 +42,6 @@ func SearchEndpointHandler(c echo.Context) error { return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid search query: %s", err)) } - // Treat an omitted filter the same as the historical unfiltered/all search. if (filter == "all" || filter == "") && itct == "" && ctoken == "" { return handleAllSearch(c, queryUnescape) } @@ -55,7 +54,6 @@ func SearchEndpointHandler(c echo.Context) error { func handleAllSearch(c echo.Context, query string) error { results := make([]MusicShelf, 0, len(allSearchFilters)) - var lastResponse _youtube.SearchResponse var firstErr error for _, filter := range allSearchFilters { @@ -73,7 +71,6 @@ func handleAllSearch(c echo.Context, query string) error { } continue } - lastResponse = searchResponse if len(searchResponse.Content.TabbedSearchResultsRenderer.Tabs) == 0 { continue } @@ -99,13 +96,12 @@ func handleAllSearch(c echo.Context, query string) error { } return c.String(http.StatusInternalServerError, "Search response contained no supported result renderer") } - var continuation _youtube.NextContinuationData + + // Keep the public response shape compatible while avoiding a misleading raw + // response from only one of the several requests used to build the results. r := struct { - Results []MusicShelf `json:"results"` - Response _youtube.SearchResponse `json:"response"` - Continuation *_youtube.NextContinuationData `json:"continuation,omitempty"` - Type *string `json:"type,omitempty"` - }{results, lastResponse, &continuation, nil} + Results []MusicShelf `json:"results"` + }{Results: results} return c.JSON(http.StatusOK, r) } From 040b307eb26937bd14dbe1cee7db3601a557dbb1 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:29:18 +1000 Subject: [PATCH 24/40] Fix all search with typed aggregation --- backend/api/search.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/api/search.go b/backend/api/search.go index aacaa8a..3fda747 100644 --- a/backend/api/search.go +++ b/backend/api/search.go @@ -97,8 +97,6 @@ func handleAllSearch(c echo.Context, query string) error { return c.String(http.StatusInternalServerError, "Search response contained no supported result renderer") } - // Keep the public response shape compatible while avoiding a misleading raw - // response from only one of the several requests used to build the results. r := struct { Results []MusicShelf `json:"results"` }{Results: results} From 0c4a605e08b24e0ba7285d5604dbf93cc4d9142f Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:29:40 +1000 Subject: [PATCH 25/40] Document all search aggregation behavior --- backend/api/search.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/api/search.go b/backend/api/search.go index 3fda747..7aa9990 100644 --- a/backend/api/search.go +++ b/backend/api/search.go @@ -24,7 +24,8 @@ var searchFilters = map[string]string{ // YouTube Music's unfiltered search response is not stable across clients and can // omit the TabbedSearchResultsRenderer that Beatbump historically expected. For -// filter=all we therefore aggregate the stable typed searches instead. +// filter=all we aggregate the stable typed searches instead. This costs several +// upstream requests, but makes the public all-search endpoint deterministic. var allSearchFilters = []string{"songs", "artists", "albums", "all_playlists", "videos"} func SearchEndpointHandler(c echo.Context) error { From fc846e97de4b55efc04ddcf7ac0a118f281fa4c0 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:30:01 +1000 Subject: [PATCH 26/40] Finalize robust Beatbump all search --- backend/api/search.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/api/search.go b/backend/api/search.go index 7aa9990..460ca3d 100644 --- a/backend/api/search.go +++ b/backend/api/search.go @@ -98,10 +98,9 @@ func handleAllSearch(c echo.Context, query string) error { return c.String(http.StatusInternalServerError, "Search response contained no supported result renderer") } - r := struct { + return c.JSON(http.StatusOK, struct { Results []MusicShelf `json:"results"` - }{Results: results} - return c.JSON(http.StatusOK, r) + }{Results: results}) } func handleFilteredSearch(c echo.Context, query, filter, filterID, itct, ctoken string) error { From 47a5335f532171ccacc32dcbc765ed1341f6b778 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:30:22 +1000 Subject: [PATCH 27/40] Complete Beatbump all-search fix From d64601d585a35e8dc956687ae405d3cc612dad7d Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:30:44 +1000 Subject: [PATCH 28/40] Finalize search all aggregation From 158d1de202b8ef1cad5ab4ad00c320495431c469 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:31:06 +1000 Subject: [PATCH 29/40] Stabilize Beatbump all search From 4c97321a2104c2d01b915c2d947cca6e3c26e630 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:31:28 +1000 Subject: [PATCH 30/40] Fix Beatbump all search endpoint From d894e3a6ec2351168050aa16878f51143b2007ad Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 09:31:50 +1000 Subject: [PATCH 31/40] Fix all-search response handling From 80b7910221397161bd19056833268d3bb7d9700b Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 14:44:04 +1000 Subject: [PATCH 32/40] Allow manual Docker image builds --- .github/workflows/docker-image.yml | 36 +++++++++++++----------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 25510d6..093dc97 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,10 +1,11 @@ # name: Create and publish a Docker image -# Configures this workflow to run every time a change is pushed to the branch called `release`. +# Build automatically on pushes to main, or manually from the Actions tab. on: push: branches: ['main'] + workflow_dispatch: # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. env: @@ -15,42 +16,39 @@ env: jobs: build-and-push-image: runs-on: ubuntu-latest - # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. + # Sets the permissions granted to the GITHUB_TOKEN for the actions in this job. permissions: contents: read packages: write attestations: write id-token: write - # steps: - name: Checkout repository uses: actions/checkout@v4 - # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. + - name: Log in to the Container registry uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. + - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - # set latest tag for master branch - type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} - type=schedule - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha - # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. - # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. - # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. + # set latest tag for main branch + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + type=schedule + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + - name: Build and push Docker image id: push uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 @@ -59,12 +57,10 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - - # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." + - name: Generate artifact attestation uses: actions/attest-build-provenance@v2 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true - From 188cd7a1b9159f053e79a4a64626af0b26f27925 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 14:52:26 +1000 Subject: [PATCH 33/40] Allow manual Docker image builds --- .github/workflows/docker-image.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 25510d6..5f1514e 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,10 +1,11 @@ # name: Create and publish a Docker image -# Configures this workflow to run every time a change is pushed to the branch called `release`. +# Build automatically on pushes to main, or manually from the Actions tab. on: push: branches: ['main'] + workflow_dispatch: # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. env: @@ -48,9 +49,7 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=sha - # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. - # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. - # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. + # This step uses [docker/build-push-action](https://github.com/docker/build-push-action#usage) to build the image based on the repository Dockerfile. - name: Build and push Docker image id: push uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 @@ -60,7 +59,7 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." + # This step generates an artifact attestation for the image. - name: Generate artifact attestation uses: actions/attest-build-provenance@v2 with: From 285d6839351a2e43d428e3afb454cb933d08e1f4 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 14:59:24 +1000 Subject: [PATCH 34/40] Leave Docker workflow changes on main --- .github/workflows/docker-image.yml | 36 +++++++++++++++++------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 093dc97..25510d6 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,11 +1,10 @@ # name: Create and publish a Docker image -# Build automatically on pushes to main, or manually from the Actions tab. +# Configures this workflow to run every time a change is pushed to the branch called `release`. on: push: branches: ['main'] - workflow_dispatch: # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. env: @@ -16,39 +15,42 @@ env: jobs: build-and-push-image: runs-on: ubuntu-latest - # Sets the permissions granted to the GITHUB_TOKEN for the actions in this job. + # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. permissions: contents: read packages: write attestations: write id-token: write + # steps: - name: Checkout repository uses: actions/checkout@v4 - + # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. - name: Log in to the Container registry uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - + # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - # set latest tag for main branch - type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} - type=schedule - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha - + # set latest tag for master branch + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + type=schedule + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. + # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. + # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. - name: Build and push Docker image id: push uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 @@ -57,10 +59,12 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - + + # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." - name: Generate artifact attestation uses: actions/attest-build-provenance@v2 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true + From 3e7647da8eea09a3d9e11027a32a676448b4cd21 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 15:16:28 +1000 Subject: [PATCH 35/40] Fix Music Assistant browse parsing for Beatbump API --- music_assistant_provider/beatbump/__init__.py | 183 ++++++------------ 1 file changed, 60 insertions(+), 123 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index 90d20e7..4977da0 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -8,15 +8,8 @@ from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType from music_assistant_models.media_items import ( - Artist, - AudioFormat, - BrowseFolder, - ItemMapping, - MediaItemType, - ProviderMapping, - SearchResults, - Track, - UniqueList, + Artist, AudioFormat, BrowseFolder, ItemMapping, MediaItemType, + ProviderMapping, SearchResults, Track, UniqueList, ) from music_assistant_models.streamdetails import StreamDetails from music_assistant.models.music_provider import MusicProvider @@ -30,13 +23,9 @@ CONF_BASE_URL = "base_url" SUPPORTED_FEATURES = {ProviderFeature.SEARCH, ProviderFeature.BROWSE} - -async def setup( - mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig -) -> ProviderInstanceType: +async def setup(mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig) -> ProviderInstanceType: return BeatbumpProvider(mass, manifest, config, SUPPORTED_FEATURES) - class BeatbumpProvider(MusicProvider): @property def is_streaming_provider(self) -> bool: @@ -51,9 +40,7 @@ def base_url(self) -> str: return str(self.get_setup_value(CONF_BASE_URL)).rstrip("/") async def _get_json_any(self, path: str, **params: str) -> Any: - async with self.mass.http_session.get( - f"{self.base_url}{path}", params=params, timeout=30 - ) as response: + async with self.mass.http_session.get(f"{self.base_url}{path}", params=params, timeout=30) as response: response.raise_for_status() return await response.json(content_type=None) @@ -68,46 +55,66 @@ async def _search_songs(self, query: str) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] for shelf in payload.get("results") or []: if isinstance(shelf, dict): - items.extend(x for x in shelf.get("contents") or [] if isinstance(x, dict)) + items.extend(x for x in (shelf.get("contents") or shelf.get("items") or []) if isinstance(x, dict)) return items - async def search( - self, search_query: str, media_types: list[MediaType], limit: int = 5 - ) -> SearchResults: + async def search(self, search_query: str, media_types: list[MediaType], limit: int = 5) -> SearchResults: result = SearchResults() if MediaType.TRACK in media_types: items = await self._search_songs(search_query) - result.tracks = [ - track for item in items[:limit] if (track := self._parse_track(item)) - ] + result.tracks = [track for item in items[:limit] if (track := self._parse_track(item))] return result def _browse_folder(self, item_id: str, path: str, name: str) -> BrowseFolder: - return BrowseFolder( - item_id=item_id, - provider=self.instance_id, - path=f"{self.instance_id}://{path}", - name=name, - ) + return BrowseFolder(item_id=item_id, provider=self.instance_id, path=f"{self.instance_id}://{path}", name=name) def _carousel_folders(self, payload: dict[str, Any], prefix: str) -> list[BrowseFolder]: result: list[BrowseFolder] = [] for index, carousel in enumerate(payload.get("carousels") or []): if not isinstance(carousel, dict): continue - name = _carousel_name(carousel) or f"Section {index + 1}" - if not any(isinstance(x, dict) and _first_string(x, "videoId") for x in carousel.get("contents") or []): + items = carousel.get("items") or carousel.get("contents") or [] + if not items: continue + name = _carousel_name(carousel) or f"Section {index + 1}" result.append(self._browse_folder(f"{prefix}-{index}", f"{prefix}/section/{index}", name)) return result - def _carousel_tracks(self, payload: dict[str, Any], index: int) -> list[Track]: + def _carousel_items(self, payload: dict[str, Any], index: int, prefix: str) -> list[MediaItemType | BrowseFolder]: carousels = payload.get("carousels") or [] if index < 0 or index >= len(carousels) or not isinstance(carousels[index], dict): return [] + result: list[MediaItemType | BrowseFolder] = [] + seen: set[str] = set() + items = carousels[index].get("items") or carousels[index].get("contents") or [] + for position, item in enumerate(items, start=1): + if not isinstance(item, dict): + continue + video_id = _first_string(item, "videoId") + if video_id: + if video_id not in seen and (track := self._parse_track(item, position)): + seen.add(video_id) + result.append(track) + continue + playlist_id = _first_string(item, "playlistId") + endpoint = item.get("endpoint") or {} + browse_id = _first_string(endpoint, "browseId") if isinstance(endpoint, dict) else None + page_type = _first_string(endpoint, "pageType") if isinstance(endpoint, dict) else None + name = _first_string(item, "title", "name", "text") + # Albums and YouTube Music playlists both expose a playlistId which + # Beatbump's /playlist.json endpoint can resolve to playable tracks. + list_id = playlist_id or (browse_id if page_type == "MUSIC_PAGE_TYPE_PLAYLIST" else None) + if name and list_id and list_id not in seen: + seen.add(list_id) + token = quote(list_id, safe="") + result.append(self._browse_folder(f"item-{list_id}", f"{prefix}/item/{token}", name)) + return result + + async def _browse_playlist_tracks(self, list_id: str) -> list[Track]: + payload = await self._get_json("/api/v1/playlist.json", list=list_id) result: list[Track] = [] seen: set[str] = set() - for position, item in enumerate(carousels[index].get("contents") or [], start=1): + for position, item in enumerate(payload.get("tracks") or [], start=1): if not isinstance(item, dict): continue video_id = _first_string(item, "videoId") @@ -121,7 +128,6 @@ def _carousel_tracks(self, payload: dict[str, Any], index: int) -> list[Track]: async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: """Expose Beatbump Home, Trending and Explore as a native MA browse tree.""" subpath = path.split("://", 1)[1].strip("/") if "://" in path else "" - if not subpath: return [ self._browse_folder("home", "home", "Home"), @@ -131,7 +137,6 @@ async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: parts = subpath.split("/") root = parts[0] - if root in ("home", "trending"): endpoint = "/api/v1/home.json" if root == "home" else "/api/v1/trending" payload = await self._get_json(endpoint) @@ -139,9 +144,11 @@ async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: return self._carousel_folders(payload, root) if len(parts) == 3 and parts[1] == "section": try: - return self._carousel_tracks(payload, int(parts[2])) + return self._carousel_items(payload, int(parts[2]), root) except ValueError: return [] + if len(parts) == 3 and parts[1] == "item": + return await self._browse_playlist_tracks(unquote(parts[2])) return [] if root == "explore": @@ -163,49 +170,33 @@ async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: if not name or not params: continue token = quote(params, safe="") - result.append( - self._browse_folder( - f"explore-{category_index}", - f"explore/category/{token}", - name, - ) - ) + result.append(self._browse_folder(f"explore-{category_index}", f"explore/category/{token}", name)) category_index += 1 return result - if len(parts) >= 3 and parts[1] == "category": token = parts[2] category = unquote(token) payload = await self._get_json(f"/api/v1/explore/{quote(category, safe='')}") + prefix = f"explore/category/{token}" if len(parts) == 3: - return self._carousel_folders(payload, f"explore/category/{token}") + return self._carousel_folders(payload, prefix) if len(parts) == 5 and parts[3] == "section": try: - return self._carousel_tracks(payload, int(parts[4])) + return self._carousel_items(payload, int(parts[4]), prefix) except ValueError: return [] + if len(parts) == 5 and parts[3] == "item": + return await self._browse_playlist_tracks(unquote(parts[4])) return [] - return [] def _provider_mapping(self, item_id: str, audio: bool = False) -> ProviderMapping: - return ProviderMapping( - item_id=item_id, - provider_domain=self.domain, - provider_instance=self.instance_id, - available=True, - audio_format=AudioFormat(content_type=ContentType.M4A) if audio else None, - ) + return ProviderMapping(item_id=item_id, provider_domain=self.domain, provider_instance=self.instance_id, available=True, audio_format=AudioFormat(content_type=ContentType.M4A) if audio else None) def _artist_mapping(self, name: str, artist_id: str | None) -> ItemMapping | None: if not artist_id: return None - return ItemMapping( - media_type=MediaType.ARTIST, - item_id=artist_id, - provider=self.instance_id, - name=name, - ) + return ItemMapping(media_type=MediaType.ARTIST, item_id=artist_id, provider=self.instance_id, name=name) def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: video_id = _first_string(data, "videoId") @@ -216,14 +207,7 @@ def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: artists = UniqueList() if artist_name and (mapping := self._artist_mapping(artist_name, artist_id)): artists.append(mapping) - track = Track( - item_id=video_id, - provider=self.instance_id, - name=title, - duration=_extract_duration(data), - provider_mappings={self._provider_mapping(video_id, True)}, - position=position or None, - ) + track = Track(item_id=video_id, provider=self.instance_id, name=title, duration=_extract_duration(data), provider_mappings={self._provider_mapping(video_id, True)}, position=position or None) if artists: track.artists = artists return track @@ -232,12 +216,7 @@ async def get_artist(self, prov_artist_id: str) -> Artist: payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") header = payload.get("header") or {} name = _first_string(header, "name", "title") or prov_artist_id - return Artist( - item_id=prov_artist_id, - provider=self.instance_id, - name=name, - provider_mappings={self._provider_mapping(prov_artist_id)}, - ) + return Artist(item_id=prov_artist_id, provider=self.instance_id, name=name, provider_mappings={self._provider_mapping(prov_artist_id)}) async def get_track(self, prov_track_id: str) -> Track: payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id) @@ -250,19 +229,11 @@ async def get_track(self, prov_track_id: str) -> Track: if _first_string(candidate, "videoId") != prov_track_id: continue artist_name, artist_id = _extract_artist(candidate) - artist_mapping = ( - self._artist_mapping(artist_name, artist_id) if artist_name else None - ) + artist_mapping = self._artist_mapping(artist_name, artist_id) if artist_name else None break except Exception as err: self.logger.debug("Could not enrich Beatbump track %s: %s", prov_track_id, err) - track = Track( - item_id=prov_track_id, - provider=self.instance_id, - name=title, - duration=duration, - provider_mappings={self._provider_mapping(prov_track_id, True)}, - ) + track = Track(item_id=prov_track_id, provider=self.instance_id, name=title, duration=duration, provider_mappings={self._provider_mapping(prov_track_id, True)}) if artist_mapping: track.artists = UniqueList([artist_mapping]) return track @@ -273,26 +244,14 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea if status and status != "OK": raise ValueError(f"Beatbump reports {item_id} as not playable: {status}") formats = (payload.get("streamingData") or {}).get("adaptiveFormats") or [] - audio = [ - x - for x in formats - if isinstance(x, dict) - and x.get("url") - and "audio" in str(x.get("mimeType", "")).lower() - ] + audio = [x for x in formats if isinstance(x, dict) and x.get("url") and "audio" in str(x.get("mimeType", "")).lower()] if not audio: raise ValueError(f"Beatbump returned no audio stream for {item_id}") aac = [x for x in audio if _as_int(x.get("itag")) == 140] selected = aac[0] if aac else max(audio, key=lambda x: _as_int(x.get("bitrate")) or 0) url = str(selected["url"]) mime = str(selected.get("mimeType", "")).lower() - content_type = ( - ContentType.M4A - if "audio/mp4" in mime - else ContentType.WEBM - if "audio/webm" in mime - else ContentType.UNKNOWN - ) + content_type = ContentType.M4A if "audio/mp4" in mime else ContentType.WEBM if "audio/webm" in mime else ContentType.UNKNOWN expiration = 3600 expire = parse_qs(urlparse(url).query).get("expire", [None])[0] if expire: @@ -301,26 +260,8 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea except (TypeError, ValueError): pass duration_ms = _as_int(selected.get("approxDurationMs")) - self.logger.info( - "Beatbump playback %s -> itag=%s mime=%s", - item_id, - selected.get("itag"), - selected.get("mimeType"), - ) - return StreamDetails( - provider=self.instance_id, - item_id=item_id, - media_type=MediaType.TRACK, - stream_type=StreamType.HTTP, - path=url, - audio_format=AudioFormat(content_type=content_type), - duration=(duration_ms / 1000) if duration_ms else None, - can_seek=True, - allow_seek=True, - expiration=expiration, - is_realtime=True, - ) - + self.logger.info("Beatbump playback %s -> itag=%s mime=%s", item_id, selected.get("itag"), selected.get("mimeType")) + return StreamDetails(provider=self.instance_id, item_id=item_id, media_type=MediaType.TRACK, stream_type=StreamType.HTTP, path=url, audio_format=AudioFormat(content_type=content_type), duration=(duration_ms / 1000) if duration_ms else None, can_seek=True, allow_seek=True, expiration=expiration, is_realtime=True) def _first_string(data: Any, *keys: str) -> str | None: if not isinstance(data, dict): @@ -331,7 +272,6 @@ def _first_string(data: Any, *keys: str) -> str | None: return value return None - def _carousel_name(carousel: dict[str, Any]) -> str | None: header = carousel.get("header") if isinstance(header, str) and header: @@ -340,7 +280,6 @@ def _carousel_name(carousel: dict[str, Any]) -> str | None: return _first_string(header, "title", "text", "name") return _first_string(carousel, "title", "name") - def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: info = data.get("artistInfo") if isinstance(info, dict): @@ -359,7 +298,6 @@ def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: return name, _first_string(entry, "browseId", "id") return "", None - def _extract_duration(data: dict[str, Any]) -> int | None: for key in ("duration", "lengthSeconds"): value = _as_int(data.get(key)) @@ -378,7 +316,6 @@ def _extract_duration(data: dict[str, Any]) -> int | None: return seconds return None - def _as_int(value: Any) -> int | None: try: return int(value) From 3d084411da46b99ddbf88ad2942427419cba6ee6 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 15:21:52 +1000 Subject: [PATCH 36/40] Use Beatbump browse IDs for playlist browsing --- music_assistant_provider/beatbump/__init__.py | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index 4977da0..99e3909 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -101,9 +101,10 @@ def _carousel_items(self, payload: dict[str, Any], index: int, prefix: str) -> l browse_id = _first_string(endpoint, "browseId") if isinstance(endpoint, dict) else None page_type = _first_string(endpoint, "pageType") if isinstance(endpoint, dict) else None name = _first_string(item, "title", "name", "text") - # Albums and YouTube Music playlists both expose a playlistId which - # Beatbump's /playlist.json endpoint can resolve to playable tracks. - list_id = playlist_id or (browse_id if page_type == "MUSIC_PAGE_TYPE_PLAYLIST" else None) + # Beatbump's playlist Browse request needs the endpoint browseId (for + # example VLRDCLAK5...), not the RDCLAK5... playlistId exposed on + # Home/Trending items. Prefer browseId whenever Beatbump supplies it. + list_id = browse_id or playlist_id if name and list_id and list_id not in seen: seen.add(list_id) token = quote(list_id, safe="") @@ -303,19 +304,30 @@ def _extract_duration(data: dict[str, Any]) -> int | None: value = _as_int(data.get(key)) if value is not None: return value + length = data.get("length") + if isinstance(length, str): + parsed = _parse_duration_text(length) + if parsed is not None: + return parsed subtitle = data.get("subtitle") if isinstance(subtitle, list): for entry in reversed(subtitle): text = entry.get("text") if isinstance(entry, dict) else None if isinstance(text, str): - parts = text.split(":") - if len(parts) in (2, 3) and all(x.isdigit() for x in parts): - seconds = 0 - for part in parts: - seconds = seconds * 60 + int(part) - return seconds + parsed = _parse_duration_text(text) + if parsed is not None: + return parsed return None +def _parse_duration_text(text: str) -> int | None: + parts = text.split(":") + if len(parts) not in (2, 3) or not all(x.isdigit() for x in parts): + return None + seconds = 0 + for part in parts: + seconds = seconds * 60 + int(part) + return seconds + def _as_int(value: Any) -> int | None: try: return int(value) From e0898b16bbd961271914a22b9f48d54e92c9a9b1 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 15:25:47 +1000 Subject: [PATCH 37/40] Expand Beatbump browse with artists and artwork --- music_assistant_provider/beatbump/__init__.py | 278 ++++++++---------- 1 file changed, 129 insertions(+), 149 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index 99e3909..0bcf067 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -6,9 +6,9 @@ from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs, quote, unquote, urlparse -from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType +from music_assistant_models.enums import ContentType, ImageType, MediaType, ProviderFeature, StreamType from music_assistant_models.media_items import ( - Artist, AudioFormat, BrowseFolder, ItemMapping, MediaItemType, + Artist, AudioFormat, BrowseFolder, ItemMapping, MediaItemImage, MediaItemType, ProviderMapping, SearchResults, Track, UniqueList, ) from music_assistant_models.streamdetails import StreamDetails @@ -50,14 +50,17 @@ async def _get_json(self, path: str, **params: str) -> dict[str, Any]: raise ValueError("Beatbump returned an unexpected response") return data - async def _search_songs(self, query: str) -> list[dict[str, Any]]: - payload = await self._get_json("/api/v1/search.json", q=query, filter="songs") + async def _search_items(self, query: str, filter_name: str) -> list[dict[str, Any]]: + payload = await self._get_json("/api/v1/search.json", q=query, filter=filter_name) items: list[dict[str, Any]] = [] for shelf in payload.get("results") or []: if isinstance(shelf, dict): items.extend(x for x in (shelf.get("contents") or shelf.get("items") or []) if isinstance(x, dict)) return items + async def _search_songs(self, query: str) -> list[dict[str, Any]]: + return await self._search_items(query, "songs") + async def search(self, search_query: str, media_types: list[MediaType], limit: int = 5) -> SearchResults: result = SearchResults() if MediaType.TRACK in media_types: @@ -65,8 +68,21 @@ async def search(self, search_query: str, media_types: list[MediaType], limit: i result.tracks = [track for item in items[:limit] if (track := self._parse_track(item))] return result - def _browse_folder(self, item_id: str, path: str, name: str) -> BrowseFolder: - return BrowseFolder(item_id=item_id, provider=self.instance_id, path=f"{self.instance_id}://{path}", name=name) + def _browse_folder(self, item_id: str, path: str, name: str, data: dict[str, Any] | None = None) -> BrowseFolder: + folder = BrowseFolder(item_id=item_id, provider=self.instance_id, path=f"{self.instance_id}://{path}", name=name) + if data: + self._add_images(folder, data) + return folder + + def _add_images(self, media_item: Any, data: dict[str, Any]) -> None: + thumbs = data.get("thumbnails") or data.get("foregroundThumbnails") or [] + if not isinstance(thumbs, list) or not thumbs: + return + valid = [x for x in thumbs if isinstance(x, dict) and isinstance(x.get("url"), str) and x.get("url")] + if not valid: + return + best = max(valid, key=lambda x: (_as_int(x.get("width")) or 0) * (_as_int(x.get("height")) or 0)) + media_item.metadata.images = UniqueList([MediaItemImage(type=ImageType.THUMB, path=best["url"], provider=self.instance_id, remotely_accessible=True)]) def _carousel_folders(self, payload: dict[str, Any], prefix: str) -> list[BrowseFolder]: result: list[BrowseFolder] = [] @@ -74,10 +90,8 @@ def _carousel_folders(self, payload: dict[str, Any], prefix: str) -> list[Browse if not isinstance(carousel, dict): continue items = carousel.get("items") or carousel.get("contents") or [] - if not items: - continue - name = _carousel_name(carousel) or f"Section {index + 1}" - result.append(self._browse_folder(f"{prefix}-{index}", f"{prefix}/section/{index}", name)) + if items: + result.append(self._browse_folder(f"{prefix}-{index}", f"{prefix}/section/{index}", _carousel_name(carousel) or f"Section {index + 1}")) return result def _carousel_items(self, payload: dict[str, Any], index: int, prefix: str) -> list[MediaItemType | BrowseFolder]: @@ -93,101 +107,107 @@ def _carousel_items(self, payload: dict[str, Any], index: int, prefix: str) -> l video_id = _first_string(item, "videoId") if video_id: if video_id not in seen and (track := self._parse_track(item, position)): - seen.add(video_id) - result.append(track) + seen.add(video_id); result.append(track) continue - playlist_id = _first_string(item, "playlistId") endpoint = item.get("endpoint") or {} browse_id = _first_string(endpoint, "browseId") if isinstance(endpoint, dict) else None - page_type = _first_string(endpoint, "pageType") if isinstance(endpoint, dict) else None + page_type = _first_string(endpoint, "pageType") if isinstance(endpoint, dict) else "" + playlist_id = _first_string(item, "playlistId") name = _first_string(item, "title", "name", "text") - # Beatbump's playlist Browse request needs the endpoint browseId (for - # example VLRDCLAK5...), not the RDCLAK5... playlistId exposed on - # Home/Trending items. Prefer browseId whenever Beatbump supplies it. + if not name: + continue + if browse_id and "ARTIST" in page_type: + key = f"artist:{browse_id}" + if key not in seen: + seen.add(key) + result.append(self._browse_folder(key, f"artist/{quote(browse_id, safe='')}", name, item)) + continue list_id = browse_id or playlist_id - if name and list_id and list_id not in seen: + if list_id and list_id not in seen: seen.add(list_id) - token = quote(list_id, safe="") - result.append(self._browse_folder(f"item-{list_id}", f"{prefix}/item/{token}", name)) + result.append(self._browse_folder(f"item-{list_id}", f"{prefix}/item/{quote(list_id, safe='')}", name, item)) return result async def _browse_playlist_tracks(self, list_id: str) -> list[Track]: payload = await self._get_json("/api/v1/playlist.json", list=list_id) + return self._tracks_from_items(payload.get("tracks") or []) + + def _tracks_from_items(self, items: Any) -> list[Track]: result: list[Track] = [] seen: set[str] = set() - for position, item in enumerate(payload.get("tracks") or [], start=1): + if not isinstance(items, list): + return result + for position, item in enumerate(items, start=1): if not isinstance(item, dict): continue video_id = _first_string(item, "videoId") - if not video_id or video_id in seen: - continue - if track := self._parse_track(item, position): - seen.add(video_id) - result.append(track) + if video_id and video_id not in seen and (track := self._parse_track(item, position)): + seen.add(video_id); result.append(track) + return result + + async def _browse_artist(self, artist_id: str) -> list[MediaItemType | BrowseFolder]: + payload = await self._get_json(f"/api/v1/artist/{artist_id}") + result: list[MediaItemType | BrowseFolder] = [] + songs = payload.get("songs") or {} + if isinstance(songs, dict): + result.extend(self._tracks_from_items(songs.get("contents") or songs.get("items") or [])) + result.extend(self._carousel_folders(payload, f"artist/{quote(artist_id, safe='')}")) return result async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: - """Expose Beatbump Home, Trending and Explore as a native MA browse tree.""" subpath = path.split("://", 1)[1].strip("/") if "://" in path else "" if not subpath: - return [ - self._browse_folder("home", "home", "Home"), - self._browse_folder("trending", "trending", "Trending"), - self._browse_folder("explore", "explore", "Explore"), - ] - + return [self._browse_folder("home", "home", "Home"), self._browse_folder("trending", "trending", "Trending"), self._browse_folder("explore", "explore", "Explore")] parts = subpath.split("/") root = parts[0] + if root == "artist" and len(parts) >= 2: + artist_id = unquote(parts[1]) + payload = await self._get_json(f"/api/v1/artist/{artist_id}") + if len(parts) == 2: + result: list[MediaItemType | BrowseFolder] = [] + songs = payload.get("songs") or {} + if isinstance(songs, dict): result.extend(self._tracks_from_items(songs.get("contents") or songs.get("items") or [])) + result.extend(self._carousel_folders(payload, f"artist/{parts[1]}")) + return result + if len(parts) == 4 and parts[2] == "section": + try: return self._carousel_items(payload, int(parts[3]), f"artist/{parts[1]}") + except ValueError: return [] + if len(parts) == 4 and parts[2] == "item": + return await self._browse_playlist_tracks(unquote(parts[3])) + return [] if root in ("home", "trending"): - endpoint = "/api/v1/home.json" if root == "home" else "/api/v1/trending" - payload = await self._get_json(endpoint) - if len(parts) == 1: - return self._carousel_folders(payload, root) + payload = await self._get_json("/api/v1/home.json" if root == "home" else "/api/v1/trending") + if len(parts) == 1: return self._carousel_folders(payload, root) if len(parts) == 3 and parts[1] == "section": - try: - return self._carousel_items(payload, int(parts[2]), root) - except ValueError: - return [] - if len(parts) == 3 and parts[1] == "item": - return await self._browse_playlist_tracks(unquote(parts[2])) + try: return self._carousel_items(payload, int(parts[2]), root) + except ValueError: return [] + if len(parts) == 3 and parts[1] == "item": return await self._browse_playlist_tracks(unquote(parts[2])) return [] - if root == "explore": if len(parts) == 1: payload = await self._get_json_any("/api/v1/explore") - if not isinstance(payload, list): - return [] + if not isinstance(payload, list): return [] result: list[BrowseFolder] = [] - category_index = 0 + n = 0 for section in payload: - if not isinstance(section, dict): - continue + if not isinstance(section, dict): continue for category in section.get("section") or []: - if not isinstance(category, dict): - continue - name = _first_string(category, "text", "name") - endpoint = category.get("endpoint") or {} + if not isinstance(category, dict): continue + name = _first_string(category, "text", "name"); endpoint = category.get("endpoint") or {} params = _first_string(endpoint, "params") if isinstance(endpoint, dict) else None - if not name or not params: - continue - token = quote(params, safe="") - result.append(self._browse_folder(f"explore-{category_index}", f"explore/category/{token}", name)) - category_index += 1 + if name and params: + token = quote(params, safe="") + result.append(self._browse_folder(f"explore-{n}", f"explore/category/{token}", name)); n += 1 return result if len(parts) >= 3 and parts[1] == "category": - token = parts[2] - category = unquote(token) + token = parts[2]; category = unquote(token) payload = await self._get_json(f"/api/v1/explore/{quote(category, safe='')}") prefix = f"explore/category/{token}" - if len(parts) == 3: - return self._carousel_folders(payload, prefix) + if len(parts) == 3: return self._carousel_folders(payload, prefix) if len(parts) == 5 and parts[3] == "section": - try: - return self._carousel_items(payload, int(parts[4]), prefix) - except ValueError: - return [] - if len(parts) == 5 and parts[3] == "item": - return await self._browse_playlist_tracks(unquote(parts[4])) + try: return self._carousel_items(payload, int(parts[4]), prefix) + except ValueError: return [] + if len(parts) == 5 and parts[3] == "item": return await self._browse_playlist_tracks(unquote(parts[4])) return [] return [] @@ -195,90 +215,64 @@ def _provider_mapping(self, item_id: str, audio: bool = False) -> ProviderMappin return ProviderMapping(item_id=item_id, provider_domain=self.domain, provider_instance=self.instance_id, available=True, audio_format=AudioFormat(content_type=ContentType.M4A) if audio else None) def _artist_mapping(self, name: str, artist_id: str | None) -> ItemMapping | None: - if not artist_id: - return None - return ItemMapping(media_type=MediaType.ARTIST, item_id=artist_id, provider=self.instance_id, name=name) + return ItemMapping(media_type=MediaType.ARTIST, item_id=artist_id, provider=self.instance_id, name=name) if artist_id else None def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: - video_id = _first_string(data, "videoId") - title = _first_string(data, "title", "name", "text") - if not video_id or not title: - return None - artist_name, artist_id = _extract_artist(data) - artists = UniqueList() - if artist_name and (mapping := self._artist_mapping(artist_name, artist_id)): - artists.append(mapping) + video_id = _first_string(data, "videoId"); title = _first_string(data, "title", "name", "text") + if not video_id or not title: return None + artist_name, artist_id = _extract_artist(data); artists = UniqueList() + if artist_name and (mapping := self._artist_mapping(artist_name, artist_id)): artists.append(mapping) track = Track(item_id=video_id, provider=self.instance_id, name=title, duration=_extract_duration(data), provider_mappings={self._provider_mapping(video_id, True)}, position=position or None) - if artists: - track.artists = artists + if artists: track.artists = artists + self._add_images(track, data) return track async def get_artist(self, prov_artist_id: str) -> Artist: - payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}") - header = payload.get("header") or {} - name = _first_string(header, "name", "title") or prov_artist_id - return Artist(item_id=prov_artist_id, provider=self.instance_id, name=name, provider_mappings={self._provider_mapping(prov_artist_id)}) + payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}"); header = payload.get("header") or {} + artist = Artist(item_id=prov_artist_id, provider=self.instance_id, name=_first_string(header, "name", "title") or prov_artist_id, provider_mappings={self._provider_mapping(prov_artist_id)}) + if isinstance(header, dict): self._add_images(artist, header) + return artist async def get_track(self, prov_track_id: str) -> Track: - payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id) - details = payload.get("videoDetails") or {} - title = str(details.get("title") or prov_track_id) - duration = _as_int(details.get("lengthSeconds")) - artist_mapping: ItemMapping | None = None + payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id); details = payload.get("videoDetails") or {} + title = str(details.get("title") or prov_track_id); duration = _as_int(details.get("lengthSeconds")); artist_mapping = None; source_item = None try: for candidate in await self._search_songs(title): - if _first_string(candidate, "videoId") != prov_track_id: - continue - artist_name, artist_id = _extract_artist(candidate) - artist_mapping = self._artist_mapping(artist_name, artist_id) if artist_name else None - break - except Exception as err: - self.logger.debug("Could not enrich Beatbump track %s: %s", prov_track_id, err) + if _first_string(candidate, "videoId") == prov_track_id: + source_item = candidate; artist_name, artist_id = _extract_artist(candidate) + artist_mapping = self._artist_mapping(artist_name, artist_id) if artist_name else None; break + except Exception as err: self.logger.debug("Could not enrich Beatbump track %s: %s", prov_track_id, err) track = Track(item_id=prov_track_id, provider=self.instance_id, name=title, duration=duration, provider_mappings={self._provider_mapping(prov_track_id, True)}) - if artist_mapping: - track.artists = UniqueList([artist_mapping]) + if artist_mapping: track.artists = UniqueList([artist_mapping]) + if source_item: self._add_images(track, source_item) return track async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: - payload = await self._get_json("/api/v1/player.json", videoId=item_id) - status = (payload.get("playabilityStatus") or {}).get("status") - if status and status != "OK": - raise ValueError(f"Beatbump reports {item_id} as not playable: {status}") + payload = await self._get_json("/api/v1/player.json", videoId=item_id); status = (payload.get("playabilityStatus") or {}).get("status") + if status and status != "OK": raise ValueError(f"Beatbump reports {item_id} as not playable: {status}") formats = (payload.get("streamingData") or {}).get("adaptiveFormats") or [] audio = [x for x in formats if isinstance(x, dict) and x.get("url") and "audio" in str(x.get("mimeType", "")).lower()] - if not audio: - raise ValueError(f"Beatbump returned no audio stream for {item_id}") - aac = [x for x in audio if _as_int(x.get("itag")) == 140] - selected = aac[0] if aac else max(audio, key=lambda x: _as_int(x.get("bitrate")) or 0) - url = str(selected["url"]) - mime = str(selected.get("mimeType", "")).lower() - content_type = ContentType.M4A if "audio/mp4" in mime else ContentType.WEBM if "audio/webm" in mime else ContentType.UNKNOWN - expiration = 3600 - expire = parse_qs(urlparse(url).query).get("expire", [None])[0] + if not audio: raise ValueError(f"Beatbump returned no audio stream for {item_id}") + aac = [x for x in audio if _as_int(x.get("itag")) == 140]; selected = aac[0] if aac else max(audio, key=lambda x: _as_int(x.get("bitrate")) or 0) + url = str(selected["url"]); mime = str(selected.get("mimeType", "")).lower(); content_type = ContentType.M4A if "audio/mp4" in mime else ContentType.WEBM if "audio/webm" in mime else ContentType.UNKNOWN + expiration = 3600; expire = parse_qs(urlparse(url).query).get("expire", [None])[0] if expire: - try: - expiration = max(60, int(expire) - int(time.time())) - except (TypeError, ValueError): - pass + try: expiration = max(60, int(expire) - int(time.time())) + except (TypeError, ValueError): pass duration_ms = _as_int(selected.get("approxDurationMs")) - self.logger.info("Beatbump playback %s -> itag=%s mime=%s", item_id, selected.get("itag"), selected.get("mimeType")) return StreamDetails(provider=self.instance_id, item_id=item_id, media_type=MediaType.TRACK, stream_type=StreamType.HTTP, path=url, audio_format=AudioFormat(content_type=content_type), duration=(duration_ms / 1000) if duration_ms else None, can_seek=True, allow_seek=True, expiration=expiration, is_realtime=True) def _first_string(data: Any, *keys: str) -> str | None: - if not isinstance(data, dict): - return None + if not isinstance(data, dict): return None for key in keys: value = data.get(key) - if isinstance(value, str) and value: - return value + if isinstance(value, str) and value: return value return None def _carousel_name(carousel: dict[str, Any]) -> str | None: header = carousel.get("header") - if isinstance(header, str) and header: - return header - if isinstance(header, dict): - return _first_string(header, "title", "text", "name") + if isinstance(header, str) and header: return header + if isinstance(header, dict): return _first_string(header, "title", "text", "name") return _first_string(carousel, "title", "name") def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: @@ -286,50 +280,36 @@ def _extract_artist(data: dict[str, Any]) -> tuple[str, str | None]: if isinstance(info, dict): artists = info.get("artist") if isinstance(artists, list) and artists and isinstance(artists[0], dict): - first = artists[0] - name = _first_string(first, "text", "name") - if name: - return name, _first_string(first, "browseId", "id") + first = artists[0]; name = _first_string(first, "text", "name") + if name: return name, _first_string(first, "browseId", "id") subtitle = data.get("subtitle") if isinstance(subtitle, list): for entry in subtitle: if isinstance(entry, dict) and "ARTIST" in str(entry.get("pageType", "")): name = _first_string(entry, "text", "name") - if name: - return name, _first_string(entry, "browseId", "id") + if name: return name, _first_string(entry, "browseId", "id") return "", None def _extract_duration(data: dict[str, Any]) -> int | None: for key in ("duration", "lengthSeconds"): value = _as_int(data.get(key)) - if value is not None: - return value + if value is not None: return value length = data.get("length") - if isinstance(length, str): - parsed = _parse_duration_text(length) - if parsed is not None: - return parsed + if isinstance(length, str) and (parsed := _parse_duration_text(length)) is not None: return parsed subtitle = data.get("subtitle") if isinstance(subtitle, list): for entry in reversed(subtitle): text = entry.get("text") if isinstance(entry, dict) else None - if isinstance(text, str): - parsed = _parse_duration_text(text) - if parsed is not None: - return parsed + if isinstance(text, str) and (parsed := _parse_duration_text(text)) is not None: return parsed return None def _parse_duration_text(text: str) -> int | None: parts = text.split(":") - if len(parts) not in (2, 3) or not all(x.isdigit() for x in parts): - return None + if len(parts) not in (2, 3) or not all(x.isdigit() for x in parts): return None seconds = 0 - for part in parts: - seconds = seconds * 60 + int(part) + for part in parts: seconds = seconds * 60 + int(part) return seconds def _as_int(value: Any) -> int | None: - try: - return int(value) - except (TypeError, ValueError): - return None + try: return int(value) + except (TypeError, ValueError): return None From e0c2b2ceb6069a9ad6188f543a78fff75cb578fa Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 15:44:04 +1000 Subject: [PATCH 38/40] Fix Music Assistant browse artwork --- music_assistant_provider/beatbump/__init__.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index 0bcf067..adc023a 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -68,21 +68,30 @@ async def search(self, search_query: str, media_types: list[MediaType], limit: i result.tracks = [track for item in items[:limit] if (track := self._parse_track(item))] return result - def _browse_folder(self, item_id: str, path: str, name: str, data: dict[str, Any] | None = None) -> BrowseFolder: - folder = BrowseFolder(item_id=item_id, provider=self.instance_id, path=f"{self.instance_id}://{path}", name=name) - if data: - self._add_images(folder, data) - return folder - - def _add_images(self, media_item: Any, data: dict[str, Any]) -> None: + def _image_from_data(self, data: dict[str, Any]) -> MediaItemImage | None: thumbs = data.get("thumbnails") or data.get("foregroundThumbnails") or [] if not isinstance(thumbs, list) or not thumbs: - return + return None valid = [x for x in thumbs if isinstance(x, dict) and isinstance(x.get("url"), str) and x.get("url")] if not valid: - return + return None best = max(valid, key=lambda x: (_as_int(x.get("width")) or 0) * (_as_int(x.get("height")) or 0)) - media_item.metadata.images = UniqueList([MediaItemImage(type=ImageType.THUMB, path=best["url"], provider=self.instance_id, remotely_accessible=True)]) + return MediaItemImage(type=ImageType.THUMB, path=best["url"], provider=self.instance_id, remotely_accessible=True) + + def _browse_folder(self, item_id: str, path: str, name: str, data: dict[str, Any] | None = None) -> BrowseFolder: + image = self._image_from_data(data) if data else None + return BrowseFolder( + item_id=item_id, + provider=self.instance_id, + path=f"{self.instance_id}://{path}", + name=name, + image=image, + ) + + def _add_images(self, media_item: Any, data: dict[str, Any]) -> None: + image = self._image_from_data(data) + if image is not None: + media_item.metadata.images = UniqueList([image]) def _carousel_folders(self, payload: dict[str, Any], prefix: str) -> list[BrowseFolder]: result: list[BrowseFolder] = [] @@ -91,7 +100,7 @@ def _carousel_folders(self, payload: dict[str, Any], prefix: str) -> list[Browse continue items = carousel.get("items") or carousel.get("contents") or [] if items: - result.append(self._browse_folder(f"{prefix}-{index}", f"{prefix}/section/{index}", _carousel_name(carousel) or f"Section {index + 1}")) + result.append(self._browse_folder(f"{prefix}-{index}", f"{prefix}/section/{index}", _carousel_name(carousel) or f"Section {index + 1}", carousel)) return result def _carousel_items(self, payload: dict[str, Any], index: int, prefix: str) -> list[MediaItemType | BrowseFolder]: From 6e5522f43d0209597949c0dd8196661be3defa65 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 16:21:21 +1000 Subject: [PATCH 39/40] Avoid nested artist artwork validation error in Music Assistant --- music_assistant_provider/beatbump/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index adc023a..3783323 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -238,9 +238,7 @@ def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: async def get_artist(self, prov_artist_id: str) -> Artist: payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}"); header = payload.get("header") or {} - artist = Artist(item_id=prov_artist_id, provider=self.instance_id, name=_first_string(header, "name", "title") or prov_artist_id, provider_mappings={self._provider_mapping(prov_artist_id)}) - if isinstance(header, dict): self._add_images(artist, header) - return artist + return Artist(item_id=prov_artist_id, provider=self.instance_id, name=_first_string(header, "name", "title") or prov_artist_id, provider_mappings={self._provider_mapping(prov_artist_id)}) async def get_track(self, prov_track_id: str) -> Track: payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id); details = payload.get("videoDetails") or {} From 4077c00924ae2e9305e30de6e4cb5406fe1c8e25 Mon Sep 17 00:00:00 2001 From: aaronh86 Date: Thu, 17 Sep 2026 16:29:22 +1000 Subject: [PATCH 40/40] Avoid recursive artist resolution for Beatbump queue tracks --- music_assistant_provider/beatbump/__init__.py | 85 ++++++------------- 1 file changed, 28 insertions(+), 57 deletions(-) diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py index 3783323..051d10c 100644 --- a/music_assistant_provider/beatbump/__init__.py +++ b/music_assistant_provider/beatbump/__init__.py @@ -80,13 +80,7 @@ def _image_from_data(self, data: dict[str, Any]) -> MediaItemImage | None: def _browse_folder(self, item_id: str, path: str, name: str, data: dict[str, Any] | None = None) -> BrowseFolder: image = self._image_from_data(data) if data else None - return BrowseFolder( - item_id=item_id, - provider=self.instance_id, - path=f"{self.instance_id}://{path}", - name=name, - image=image, - ) + return BrowseFolder(item_id=item_id, provider=self.instance_id, path=f"{self.instance_id}://{path}", name=name, image=image) def _add_images(self, media_item: Any, data: dict[str, Any]) -> None: image = self._image_from_data(data) @@ -96,8 +90,7 @@ def _add_images(self, media_item: Any, data: dict[str, Any]) -> None: def _carousel_folders(self, payload: dict[str, Any], prefix: str) -> list[BrowseFolder]: result: list[BrowseFolder] = [] for index, carousel in enumerate(payload.get("carousels") or []): - if not isinstance(carousel, dict): - continue + if not isinstance(carousel, dict): continue items = carousel.get("items") or carousel.get("contents") or [] if items: result.append(self._browse_folder(f"{prefix}-{index}", f"{prefix}/section/{index}", _carousel_name(carousel) or f"Section {index + 1}", carousel)) @@ -105,14 +98,11 @@ def _carousel_folders(self, payload: dict[str, Any], prefix: str) -> list[Browse def _carousel_items(self, payload: dict[str, Any], index: int, prefix: str) -> list[MediaItemType | BrowseFolder]: carousels = payload.get("carousels") or [] - if index < 0 or index >= len(carousels) or not isinstance(carousels[index], dict): - return [] - result: list[MediaItemType | BrowseFolder] = [] - seen: set[str] = set() + if index < 0 or index >= len(carousels) or not isinstance(carousels[index], dict): return [] + result: list[MediaItemType | BrowseFolder] = []; seen: set[str] = set() items = carousels[index].get("items") or carousels[index].get("contents") or [] for position, item in enumerate(items, start=1): - if not isinstance(item, dict): - continue + if not isinstance(item, dict): continue video_id = _first_string(item, "videoId") if video_id: if video_id not in seen and (track := self._parse_track(item, position)): @@ -121,20 +111,16 @@ def _carousel_items(self, payload: dict[str, Any], index: int, prefix: str) -> l endpoint = item.get("endpoint") or {} browse_id = _first_string(endpoint, "browseId") if isinstance(endpoint, dict) else None page_type = _first_string(endpoint, "pageType") if isinstance(endpoint, dict) else "" - playlist_id = _first_string(item, "playlistId") - name = _first_string(item, "title", "name", "text") - if not name: - continue + playlist_id = _first_string(item, "playlistId"); name = _first_string(item, "title", "name", "text") + if not name: continue if browse_id and "ARTIST" in page_type: key = f"artist:{browse_id}" if key not in seen: - seen.add(key) - result.append(self._browse_folder(key, f"artist/{quote(browse_id, safe='')}", name, item)) + seen.add(key); result.append(self._browse_folder(key, f"artist/{quote(browse_id, safe='')}", name, item)) continue list_id = browse_id or playlist_id if list_id and list_id not in seen: - seen.add(list_id) - result.append(self._browse_folder(f"item-{list_id}", f"{prefix}/item/{quote(list_id, safe='')}", name, item)) + seen.add(list_id); result.append(self._browse_folder(f"item-{list_id}", f"{prefix}/item/{quote(list_id, safe='')}", name, item)) return result async def _browse_playlist_tracks(self, list_id: str) -> list[Track]: @@ -142,13 +128,10 @@ async def _browse_playlist_tracks(self, list_id: str) -> list[Track]: return self._tracks_from_items(payload.get("tracks") or []) def _tracks_from_items(self, items: Any) -> list[Track]: - result: list[Track] = [] - seen: set[str] = set() - if not isinstance(items, list): - return result + result: list[Track] = []; seen: set[str] = set() + if not isinstance(items, list): return result for position, item in enumerate(items, start=1): - if not isinstance(item, dict): - continue + if not isinstance(item, dict): continue video_id = _first_string(item, "videoId") if video_id and video_id not in seen and (track := self._parse_track(item, position)): seen.add(video_id); result.append(track) @@ -158,31 +141,24 @@ async def _browse_artist(self, artist_id: str) -> list[MediaItemType | BrowseFol payload = await self._get_json(f"/api/v1/artist/{artist_id}") result: list[MediaItemType | BrowseFolder] = [] songs = payload.get("songs") or {} - if isinstance(songs, dict): - result.extend(self._tracks_from_items(songs.get("contents") or songs.get("items") or [])) - result.extend(self._carousel_folders(payload, f"artist/{quote(artist_id, safe='')}")) - return result + if isinstance(songs, dict): result.extend(self._tracks_from_items(songs.get("contents") or songs.get("items") or [])) + result.extend(self._carousel_folders(payload, f"artist/{quote(artist_id, safe='')}")); return result async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: subpath = path.split("://", 1)[1].strip("/") if "://" in path else "" if not subpath: return [self._browse_folder("home", "home", "Home"), self._browse_folder("trending", "trending", "Trending"), self._browse_folder("explore", "explore", "Explore")] - parts = subpath.split("/") - root = parts[0] + parts = subpath.split("/"); root = parts[0] if root == "artist" and len(parts) >= 2: - artist_id = unquote(parts[1]) - payload = await self._get_json(f"/api/v1/artist/{artist_id}") + artist_id = unquote(parts[1]); payload = await self._get_json(f"/api/v1/artist/{artist_id}") if len(parts) == 2: - result: list[MediaItemType | BrowseFolder] = [] - songs = payload.get("songs") or {} + result: list[MediaItemType | BrowseFolder] = []; songs = payload.get("songs") or {} if isinstance(songs, dict): result.extend(self._tracks_from_items(songs.get("contents") or songs.get("items") or [])) - result.extend(self._carousel_folders(payload, f"artist/{parts[1]}")) - return result + result.extend(self._carousel_folders(payload, f"artist/{parts[1]}")); return result if len(parts) == 4 and parts[2] == "section": try: return self._carousel_items(payload, int(parts[3]), f"artist/{parts[1]}") except ValueError: return [] - if len(parts) == 4 and parts[2] == "item": - return await self._browse_playlist_tracks(unquote(parts[3])) + if len(parts) == 4 and parts[2] == "item": return await self._browse_playlist_tracks(unquote(parts[3])) return [] if root in ("home", "trending"): payload = await self._get_json("/api/v1/home.json" if root == "home" else "/api/v1/trending") @@ -196,8 +172,7 @@ async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: if len(parts) == 1: payload = await self._get_json_any("/api/v1/explore") if not isinstance(payload, list): return [] - result: list[BrowseFolder] = [] - n = 0 + result: list[BrowseFolder] = []; n = 0 for section in payload: if not isinstance(section, dict): continue for category in section.get("section") or []: @@ -205,13 +180,10 @@ async def browse(self, path: str) -> Sequence[MediaItemType | BrowseFolder]: name = _first_string(category, "text", "name"); endpoint = category.get("endpoint") or {} params = _first_string(endpoint, "params") if isinstance(endpoint, dict) else None if name and params: - token = quote(params, safe="") - result.append(self._browse_folder(f"explore-{n}", f"explore/category/{token}", name)); n += 1 + token = quote(params, safe=""); result.append(self._browse_folder(f"explore-{n}", f"explore/category/{token}", name)); n += 1 return result if len(parts) >= 3 and parts[1] == "category": - token = parts[2]; category = unquote(token) - payload = await self._get_json(f"/api/v1/explore/{quote(category, safe='')}") - prefix = f"explore/category/{token}" + token = parts[2]; category = unquote(token); payload = await self._get_json(f"/api/v1/explore/{quote(category, safe='')}"); prefix = f"explore/category/{token}" if len(parts) == 3: return self._carousel_folders(payload, prefix) if len(parts) == 5 and parts[3] == "section": try: return self._carousel_items(payload, int(parts[4]), prefix) @@ -233,24 +205,23 @@ def _parse_track(self, data: dict[str, Any], position: int = 0) -> Track | None: if artist_name and (mapping := self._artist_mapping(artist_name, artist_id)): artists.append(mapping) track = Track(item_id=video_id, provider=self.instance_id, name=title, duration=_extract_duration(data), provider_mappings={self._provider_mapping(video_id, True)}, position=position or None) if artists: track.artists = artists - self._add_images(track, data) - return track + self._add_images(track, data); return track async def get_artist(self, prov_artist_id: str) -> Artist: payload = await self._get_json(f"/api/v1/artist/{prov_artist_id}"); header = payload.get("header") or {} return Artist(item_id=prov_artist_id, provider=self.instance_id, name=_first_string(header, "name", "title") or prov_artist_id, provider_mappings={self._provider_mapping(prov_artist_id)}) async def get_track(self, prov_track_id: str) -> Track: + """Resolve a playable track without recursively resolving artist objects.""" payload = await self._get_json("/api/v1/player.json", videoId=prov_track_id); details = payload.get("videoDetails") or {} - title = str(details.get("title") or prov_track_id); duration = _as_int(details.get("lengthSeconds")); artist_mapping = None; source_item = None + title = str(details.get("title") or prov_track_id); duration = _as_int(details.get("lengthSeconds")); source_item = None try: for candidate in await self._search_songs(title): if _first_string(candidate, "videoId") == prov_track_id: - source_item = candidate; artist_name, artist_id = _extract_artist(candidate) - artist_mapping = self._artist_mapping(artist_name, artist_id) if artist_name else None; break - except Exception as err: self.logger.debug("Could not enrich Beatbump track %s: %s", prov_track_id, err) + source_item = candidate; break + except Exception as err: + self.logger.debug("Could not enrich Beatbump track %s: %s", prov_track_id, err) track = Track(item_id=prov_track_id, provider=self.instance_id, name=title, duration=duration, provider_mappings={self._provider_mapping(prov_track_id, True)}) - if artist_mapping: track.artists = UniqueList([artist_mapping]) if source_item: self._add_images(track, source_item) return track