Skip to content

Commit aaae060

Browse files
committed
Navidrome Plugin - Let's begin from this
0 parents  commit aaae060

File tree

7 files changed

+381
-0
lines changed

7 files changed

+381
-0
lines changed

.github/workflows/release.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: Build and Release
2+
3+
on:
4+
workflow_dispatch:
5+
6+
permissions:
7+
contents: write
8+
9+
jobs:
10+
build-and-release:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Checkout code
15+
uses: actions/checkout@v4
16+
with:
17+
fetch-depth: 0
18+
19+
- name: Get next version
20+
id: version
21+
run: |
22+
# Get the latest release tag, default to 0 if none exists
23+
LATEST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -n1 | sed 's/v//' || echo "0")
24+
if [ -z "$LATEST_TAG" ]; then
25+
LATEST_TAG=0
26+
fi
27+
NEXT_VERSION=$((LATEST_TAG + 1))
28+
echo "version=$NEXT_VERSION" >> $GITHUB_OUTPUT
29+
echo "Next version will be: v$NEXT_VERSION"
30+
31+
- name: Setup Go
32+
uses: actions/setup-go@v5
33+
with:
34+
go-version: '1.23'
35+
36+
- name: Setup TinyGo
37+
uses: acifani/setup-tinygo@v2
38+
with:
39+
tinygo-version: '0.34.0'
40+
41+
- name: Download dependencies
42+
run: go mod download
43+
44+
- name: Update manifest version
45+
run: |
46+
VERSION="${{ steps.version.outputs.version }}.0.0"
47+
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" manifest.json
48+
49+
- name: Build and package plugin
50+
run: make package
51+
52+
- name: Create Release
53+
uses: softprops/action-gh-release@v2
54+
with:
55+
tag_name: v${{ steps.version.outputs.version }}
56+
name: Release v${{ steps.version.outputs.version }}
57+
body: |
58+
## AudioMuse-AI Navidrome Plugin v${{ steps.version.outputs.version }}
59+
60+
### Installation
61+
1. Download `audiomuseai.ndp` (or unpack `manifest.json` + `plugin.wasm`).
62+
2. Copy it to your Navidrome plugins directory and restart Navidrome.
63+
3. Activate the plugin in Navidrome's Plugin Management UI and set AudioMuse-AI API URL.
64+
65+
### What's included
66+
- `audiomuseai.ndp` - packaged plugin (manifest + plugin.wasm)
67+
files: |
68+
audiomuseai.ndp
69+
manifest.json
70+
draft: false
71+
prerelease: false

Makefile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
PLUGIN_NAME := audiomuseai
2+
PACKAGE_NAME := audiomuseai
3+
4+
.PHONY: build package clean
5+
6+
build:
7+
tinygo build -o $(PLUGIN_NAME).wasm -target=wasip1 -scheduler=none -buildmode=c-shared main.go
8+
9+
package: build
10+
# Navidrome expects the wasm file in the package to be named `plugin.wasm`
11+
cp $(PLUGIN_NAME).wasm plugin.wasm
12+
zip -j $(PACKAGE_NAME).ndp manifest.json plugin.wasm
13+
# Clean up temporary and built wasm output
14+
rm -f plugin.wasm $(PLUGIN_NAME).wasm
15+
16+
clean:
17+
rm -f $(PLUGIN_NAME).wasm $(PACKAGE_NAME).ndp

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# AudioMuse-AI Navidrome Plugin
2+
3+
A Navidrome plugin that reimplements the "Instant Mix" button to use AudioMuse-AI for similar track recommendations.
4+
5+
**IMPORTANT** InstantMix support in Navidrome is still not released in the stable image, you can find only in the develop image
6+
7+
## HOW-TO Install
8+
9+
- The ENV var ND_PLUGINS_ENABLED, ND_PLUGINS_AUTORELOAD and ND_AGENTS are important, assuming that you deploy with docker compose you should use something like this:
10+
11+
```yaml
12+
version: '3'
13+
services:
14+
navidrome:
15+
image: deluan/navidrome:latest
16+
ports:
17+
- '4533:4533'
18+
environment:
19+
- ND_PLUGINS_ENABLED=true
20+
- ND_PLUGINS_AUTORELOAD=true
21+
- ND_AGENTS=audiomuseai
22+
volumes:
23+
- ./data:/data
24+
- /path/to/music:/music:ro
25+
```
26+
27+
- Then you need to put `audiomuseai.ndp` in Navidrome data plugins folder (default: `/data/plugins`).
28+
- Restart Navidrome, go to UI -> Plugins, enable **AudioMuse-AI**, set **AudioMuse-AI API URL** and other configuration parameter.
29+
30+
## HOW-TO build
31+
32+
- Requirements (Ubuntu / macOS): Go, TinyGo.
33+
- Build:
34+
35+
```bash
36+
make build # -> audiomuseai.wasm
37+
make package # -> audiomuseai.ndp
38+
```
39+
40+
Full stop.

go.mod

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module audiomuse-navidrome-plugin
2+
3+
go 1.25
4+
5+
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260128174646-77367548f6a2
6+
7+
require (
8+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
9+
github.com/extism/go-pdk v1.1.3 // indirect
10+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
11+
github.com/stretchr/objx v0.5.2 // indirect
12+
github.com/stretchr/testify v1.11.1 // indirect
13+
gopkg.in/yaml.v3 v3.0.1 // indirect
14+
)

go.sum

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
2+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
4+
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
5+
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260128174646-77367548f6a2 h1:5apPLTMuyJFAMVQr1pT3Zo+Y1K7yzz6N+a9yLcfHhMU=
6+
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260128174646-77367548f6a2/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo=
7+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
8+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
9+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
10+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
11+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
12+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
13+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
14+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
15+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
16+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

main.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/url"
7+
"sort"
8+
"strconv"
9+
10+
"github.com/navidrome/navidrome/plugins/pdk/go/metadata"
11+
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
12+
)
13+
14+
// Configuration keys (must match manifest.json)
15+
const (
16+
configAPIUrl = "apiUrl"
17+
configTrackCount = "trackCount"
18+
configEliminateDuplicates = "eliminateDuplicates"
19+
configRadiusSimilarity = "radiusSimilarity"
20+
)
21+
22+
// Default values
23+
const (
24+
defaultAPIUrl = "http://192.168.3.203:8000"
25+
defaultTrackCount = 200
26+
defaultEliminateDuplicates = true
27+
defaultRadiusSimilarity = true
28+
)
29+
30+
// audioMuseResponse represents a single track from AudioMuse-AI API
31+
type audioMuseResponse struct {
32+
ItemID string `json:"item_id"`
33+
Title string `json:"title"`
34+
Author string `json:"author"`
35+
Album string `json:"album"`
36+
Distance float64 `json:"distance"`
37+
}
38+
39+
const pluginID = "audiomuseai"
40+
41+
type audioMusePlugin struct{}
42+
43+
func init() {
44+
metadata.Register(&audioMusePlugin{})
45+
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Plugin registered successfully (id: %s)", pluginID))
46+
}
47+
48+
// Compile-time check that we implement the interface
49+
var _ metadata.SimilarSongsByTrackProvider = (*audioMusePlugin)(nil)
50+
51+
// getConfigString retrieves a string config value with a default fallback
52+
func getConfigString(key, defaultValue string) string {
53+
if value, ok := pdk.GetConfig(key); ok && value != "" {
54+
return value
55+
}
56+
return defaultValue
57+
}
58+
59+
// getConfigInt retrieves an integer config value with a default fallback
60+
func getConfigInt(key string, defaultValue int) int {
61+
if value, ok := pdk.GetConfig(key); ok && value != "" {
62+
if intVal, err := strconv.Atoi(value); err == nil {
63+
return intVal
64+
}
65+
}
66+
return defaultValue
67+
}
68+
69+
// getConfigBool retrieves a boolean config value with a default fallback
70+
func getConfigBool(key string, defaultValue bool) bool {
71+
if value, ok := pdk.GetConfig(key); ok && value != "" {
72+
return value == "true"
73+
}
74+
return defaultValue
75+
}
76+
77+
func (p *audioMusePlugin) GetSimilarSongsByTrack(input metadata.SimilarSongsByTrackRequest) (*metadata.SimilarSongsResponse, error) {
78+
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] GetSimilarSongsByTrack called for track ID: %s, Name: %s, Artist: %s", input.ID, input.Name, input.Artist))
79+
80+
// Read configuration
81+
apiBaseURL := getConfigString(configAPIUrl, defaultAPIUrl)
82+
trackCount := getConfigInt(configTrackCount, defaultTrackCount)
83+
eliminateDuplicates := getConfigBool(configEliminateDuplicates, defaultEliminateDuplicates)
84+
radiusSimilarity := getConfigBool(configRadiusSimilarity, defaultRadiusSimilarity)
85+
86+
pdk.Log(pdk.LogDebug, fmt.Sprintf("[AudioMuse] Config - API URL: %s, TrackCount: %d, EliminateDuplicates: %v, RadiusSimilarity: %v",
87+
apiBaseURL, trackCount, eliminateDuplicates, radiusSimilarity))
88+
89+
// Build the API URL with query parameters
90+
params := url.Values{}
91+
params.Set("item_id", input.ID)
92+
params.Set("n", strconv.Itoa(trackCount))
93+
params.Set("eliminate_duplicates", strconv.FormatBool(eliminateDuplicates))
94+
params.Set("radius_similarity", strconv.FormatBool(radiusSimilarity))
95+
96+
apiURL := fmt.Sprintf("%s/api/similar_tracks?%s", apiBaseURL, params.Encode())
97+
98+
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Calling API: %s", apiURL))
99+
100+
// Make HTTP GET request to AudioMuse-AI using PDK
101+
req := pdk.NewHTTPRequest(pdk.MethodGet, apiURL)
102+
resp := req.Send()
103+
104+
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] API response status: %d", resp.Status()))
105+
106+
if resp.Status() != 200 {
107+
errMsg := fmt.Sprintf("[AudioMuse] ERROR: AudioMuse-AI returned status %d", resp.Status())
108+
pdk.Log(pdk.LogError, errMsg)
109+
return nil, fmt.Errorf("AudioMuse-AI returned status %d", resp.Status())
110+
}
111+
112+
// Parse JSON response
113+
var tracks []audioMuseResponse
114+
body := resp.Body()
115+
pdk.Log(pdk.LogDebug, fmt.Sprintf("[AudioMuse] Response body length: %d bytes", len(body)))
116+
117+
if err := json.Unmarshal(body, &tracks); err != nil {
118+
errMsg := fmt.Sprintf("[AudioMuse] ERROR: Failed to parse response: %v", err)
119+
pdk.Log(pdk.LogError, errMsg)
120+
return nil, fmt.Errorf("failed to parse AudioMuse-AI response: %w", err)
121+
}
122+
123+
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Successfully parsed %d similar tracks", len(tracks)))
124+
125+
126+
// Sort tracks by distance ascending (smaller distance = more similar)
127+
sort.Slice(tracks, func(i, j int) bool { return tracks[i].Distance < tracks[j].Distance })
128+
129+
// Convert to Navidrome SongRef format preserving order
130+
songs := make([]metadata.SongRef, 0, len(tracks))
131+
for _, track := range tracks {
132+
songs = append(songs, metadata.SongRef{
133+
ID: track.ItemID,
134+
Name: track.Title,
135+
Artist: track.Author,
136+
Album: track.Album,
137+
})
138+
}
139+
140+
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Returning %d songs to Navidrome", len(songs)))
141+
142+
return &metadata.SimilarSongsResponse{
143+
Songs: songs,
144+
}, nil
145+
}
146+
147+
func main() {}

manifest.json

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{
2+
"name": "AudioMuse-AI Instant Mix",
3+
"id": "audiomuseai",
4+
"author": "AudioMuse",
5+
"homepage": "https://github.com/yourusername/audiomuse-navidrome-plugin",
6+
"version": "1.0.0",
7+
"description": "Replaces the Instant Mix button with AudioMuse-AI recommendations for similar tracks",
8+
"config": {
9+
"schema": {
10+
"type": "object",
11+
"properties": {
12+
"apiUrl": {
13+
"type": "string",
14+
"title": "AudioMuse-AI API URL",
15+
"description": "Base URL for the AudioMuse-AI server (e.g., http://192.168.3.169:8087)",
16+
"default": "http://192.168.3.203:8000"
17+
},
18+
"trackCount": {
19+
"type": "integer",
20+
"title": "Number of Similar Tracks",
21+
"description": "Maximum number of similar tracks to return",
22+
"default": 200,
23+
"minimum": 10,
24+
"maximum": 500
25+
},
26+
"eliminateDuplicates": {
27+
"type": "boolean",
28+
"title": "Eliminate Duplicates",
29+
"description": "Remove duplicate tracks from results",
30+
"default": true
31+
},
32+
"radiusSimilarity": {
33+
"type": "boolean",
34+
"title": "Use Radius Similarity",
35+
"description": "Use radius-based similarity algorithm",
36+
"default": true
37+
}
38+
}
39+
},
40+
"uiSchema": {
41+
"type": "VerticalLayout",
42+
"elements": [
43+
{
44+
"type": "Control",
45+
"scope": "#/properties/apiUrl"
46+
},
47+
{
48+
"type": "Control",
49+
"scope": "#/properties/trackCount"
50+
},
51+
{
52+
"type": "HorizontalLayout",
53+
"elements": [
54+
{
55+
"type": "Control",
56+
"scope": "#/properties/eliminateDuplicates"
57+
},
58+
{
59+
"type": "Control",
60+
"scope": "#/properties/radiusSimilarity"
61+
}
62+
]
63+
}
64+
]
65+
}
66+
},
67+
"permissions": {
68+
"config": {
69+
"reason": "To read plugin configuration in Navidrome UI"
70+
},
71+
"http": {
72+
"reason": "To call AudioMuse-AI API for similar tracks",
73+
"requiredHosts": ["*"]
74+
}
75+
}
76+
}

0 commit comments

Comments
 (0)