Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
9e4e5b9
Comment out overscroll-behavior property
aaronh86 May 8, 2026
82248c8
Update _layout.scss
aaronh86 May 8, 2026
f6e21f6
Merge branch 'giwty:main' into main
aaronh86 May 8, 2026
0733eb7
Add Music Assistant Beatbump provider manifest
aaronh86 Sep 16, 2026
cd2000c
Add Beatbump provider setup flow
aaronh86 Sep 16, 2026
acaeb25
Implement initial Music Assistant Beatbump provider
aaronh86 Sep 16, 2026
813f934
Document Music Assistant provider installation
aaronh86 Sep 16, 2026
78903fb
Add Music Assistant provider installer
aaronh86 Sep 16, 2026
e66e260
Fix Music Assistant installer file list
aaronh86 Sep 16, 2026
34d7d98
Fix Music Assistant parsing of Beatbump song search results
aaronh86 Sep 16, 2026
b255260
Prefer AAC itag 140 for Music Assistant playback
aaronh86 Sep 16, 2026
b49fb49
Build out Beatbump Music Assistant catalog provider
aaronh86 Sep 16, 2026
8c7aca2
Install Beatbump icon with Music Assistant provider
aaronh86 Sep 16, 2026
4b4d414
Fix artist IDs resolved from Beatbump player metadata
aaronh86 Sep 16, 2026
531d32c
Add browsable Beatbump home catalogue to Music Assistant
aaronh86 Sep 16, 2026
dc1db94
Use provider instance for Beatbump browse folder
aaronh86 Sep 16, 2026
39fb6ad
Format Beatbump browse provider
aaronh86 Sep 16, 2026
cee568f
Expand Beatbump browse with Home, Trending and Explore
aaronh86 Sep 16, 2026
f5dfe24
Fix unfiltered all search fallback
aaronh86 Sep 16, 2026
d4eba9e
Avoid nil shelf continuation access
aaronh86 Sep 16, 2026
4e9b9a9
Format Beatbump search fallback
aaronh86 Sep 16, 2026
4c6853e
Return useful error when all search has no results
aaronh86 Sep 16, 2026
551df7c
Preserve all-search API compatibility
aaronh86 Sep 16, 2026
5dc1827
Keep all-search response payload compact
aaronh86 Sep 16, 2026
040b307
Fix all search with typed aggregation
aaronh86 Sep 16, 2026
0c4a605
Document all search aggregation behavior
aaronh86 Sep 16, 2026
fc846e9
Finalize robust Beatbump all search
aaronh86 Sep 16, 2026
47a5335
Complete Beatbump all-search fix
aaronh86 Sep 16, 2026
d64601d
Finalize search all aggregation
aaronh86 Sep 16, 2026
158d1de
Stabilize Beatbump all search
aaronh86 Sep 16, 2026
4c97321
Fix Beatbump all search endpoint
aaronh86 Sep 16, 2026
d894e3a
Fix all-search response handling
aaronh86 Sep 16, 2026
80b7910
Allow manual Docker image builds
aaronh86 Sep 17, 2026
188cd7a
Allow manual Docker image builds
aaronh86 Sep 17, 2026
285d683
Leave Docker workflow changes on main
aaronh86 Sep 17, 2026
1521900
Merge Music Assistant provider and Beatbump search improvements
aaronh86 Sep 17, 2026
3e7647d
Fix Music Assistant browse parsing for Beatbump API
aaronh86 Sep 17, 2026
3d08441
Use Beatbump browse IDs for playlist browsing
aaronh86 Sep 17, 2026
e0898b1
Expand Beatbump browse with artists and artwork
aaronh86 Sep 17, 2026
e0c2b2c
Fix Music Assistant browse artwork
aaronh86 Sep 17, 2026
6e5522f
Avoid nested artist artwork validation error in Music Assistant
aaronh86 Sep 17, 2026
4077c00
Avoid recursive artist resolution for Beatbump queue tracks
aaronh86 Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions app/src/global/redesign/base/_layout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ html {
height: 100%;
// overflow-y: auto;
max-width: 100%;
overscroll-behavior: contain;
//overscroll-behavior: contain;
}
main {
margin: 0 auto;
Expand All @@ -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) {
Expand Down
140 changes: 89 additions & 51 deletions backend/api/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions music_assistant_provider/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading