diff --git a/plugins/examples/nowplaying-py/Makefile b/plugins/examples/nowplaying-py/Makefile index 2bf6ea971..1c56a2a60 100644 --- a/plugins/examples/nowplaying-py/Makefile +++ b/plugins/examples/nowplaying-py/Makefile @@ -5,8 +5,8 @@ WASM_FILE = nowplaying-py.wasm build: $(WASM_FILE) -$(WASM_FILE): plugin/__init__.py - extism-py plugin/__init__.py -o $(WASM_FILE) +$(WASM_FILE): plugin/__init__.py plugin/nd_host_scheduler.py plugin/nd_host_subsonicapi.py + PYTHONPATH=plugin 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 index 725258498..4db1b8f8b 100644 --- a/plugins/examples/nowplaying-py/README.md +++ b/plugins/examples/nowplaying-py/README.md @@ -4,10 +4,10 @@ A Python example plugin that demonstrates the **Scheduler** and **SubsonicAPI** ## Features -- Uses `scheduler_schedulerecurring` host function to set up a recurring task +- Uses `scheduler_schedule_recurring` 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` +- Uses generated Python host function wrappers from `plugins/host/python/` ## Prerequisites @@ -29,7 +29,7 @@ make nowplaying-py.wasm Or directly: ```bash -extism-py plugin/__init__.py -o nowplaying-py.wasm +PYTHONPATH=plugin extism-py plugin/__init__.py -o nowplaying-py.wasm ``` ## Installation @@ -86,31 +86,27 @@ Or when no one is playing: 2. **Callback (`nd_scheduler_callback`)**: When the scheduled task fires, calls the SubsonicAPI `getNowPlaying` endpoint and logs the results. -## Host Function Usage (Python) +## Using Generated Host Function Wrappers -This plugin demonstrates how to call Navidrome host functions from Python: +This plugin uses the generated Python host function wrappers from `plugins/host/python/`. These wrappers are generated by `hostgen` and provide type-safe, Pythonic interfaces to Navidrome host functions: ```python -import extism -import json +# Import generated host function wrappers +from nd_host_scheduler import scheduler_schedule_recurring, HostFunctionError +from nd_host_subsonicapi import subsonicapi_call -# Import the host function -@extism.import_fn("extism:host/user", "subsonicapi_call") -def _subsonicapi_call(offset: int) -> int: - """Raw host function - returns memory offset.""" - ... +# Use them directly - no manual JSON marshalling needed! +schedule_id = scheduler_schedule_recurring( + cron_expression="*/1 * * * *", + payload="check", + schedule_i_d="nowplaying-check" +) -# 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 +response_json = subsonicapi_call(uri="getNowPlaying") +``` + +The wrappers handle: +- JSON marshalling/unmarshalling +- Memory allocation and management +- Error handling (raises `HostFunctionError` on failure) +- Type hints for better IDE support \ No newline at end of file diff --git a/plugins/examples/nowplaying-py/plugin/__init__.py b/plugins/examples/nowplaying-py/plugin/__init__.py index e82ea309b..bfd20d964 100644 --- a/plugins/examples/nowplaying-py/plugin/__init__.py +++ b/plugins/examples/nowplaying-py/plugin/__init__.py @@ -17,85 +17,28 @@ import extism import json +# Import generated host function wrappers +from nd_host_scheduler import scheduler_schedule_recurring, HostFunctionError +from nd_host_subsonicapi import subsonicapi_call as _subsonicapi_call_raw + # 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. - + """Call a Subsonic API endpoint and parse the response. + + This is a convenience wrapper around the generated subsonicapi_call + that parses the JSON response string into a dict. + 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) + response_json = _subsonicapi_call_raw(uri) + return json.loads(response_json) if response_json else {} # ============================================================================= diff --git a/plugins/examples/nowplaying-py/plugin/nd_host_scheduler.py b/plugins/examples/nowplaying-py/plugin/nd_host_scheduler.py new file mode 100644 index 000000000..f7b8e320b --- /dev/null +++ b/plugins/examples/nowplaying-py/plugin/nd_host_scheduler.py @@ -0,0 +1,142 @@ +# Code generated by hostgen. DO NOT EDIT. +# +# This file contains client wrappers for the Scheduler host service. +# It is intended for use in Navidrome plugins built with extism-py. +# +# Usage: +# from nd_host_scheduler import scheduler_schedule_one_time, scheduler_schedule_recurring, scheduler_cancel_schedule + +from dataclasses import dataclass +from typing import Any + +import extism +import json + + +class HostFunctionError(Exception): + """Raised when a host function returns an error.""" + pass + + +@extism.import_fn("extism:host/user", "scheduler_scheduleonetime") +def _scheduler_scheduleonetime(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@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", "scheduler_cancelschedule") +def _scheduler_cancelschedule(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +def scheduler_schedule_one_time(delay_seconds: int, payload: str, schedule_i_d: str) -> str: + """ScheduleOneTime schedules a one-time event to be triggered after the specified delay. +Plugins that use this function must also implement the SchedulerCallback capability + +Parameters: + - delaySeconds: Number of seconds to wait before triggering the event + - payload: Data to be passed to the scheduled event handler + - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated + +Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. + + Args: + delay_seconds: int parameter. + payload: str parameter. + schedule_i_d: str parameter. + + Returns: + str: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "delaySeconds": delay_seconds, + "payload": payload, + "scheduleID": schedule_i_d, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _scheduler_scheduleonetime(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("newScheduleID", "") + + +def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_i_d: str) -> str: + """ScheduleRecurring schedules a recurring event using a cron expression. +Plugins that use this function must also implement the SchedulerCallback capability + +Parameters: + - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight) + - payload: Data to be passed to each scheduled event handler invocation + - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated + +Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. + + Args: + cron_expression: str parameter. + payload: str parameter. + schedule_i_d: str parameter. + + Returns: + str: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "cronExpression": cron_expression, + "payload": payload, + "scheduleID": schedule_i_d, + } + 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 HostFunctionError(response["error"]) + + return response.get("newScheduleID", "") + + +def scheduler_cancel_schedule(schedule_i_d: str) -> None: + """CancelSchedule cancels a scheduled job identified by its schedule ID. + +This works for both one-time and recurring schedules. Once cancelled, the job will not trigger +any future events. + +Returns an error if the schedule ID is not found or if cancellation fails. + + Args: + schedule_i_d: str parameter. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "scheduleID": schedule_i_d, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _scheduler_cancelschedule(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + diff --git a/plugins/examples/nowplaying-py/plugin/nd_host_subsonicapi.py b/plugins/examples/nowplaying-py/plugin/nd_host_subsonicapi.py new file mode 100644 index 000000000..baabad0ec --- /dev/null +++ b/plugins/examples/nowplaying-py/plugin/nd_host_subsonicapi.py @@ -0,0 +1,54 @@ +# Code generated by hostgen. DO NOT EDIT. +# +# This file contains client wrappers for the SubsonicAPI host service. +# It is intended for use in Navidrome plugins built with extism-py. +# +# Usage: +# from nd_host_subsonicapi import subsonicapi_call + +from dataclasses import dataclass +from typing import Any + +import extism +import json + + +class HostFunctionError(Exception): + """Raised when a host function returns an error.""" + pass + + +@extism.import_fn("extism:host/user", "subsonicapi_call") +def _subsonicapi_call(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +def subsonicapi_call(uri: str) -> str: + """Call executes a Subsonic API request and returns the JSON response. + +The uri parameter should be the Subsonic API path without the server prefix, +e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. + + Args: + uri: str parameter. + + Returns: + str: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + 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 HostFunctionError(response["error"]) + + return response.get("responseJSON", "")