diff --git a/plugins/examples/Makefile b/plugins/examples/Makefile index 842c8261f..d50c4b94a 100644 --- a/plugins/examples/Makefile +++ b/plugins/examples/Makefile @@ -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 .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 ../$@ diff --git a/plugins/examples/README.md b/plugins/examples/README.md index b4f4a83c6..429178637 100644 --- a/plugins/examples/README.md +++ b/plugins/examples/README.md @@ -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 diff --git a/plugins/examples/coverartarchive-py/README.md b/plugins/examples/coverartarchive-py/README.md index 29756e944..eb6ffe7ab 100644 --- a/plugins/examples/coverartarchive-py/README.md +++ b/plugins/examples/coverartarchive-py/README.md @@ -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}` diff --git a/plugins/examples/nowplaying-py/Makefile b/plugins/examples/nowplaying-py/Makefile new file mode 100644 index 000000000..2bf6ea971 --- /dev/null +++ b/plugins/examples/nowplaying-py/Makefile @@ -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) diff --git a/plugins/examples/nowplaying-py/README.md b/plugins/examples/nowplaying-py/README.md new file mode 100644 index 000000000..725258498 --- /dev/null +++ b/plugins/examples/nowplaying-py/README.md @@ -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", "{}")) +``` \ No newline at end of file diff --git a/plugins/examples/nowplaying-py/plugin/__init__.py b/plugins/examples/nowplaying-py/plugin/__init__.py new file mode 100644 index 000000000..e82ea309b --- /dev/null +++ b/plugins/examples/nowplaying-py/plugin/__init__.py @@ -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})) diff --git a/plugins/examples/wikimedia/README.md b/plugins/examples/wikimedia/README.md index 6151eeb38..5785c099a 100644 --- a/plugins/examples/wikimedia/README.md +++ b/plugins/examples/wikimedia/README.md @@ -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