diff --git a/plugins/examples/README.md b/plugins/examples/README.md index 99609e29c..08b17135d 100644 --- a/plugins/examples/README.md +++ b/plugins/examples/README.md @@ -6,7 +6,8 @@ This folder contains example plugins for Navidrome that demonstrate how to build ### Prerequisites -- [TinyGo](https://tinygo.org/getting-started/install/) (recommended) or Go 1.23+ +- [TinyGo](https://tinygo.org/getting-started/install/) (recommended) or Go 1.23+ (for Go plugins) +- [extism-py](https://github.com/extism/python-pdk) (for Python plugins) - [Extism CLI](https://extism.org/docs/install) (optional, for testing) ### Build all plugins @@ -32,12 +33,13 @@ make clean ## Available Examples -| Plugin | Description | -|-------------------------------------------------|-------------------------------------------------------------------------| -| [minimal](minimal/) | A minimal example showing the basic plugin structure | -| [wikimedia](wikimedia/) | Fetches artist metadata from Wikidata, DBpedia, and Wikipedia | -| [crypto-ticker](crypto-ticker/) | Real-time cryptocurrency prices from Coinbase using WebSocket | -| [discord-rich-presence](discord-rich-presence/) | Discord Rich Presence integration using Scrobbler, WebSocket, Scheduler | +| Plugin | Language | Description | +|-------------------------------------------------|----------|-------------------------------------------------------------------------| +| [minimal](minimal/) | Go | A minimal example showing the basic plugin structure | +| [wikimedia](wikimedia/) | Go | Fetches artist metadata from Wikidata, DBpedia, and Wikipedia | +| [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) | ## Testing with Extism CLI @@ -84,8 +86,9 @@ Agents = "lastfm,spotify,wikimedia" ## Creating Your Own Plugin -See the [minimal](minimal/) example for the simplest starting point, or [wikimedia](wikimedia/) for a more complete -example with HTTP requests, created with the [XTP CLI]((https://docs.xtp.dylibso.com/docs/cli). +The plugin system supports multiple languages. See the [minimal](minimal/) example for the simplest Go starting point, +[discord-rich-presence](discord-rich-presence/) for a more complete Go example with HTTP requests, or [coverartarchive-py](coverartarchive-py/) +for a Python example. ### Bootstrapping a New Plugin Use the XTP CLI to bootstrap a new plugin from a schema: diff --git a/plugins/examples/coverartarchive-py/Makefile b/plugins/examples/coverartarchive-py/Makefile new file mode 100644 index 000000000..e3cd60d1c --- /dev/null +++ b/plugins/examples/coverartarchive-py/Makefile @@ -0,0 +1,27 @@ +# Build the Cover Art Archive Python plugin +.PHONY: build test clean + +WASM_FILE = coverartarchive-py.wasm + +build: $(WASM_FILE) + +$(WASM_FILE): plugin/__init__.py + extism-py plugin/__init__.py -o $(WASM_FILE) + +test: build + @echo "Testing nd_manifest..." + extism call $(WASM_FILE) nd_manifest --wasi + @echo "" + @echo "Testing nd_get_album_images with Portishead's Dummy MBID..." + extism call $(WASM_FILE) nd_get_album_images --wasi \ + --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ + --allow-host "coverartarchive.org" --allow-host "archive.org" + +test-error: build + @echo "Testing error case (missing MBID)..." + -extism call $(WASM_FILE) nd_get_album_images --wasi \ + --input '{"name":"Test Album","artist":"Test Artist"}' \ + --allow-host "coverartarchive.org" + +clean: + rm -f $(WASM_FILE) diff --git a/plugins/examples/coverartarchive-py/README.md b/plugins/examples/coverartarchive-py/README.md new file mode 100644 index 000000000..29756e944 --- /dev/null +++ b/plugins/examples/coverartarchive-py/README.md @@ -0,0 +1,96 @@ +# 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. + +## 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 + +## Prerequisites + +1. **extism-py** - Python to WASM compiler + + Install using the official script: + ```bash + curl -Ls https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash + ``` + + Or download from [extism/python-pdk releases](https://github.com/extism/python-pdk/releases). + +2. **Extism CLI** (optional, for testing) + + ```bash + # macOS + brew install extism/tap/extism + + # Or see https://extism.org/docs/install + ``` + +## How to Build + +```bash +make build +``` + +Or manually: + +```bash +extism-py plugin/__init__.py -o coverartarchive-py.wasm +``` + +This produces `coverartarchive-py.wasm` in this directory. + +## Testing with Extism CLI + +Test the manifest: + +```bash +extism call coverartarchive-py.wasm nd_manifest --wasi +``` + +Test album image retrieval (using Portishead's "Dummy" MBID): + +```bash +extism call coverartarchive-py.wasm nd_get_album_images --wasi \ + --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ + --allow-host "coverartarchive.org" --allow-host "archive.org" +``` + +Run all tests: + +```bash +make test +``` + +## Installation in Navidrome + +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" + ``` + +## 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/coverartarchive-py/plugin/__init__.py b/plugins/examples/coverartarchive-py/plugin/__init__.py new file mode 100644 index 000000000..f8ef3b1f7 --- /dev/null +++ b/plugins/examples/coverartarchive-py/plugin/__init__.py @@ -0,0 +1,128 @@ +# Cover Art Archive Plugin for Navidrome +# +# This plugin fetches album cover art from the Cover Art Archive (https://coverartarchive.org/) +# using the MusicBrainz album MBID. +# +# Build with: +# extism-py plugin/__init__.py -o coverartarchive-py.wasm +# +# Test with: +# extism call coverartarchive-py.wasm nd_manifest --wasi +# extism call coverartarchive-py.wasm nd_get_album_images --wasi \ +# --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ +# --allow-host "coverartarchive.org" --allow-host "archive.org" + +import extism +import json + + +# Plugin manifest - identifies this plugin to Navidrome +@extism.plugin_fn +def nd_manifest(): + manifest = { + "name": "Cover Art Archive (Python)", + "author": "Navidrome", + "version": "1.0.0", + "description": "Album cover art from the Cover Art Archive - Python example", + "website": "https://coverartarchive.org", + "permissions": { + "http": { + "reason": "Fetch album cover art from Cover Art Archive API", + "allowedHosts": [ + "coverartarchive.org", + "*.archive.org" + ] + } + } + } + extism.output_str(json.dumps(manifest)) + + +@extism.plugin_fn +def nd_get_album_images(): + """Retrieve album cover images from Cover Art Archive.""" + input_data = extism.input_json() + mbid = input_data.get("mbid", "") + + if not mbid: + raise Exception("not found: MBID required") + + # Query Cover Art Archive API + url = f"https://coverartarchive.org/release/{mbid}" + response = extism.Http.request(url, meth="GET") + + if response.status_code != 200: + raise Exception(f"not found: CAA returned status {response.status_code}") + + try: + data = json.loads(response.data_str()) + except json.JSONDecodeError: + raise Exception("not found: invalid JSON response") + + caa_images = data.get("images", []) + if not caa_images: + raise Exception("not found: no images in response") + + # Find the front cover image + front_image = find_front_image(caa_images) + if not front_image: + raise Exception("not found: no front cover image") + + # Build the response with available image sizes + images = build_image_list(front_image) + if not images: + raise Exception("not found: no usable image URLs") + + extism.output_str(json.dumps({"images": images})) + + +def find_front_image(images): + """Find the front cover image from CAA response.""" + # First, look for an image explicitly marked as front + for img in images: + if img.get("front", False): + return img + + # Second, look for an image with "Front" in types + for img in images: + types = img.get("types", []) + if "Front" in types: + return img + + # Fallback to first image + if images: + return images[0] + + return None + + +def build_image_list(img): + """Build list of images with URLs and sizes from CAA image data.""" + images = [] + thumbnails = img.get("thumbnails", {}) + + # First, try numeric sizes (250, 500, 1200, etc.) + for size_str, url in thumbnails.items(): + if not url: + continue + try: + size = int(size_str) + images.append({"url": url, "size": size}) + except ValueError: + pass # Not a numeric size + + # If no numeric sizes, fallback to named sizes + if not images: + size_map = {"large": 500, "small": 250} + for size_name, size in size_map.items(): + url = thumbnails.get(size_name) + if url: + images.append({"url": url, "size": size}) + + # If still no images, use the main image URL + if not images: + main_url = img.get("image") + if main_url: + images.append({"url": main_url, "size": 0}) + + return images