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:
Deluan 2025-12-26 17:09:08 -05:00
parent 3b9d426c5c
commit 37f3b838d2
7 changed files with 381 additions and 58 deletions

View File

@ -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 ../$@

View File

@ -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

View File

@ -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}`

View 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)

View 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", "{}"))
```

View 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}))

View File

@ -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