mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-02 07:01:36 +00:00
feat: add Now Playing Logger plugin to showcase calling host functions from Python plugins
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
3b9d426c5c
commit
37f3b838d2
@ -2,8 +2,12 @@
|
||||
# Auto-discover all plugin folders (folders containing go.mod)
|
||||
PLUGINS := $(patsubst %/go.mod,%,$(wildcard */go.mod))
|
||||
|
||||
# Auto-discover Python plugins (folders containing plugin/__init__.py)
|
||||
PYTHON_PLUGINS := $(patsubst %/plugin/__init__.py,%,$(wildcard */plugin/__init__.py))
|
||||
|
||||
# Prefer tinygo if available, it produces smaller wasm binaries.
|
||||
TINYGO := $(shell command -v tinygo 2> /dev/null)
|
||||
EXTISM_PY := $(shell command -v extism-py 2> /dev/null)
|
||||
|
||||
# Default target: show available plugins
|
||||
.DEFAULT_GOAL := help
|
||||
@ -12,15 +16,24 @@ help:
|
||||
@echo "Available Go plugins:"
|
||||
@$(foreach p,$(PLUGINS),echo " $(p)";)
|
||||
@echo ""
|
||||
@echo "Available Python plugins:"
|
||||
@$(foreach p,$(PYTHON_PLUGINS),echo " $(p)";)
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make <plugin>.wasm Build a specific plugin (e.g., make $(firstword $(PLUGINS)).wasm)"
|
||||
@echo " make all Build all Go plugins"
|
||||
@echo " make clean Remove all built plugins"
|
||||
@echo " make all Build all plugins"
|
||||
@echo " make all-go Build all Go plugins"
|
||||
@echo " make all-python Build all Python plugins (requires extism-py)"
|
||||
@echo " make clean Remove all built plugins"
|
||||
|
||||
all: $(PLUGINS:%=%.wasm)
|
||||
all: all-go all-python
|
||||
|
||||
all-go: $(PLUGINS:%=%.wasm)
|
||||
|
||||
all-python: $(PYTHON_PLUGINS:%=%.wasm)
|
||||
|
||||
clean:
|
||||
rm -f $(PLUGINS:%=%.wasm)
|
||||
rm -f $(PLUGINS:%=%.wasm) $(PYTHON_PLUGINS:%=%.wasm)
|
||||
|
||||
%.wasm: %/*.go %/go.mod
|
||||
ifdef TINYGO
|
||||
@ -28,3 +41,10 @@ ifdef TINYGO
|
||||
else
|
||||
cd $* && GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ../$@ .
|
||||
endif
|
||||
|
||||
# Python plugin builds (generic rule for any folder with plugin/__init__.py)
|
||||
$(PYTHON_PLUGINS:%=%.wasm): %.wasm: %/plugin/__init__.py
|
||||
ifndef EXTISM_PY
|
||||
$(error extism-py is not installed. Install from https://github.com/extism/python-pdk)
|
||||
endif
|
||||
cd $* && extism-py plugin/__init__.py -o ../$@
|
||||
|
||||
@ -40,6 +40,7 @@ make clean
|
||||
| [crypto-ticker](crypto-ticker/) | Go | Real-time cryptocurrency prices from Coinbase using WebSocket |
|
||||
| [discord-rich-presence](discord-rich-presence/) | Go | Discord Rich Presence integration using Scrobbler, WebSocket, Scheduler |
|
||||
| [coverartarchive-py](coverartarchive-py/) | Python | Album cover art from Cover Art Archive (Python example) |
|
||||
| [nowplaying-py](nowplaying-py/) | Python | Logs currently playing tracks using Scheduler and SubsonicAPI |
|
||||
|
||||
## Testing with Extism CLI
|
||||
|
||||
|
||||
@ -1,50 +1,54 @@
|
||||
# Cover Art Archive Plugin (Python)
|
||||
|
||||
This plugin provides album cover images for Navidrome by querying the [Cover Art Archive](https://coverartarchive.org/) API using the MusicBrainz Release MBID.
|
||||
|
||||
**This is a Python example** demonstrating that Navidrome's plugin system supports multiple programming languages.
|
||||
A Python example plugin that fetches album cover images from the [Cover Art Archive](https://coverartarchive.org/) API using the MusicBrainz Release MBID.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the `nd_get_album_images` method of the MetadataAgent plugin interface
|
||||
- Returns front cover images for a given release MBID
|
||||
- Returns `not found` if no MBID is provided or no images are found
|
||||
- Demonstrates Python plugin development for Navidrome
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **extism-py** - Python to WASM compiler
|
||||
- [extism-py](https://github.com/extism/python-pdk) - Python PDK compiler
|
||||
```bash
|
||||
curl -Ls https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash
|
||||
```
|
||||
|
||||
Install using the official script:
|
||||
```bash
|
||||
curl -Ls https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash
|
||||
```
|
||||
> **Note:** `extism-py` requires [Binaryen](https://github.com/WebAssembly/binaryen/) (`wasm-merge`, `wasm-opt`) to be installed.
|
||||
|
||||
Or download from [extism/python-pdk releases](https://github.com/extism/python-pdk/releases).
|
||||
## Building
|
||||
|
||||
2. **Extism CLI** (optional, for testing)
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install extism/tap/extism
|
||||
|
||||
# Or see https://extism.org/docs/install
|
||||
```
|
||||
|
||||
## How to Build
|
||||
From the `plugins/examples` directory:
|
||||
|
||||
```bash
|
||||
make build
|
||||
make coverartarchive-py.wasm
|
||||
```
|
||||
|
||||
Or manually:
|
||||
Or directly:
|
||||
|
||||
```bash
|
||||
extism-py plugin/__init__.py -o coverartarchive-py.wasm
|
||||
```
|
||||
|
||||
This produces `coverartarchive-py.wasm` in this directory.
|
||||
## Installation
|
||||
|
||||
## Testing with Extism CLI
|
||||
1. Copy `coverartarchive-py.wasm` to your Navidrome plugins folder
|
||||
|
||||
2. Enable plugins in `navidrome.toml`:
|
||||
```toml
|
||||
[Plugins]
|
||||
Enabled = true
|
||||
Folder = "/path/to/plugins"
|
||||
```
|
||||
|
||||
3. Add to your agents list:
|
||||
```toml
|
||||
Agents = "coverartarchive-py,spotify,lastfm"
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Test the manifest:
|
||||
|
||||
@ -60,37 +64,14 @@ extism call coverartarchive-py.wasm nd_get_album_images --wasi \
|
||||
--allow-host "coverartarchive.org" --allow-host "archive.org"
|
||||
```
|
||||
|
||||
Run all tests:
|
||||
## How It Works
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
1. **Album Image Request (`nd_get_album_images`)**: Receives album metadata including the MusicBrainz Release MBID.
|
||||
|
||||
## Installation in Navidrome
|
||||
2. **API Query**: Fetches cover art metadata from `https://coverartarchive.org/release/{mbid}`.
|
||||
|
||||
1. Build the plugin:
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
2. Copy to your Navidrome plugins folder:
|
||||
```bash
|
||||
cp coverartarchive-py.wasm /path/to/navidrome/plugins/
|
||||
```
|
||||
|
||||
3. Enable plugins in `navidrome.toml`:
|
||||
```toml
|
||||
[Plugins]
|
||||
Enabled = true
|
||||
Folder = "/path/to/navidrome/plugins"
|
||||
```
|
||||
|
||||
4. Add to your agents list:
|
||||
```toml
|
||||
Agents = "coverartarchive-py,spotify,lastfm"
|
||||
```
|
||||
3. **Response**: Returns the front cover image URL if found.
|
||||
|
||||
## API Reference
|
||||
|
||||
- [Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API)
|
||||
- Endpoint used: `https://coverartarchive.org/release/{mbid}`
|
||||
|
||||
12
plugins/examples/nowplaying-py/Makefile
Normal file
12
plugins/examples/nowplaying-py/Makefile
Normal file
@ -0,0 +1,12 @@
|
||||
# Build the Now Playing Logger Python plugin
|
||||
.PHONY: build test clean
|
||||
|
||||
WASM_FILE = nowplaying-py.wasm
|
||||
|
||||
build: $(WASM_FILE)
|
||||
|
||||
$(WASM_FILE): plugin/__init__.py
|
||||
extism-py plugin/__init__.py -o $(WASM_FILE)
|
||||
|
||||
clean:
|
||||
rm -f $(WASM_FILE)
|
||||
116
plugins/examples/nowplaying-py/README.md
Normal file
116
plugins/examples/nowplaying-py/README.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Now Playing Logger Plugin (Python)
|
||||
|
||||
A Python example plugin that demonstrates the **Scheduler** and **SubsonicAPI** host services by periodically logging what is currently playing in Navidrome.
|
||||
|
||||
## Features
|
||||
|
||||
- Uses `scheduler_schedulerecurring` host function to set up a recurring task
|
||||
- Uses `subsonicapi_call` host function to query the `getNowPlaying` API
|
||||
- Configurable cron expression and user via plugin config
|
||||
- Demonstrates Python host function imports using `@extism.import_fn`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [extism-py](https://github.com/extism/python-pdk) - Python PDK compiler
|
||||
```bash
|
||||
curl -Ls https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash
|
||||
```
|
||||
|
||||
> **Note:** `extism-py` requires [Binaryen](https://github.com/WebAssembly/binaryen/) (`wasm-merge`, `wasm-opt`) to be installed.
|
||||
|
||||
## Building
|
||||
|
||||
From the `plugins/examples` directory:
|
||||
|
||||
```bash
|
||||
make nowplaying-py.wasm
|
||||
```
|
||||
|
||||
Or directly:
|
||||
|
||||
```bash
|
||||
extism-py plugin/__init__.py -o nowplaying-py.wasm
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy `nowplaying-py.wasm` to your Navidrome plugins folder
|
||||
|
||||
2. Enable plugins in `navidrome.toml`:
|
||||
```toml
|
||||
[Plugins]
|
||||
Enabled = true
|
||||
Folder = "/path/to/plugins"
|
||||
```
|
||||
|
||||
3. Configure the plugin (optional):
|
||||
```toml
|
||||
[PluginConfig.nowplaying-py]
|
||||
cron = "*/1 * * * *" # Check every minute (default)
|
||||
user = "admin" # Navidrome user for API calls (default)
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Key | Description | Default |
|
||||
|--------|-------------------------------------|------------------------------|
|
||||
| `cron` | Cron expression for check frequency | `*/1 * * * *` (every minute) |
|
||||
| `user` | Navidrome user for SubsonicAPI | `admin` |
|
||||
|
||||
## Testing
|
||||
|
||||
Test the manifest:
|
||||
|
||||
```bash
|
||||
extism call nowplaying-py.wasm nd_manifest --wasi
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
When running, the plugin logs messages like:
|
||||
|
||||
```
|
||||
🎵 john is playing: Pink Floyd - Comfortably Numb (The Wall)
|
||||
🎵 jane is playing: Radiohead - Paranoid Android (OK Computer)
|
||||
```
|
||||
|
||||
Or when no one is playing:
|
||||
|
||||
```
|
||||
🎵 No users currently playing music
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Initialization (`nd_on_init`)**: Reads the cron expression from config and schedules a recurring task using the Scheduler host service.
|
||||
|
||||
2. **Callback (`nd_scheduler_callback`)**: When the scheduled task fires, calls the SubsonicAPI `getNowPlaying` endpoint and logs the results.
|
||||
|
||||
## Host Function Usage (Python)
|
||||
|
||||
This plugin demonstrates how to call Navidrome host functions from Python:
|
||||
|
||||
```python
|
||||
import extism
|
||||
import json
|
||||
|
||||
# Import the host function
|
||||
@extism.import_fn("extism:host/user", "subsonicapi_call")
|
||||
def _subsonicapi_call(offset: int) -> int:
|
||||
"""Raw host function - returns memory offset."""
|
||||
...
|
||||
|
||||
# Wrapper for JSON marshalling
|
||||
def subsonicapi_call(uri: str) -> dict:
|
||||
request = {"uri": uri}
|
||||
request_bytes = json.dumps(request).encode('utf-8')
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _subsonicapi_call(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise Exception(response["error"])
|
||||
|
||||
return json.loads(response.get("responseJSON", "{}"))
|
||||
```
|
||||
197
plugins/examples/nowplaying-py/plugin/__init__.py
Normal file
197
plugins/examples/nowplaying-py/plugin/__init__.py
Normal file
@ -0,0 +1,197 @@
|
||||
# Now Playing Logger Plugin for Navidrome
|
||||
#
|
||||
# This plugin demonstrates the Scheduler and SubsonicAPI host services by
|
||||
# periodically logging what is currently playing in Navidrome.
|
||||
#
|
||||
# Build with:
|
||||
# extism-py plugin/__init__.py -o nowplaying-py.wasm
|
||||
#
|
||||
# Test manifest with:
|
||||
# extism call nowplaying-py.wasm nd_manifest --wasi
|
||||
#
|
||||
# Configuration:
|
||||
# [PluginConfig.nowplaying-py]
|
||||
# cron = "*/1 * * * *" # Every minute (default)
|
||||
# user = "admin" # User to query getNowPlaying (default)
|
||||
|
||||
import extism
|
||||
import json
|
||||
|
||||
# Schedule ID for our recurring task
|
||||
SCHEDULE_ID = "nowplaying-check"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Host Function Imports
|
||||
# =============================================================================
|
||||
# These are custom host functions provided by Navidrome.
|
||||
# We import them using the extism:host/user namespace.
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "scheduler_schedulerecurring")
|
||||
def _scheduler_schedulerecurring(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "subsonicapi_call")
|
||||
def _subsonicapi_call(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Host Function Wrappers
|
||||
# =============================================================================
|
||||
# These wrappers handle JSON marshalling/unmarshalling and memory management.
|
||||
|
||||
|
||||
def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_id: str) -> str:
|
||||
"""Schedule a recurring task using a cron expression.
|
||||
|
||||
Args:
|
||||
cron_expression: Cron format (e.g., "*/1 * * * *" for every minute)
|
||||
payload: Data to pass to the callback
|
||||
schedule_id: Unique identifier for the schedule
|
||||
|
||||
Returns:
|
||||
The schedule ID (same as input or auto-generated)
|
||||
"""
|
||||
request = {
|
||||
"cronExpression": cron_expression,
|
||||
"payload": payload,
|
||||
"scheduleID": schedule_id
|
||||
}
|
||||
request_bytes = json.dumps(request).encode('utf-8')
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _scheduler_schedulerecurring(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise Exception(response["error"])
|
||||
|
||||
return response.get("newScheduleID", schedule_id)
|
||||
|
||||
|
||||
def subsonicapi_call(uri: str) -> dict:
|
||||
"""Call a Subsonic API endpoint.
|
||||
|
||||
Args:
|
||||
uri: API path (e.g., "getNowPlaying")
|
||||
|
||||
Returns:
|
||||
Parsed JSON response from the API
|
||||
"""
|
||||
request = {"uri": uri}
|
||||
request_bytes = json.dumps(request).encode('utf-8')
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _subsonicapi_call(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise Exception(response["error"])
|
||||
|
||||
# Parse the nested JSON response
|
||||
response_json = response.get("responseJSON", "{}")
|
||||
return json.loads(response_json)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Plugin Exports
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@extism.plugin_fn
|
||||
def nd_manifest():
|
||||
"""Return the plugin manifest with metadata and permissions."""
|
||||
manifest = {
|
||||
"name": "Now Playing Logger (Python)",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "Periodically logs currently playing tracks - Python example demonstrating Scheduler and SubsonicAPI host services",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/nowplaying-py",
|
||||
"permissions": {
|
||||
"scheduler": {
|
||||
"reason": "Schedule periodic checks for now playing status"
|
||||
},
|
||||
"subsonicapi": {
|
||||
"reason": "Query the getNowPlaying API endpoint",
|
||||
"allowAdmins": True
|
||||
}
|
||||
}
|
||||
}
|
||||
extism.output_str(json.dumps(manifest))
|
||||
|
||||
|
||||
@extism.plugin_fn
|
||||
def nd_on_init():
|
||||
"""Initialize the plugin by scheduling the recurring task."""
|
||||
# Read cron expression from config, default to every minute
|
||||
cron = extism.Config.get_str("cron")
|
||||
if not cron:
|
||||
cron = "*/1 * * * *"
|
||||
|
||||
extism.log(extism.LogLevel.Info, f"Now Playing Logger initializing with cron: {cron}")
|
||||
|
||||
try:
|
||||
schedule_id = scheduler_schedule_recurring(cron, "check", SCHEDULE_ID)
|
||||
extism.log(extism.LogLevel.Info, f"Scheduled recurring task with ID: {schedule_id}")
|
||||
except Exception as e:
|
||||
extism.log(extism.LogLevel.Error, f"Failed to schedule task: {e}")
|
||||
raise
|
||||
|
||||
# Return empty success response
|
||||
extism.output_str(json.dumps({}))
|
||||
|
||||
|
||||
@extism.plugin_fn
|
||||
def nd_scheduler_callback():
|
||||
"""Handle scheduler callback - check and log now playing tracks."""
|
||||
input_data = extism.input_json()
|
||||
schedule_id = input_data.get("schedule_id", "")
|
||||
|
||||
# Only handle our schedule
|
||||
if schedule_id != SCHEDULE_ID:
|
||||
extism.output_str(json.dumps({}))
|
||||
return
|
||||
|
||||
try:
|
||||
# Read user from config, default to admin
|
||||
user = extism.Config.get_str("user")
|
||||
if not user:
|
||||
user = "admin"
|
||||
|
||||
# Call the getNowPlaying API
|
||||
response = subsonicapi_call(f"getNowPlaying?u={user}")
|
||||
|
||||
# Extract the subsonic-response
|
||||
subsonic_response = response.get("subsonic-response", {})
|
||||
now_playing = subsonic_response.get("nowPlaying", {})
|
||||
entries = now_playing.get("entry", [])
|
||||
|
||||
if not entries:
|
||||
extism.log(extism.LogLevel.Info, "🎵 No users currently playing music")
|
||||
else:
|
||||
# Handle both single entry and list of entries
|
||||
if isinstance(entries, dict):
|
||||
entries = [entries]
|
||||
|
||||
for entry in entries:
|
||||
artist = entry.get("artist", "Unknown Artist")
|
||||
title = entry.get("title", "Unknown Title")
|
||||
album = entry.get("album", "Unknown Album")
|
||||
username = entry.get("username", "Unknown User")
|
||||
|
||||
extism.log(
|
||||
extism.LogLevel.Info,
|
||||
f"🎵 {username} is playing: {artist} - {title} ({album})"
|
||||
)
|
||||
|
||||
extism.output_str(json.dumps({}))
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
extism.log(extism.LogLevel.Error, f"Failed to get now playing: {error_msg}")
|
||||
extism.output_str(json.dumps({"error": error_msg}))
|
||||
@ -150,7 +150,3 @@ wikimedia/
|
||||
| `nd_get_artist_url` | Returns Wikipedia URL for an artist |
|
||||
| `nd_get_artist_biography` | Returns artist biography from Wikipedia |
|
||||
| `nd_get_artist_images` | Returns artist image URLs from Wikidata |
|
||||
|
||||
## License
|
||||
|
||||
Same as Navidrome - GPL-3.0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user