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: diff --git a/app/src/global/redesign/base/_layout.scss b/app/src/global/redesign/base/_layout.scss index 1accd90..a687f31 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; @@ -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) { diff --git a/backend/api/search.go b/backend/api/search.go index f3daed4..460ca3d 100644 --- a/backend/api/search.go +++ b/backend/api/search.go @@ -22,67 +22,125 @@ 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 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 { urlQuery := c.Request().URL.Query() query := urlQuery.Get("q") filter := urlQuery.Get("filter") - filterId := "" - if filter != "all" && filter != "" { - filterId = searchFilters[filter] - } - 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)) + } + + if (filter == "all" || filter == "") && 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 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 + } + 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 + } + for i := range shelves { + for j := range shelves[i].Contents { + shelves[i].Contents[j].Type = filter + } + } + results = append(results, shelves...) + } + + 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") + } + return c.JSON(http.StatusOK, struct { + Results []MusicShelf `json:"results"` + }{Results: results}) +} + +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 +149,25 @@ 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 } @@ -134,29 +178,23 @@ func parseResponse(content []_youtube.SectionListRendererContents) ([]MusicShelf if shelf.MusicShelfRenderer == nil { continue } - 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 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. diff --git a/music_assistant_provider/beatbump/__init__.py b/music_assistant_provider/beatbump/__init__.py new file mode 100644 index 0000000..051d10c --- /dev/null +++ b/music_assistant_provider/beatbump/__init__.py @@ -0,0 +1,293 @@ +"""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, quote, unquote, urlparse + +from music_assistant_models.enums import ContentType, ImageType, MediaType, ProviderFeature, StreamType +from music_assistant_models.media_items import ( + Artist, AudioFormat, BrowseFolder, ItemMapping, MediaItemImage, 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 + + @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_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() + 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_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: + items = await self._search_songs(search_query) + result.tracks = [track for item in items[:limit] if (track := self._parse_track(item))] + return result + + 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 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 None + best = max(valid, key=lambda x: (_as_int(x.get("width")) or 0) * (_as_int(x.get("height")) or 0)) + 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] = [] + for index, carousel in enumerate(payload.get("carousels") or []): + 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)) + return result + + 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 + 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 + 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 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)) + 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() + 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 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]: + 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 == "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"): + 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])) + 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] = []; n = 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 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); 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) + 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) + + def _artist_mapping(self, name: str, artist_id: str | None) -> ItemMapping | None: + 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) + 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 + + 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")); source_item = None + try: + for candidate in await self._search_songs(title): + if _first_string(candidate, "videoId") == prov_track_id: + 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 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}") + 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")) + 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 + 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): + 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 + length = data.get("length") + 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) 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 + seconds = 0 + 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 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": [] +} 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("/")}) diff --git a/music_assistant_provider/install.sh b/music_assistant_provider/install.sh new file mode 100644 index 0000000..d59a6a3 --- /dev/null +++ b/music_assistant_provider/install.sh @@ -0,0 +1,50 @@ +#!/bin/sh +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 +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 setup_flow.py manifest.json; do + echo "Downloading ${file}..." + fetch "${RAW}/${file}" "${PROVIDER_DIR}/${file}" +done + +# 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 Music Assistant, then add/configure Beatbump under Music Sources." +echo "NOTE: for TrueNAS, use a persistent host-path mount for this provider directory."