navidrome/plugins/examples/coverartarchive-py/plugin/__init__.py
Deluan Quintão 1072e9f7eb
chore(plugins): document requiredHosts rules and deprecate pdk.NewHTTPRequest (#6129)
* fix(plugins): align the Python HTTP example with the repo's host-call pattern

Bind http_send with raw memory offsets like nowplaying-py does, drop
guards for fields the host always sends, and document how plugins
without a PDK call host services and which built-in HTTP APIs are
disabled.

* docs(plugins): document the private-address rules for HTTP requiredHosts

Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can.

* docs(plugins): document the private-address rules for requiredHosts

Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can, for both
HTTP and WebSocket. Inline the single-use HTTP isHostAllowed wrapper.

* feat(plugins): derive Default for Rust host service structs

The ndpgen client.rs template now adds Default to the derive list of host
service structs, as the capability and shared types templates already do.
Plugin authors can now set only the fields they need, for example
HTTPRequest { method, url, ..Default::default() }. The webhook-rs and
discord-rich-presence-rs examples use this form now. The golden files and
the generated nd-pdk-host crate are updated to match.

* feat(plugins): deprecate pdk.NewHTTPRequest in the Go PDK

Navidrome no longer enables extism's http_request host function, so a
request built with pdk.NewHTTPRequest always fails. ndpgen now reads a small
deprecation table and writes a Deprecated: paragraph for the listed extism
functions, in both the WASM wrapper and the native stub. Linters and IDEs
now point plugin authors to host.HTTPSend. The PDK example tests used to
teach NewHTTPRequest. They now use host.HTTPSend and host.HTTPMock.

* docs(plugins): correct requiredHosts rules for websocket and private addresses

Two statements in the plugin docs did not match the code.

The WebSocket section claimed requiredHosts behaves like HTTP. It does not:
host_httpclient.go only consults the allowlist when the list is non-empty and
otherwise falls back to allowing public addresses, while host_websocket.go
always calls isHostInAllowlist, so an absent list blocks every connection.

The HTTP section claimed a named host can never reach a private address.
checkPrivateDial scans the whole requiredHosts list, so a named host does
reach a private address when the same list also holds a covering IP or CIDR.

Reworded both, plus the matching requiredHosts descriptions in
manifest-schema.json, and regenerated manifest_gen.go.
2026-09-12 13:59:46 -04:00

116 lines
3.5 KiB
Python

# 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
import base64
import extism
import json
@extism.import_fn("extism:host/user", "http_send")
def _http_send(offset: int) -> int: ...
def http_get(url):
"""GET url via Navidrome's HTTP host service. Returns (status_code, body_bytes)."""
request = json.dumps({"request": {"method": "GET", "url": url}}).encode("utf-8")
response_offset = _http_send(extism.memory.alloc(request).offset)
resp = json.loads(extism.memory.string(extism.memory.find(response_offset)))
if resp.get("error"):
raise Exception(f"HTTP request failed: {resp['error']}")
result = resp["result"]
return result["statusCode"], base64.b64decode(result.get("body", ""))
@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}"
status, body = http_get(url)
if status != 200:
raise Exception(f"not found: CAA returned status {status}")
try:
data = json.loads(body)
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