# 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 # # 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. # They were copied from plugins/host/python due to extism-py limitations. 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_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 # No output - lifecycle callbacks don't return responses @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("scheduleId", "") # Only handle our schedule if schedule_id != SCHEDULE_ID: 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})" ) # No output - scheduler callbacks don't return responses except Exception as e: extism.log(extism.LogLevel.Error, f"Failed to get now playing: {e}") # Errors are logged but scheduler callbacks don't return responses