diff --git a/api/__init__.py b/api/__init__.py index ece4b75f..35faf2be 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -42,6 +42,7 @@ def create_api_blueprint(): from .retag import register_routes as reg_retag from .listenbrainz import register_routes as reg_listenbrainz from .cache import register_routes as reg_cache + from .request import register_routes as reg_request # ---- rate-limit only /api/v1 routes (not the whole app) ---- limiter.limit("60 per minute")(bp) @@ -59,6 +60,7 @@ def create_api_blueprint(): reg_retag(bp) reg_listenbrainz(bp) reg_cache(bp) + reg_request(bp) # ---- error handlers (scoped to this Blueprint) ---- @bp.errorhandler(400) diff --git a/api/request.py b/api/request.py new file mode 100644 index 00000000..b782cf98 --- /dev/null +++ b/api/request.py @@ -0,0 +1,176 @@ +""" +Inbound music request endpoint — accept a search query from external sources +(Discord bots, curl, etc.) and trigger the search-match-download pipeline. +""" + +import threading +import uuid +from datetime import datetime, timedelta + +import requests as http_requests +from flask import request, current_app + +from utils.logging_config import get_logger +from .auth import require_api_key +from .helpers import api_success, api_error + +logger = get_logger("api_request") + +# In-memory request tracking (ephemeral — survives until restart) +_pending_requests = {} +_requests_lock = threading.Lock() + +# Max age before auto-cleanup +_MAX_REQUEST_AGE = timedelta(hours=1) + + +def _cleanup_old_requests(): + """Remove requests older than 1 hour to prevent unbounded growth.""" + cutoff = datetime.now() - _MAX_REQUEST_AGE + with _requests_lock: + expired = [rid for rid, r in _pending_requests.items() + if r.get('created_at', datetime.now()) < cutoff] + for rid in expired: + del _pending_requests[rid] + + +def _run_search_and_download(request_id, query, notify_url): + """Background worker: search, download, update status, notify.""" + try: + from utils.async_helpers import run_async + + with _requests_lock: + if request_id in _pending_requests: + _pending_requests[request_id]['status'] = 'searching' + + soulseek = current_app._get_current_object().soulsync.get('soulseek_client') + if not soulseek: + with _requests_lock: + if request_id in _pending_requests: + _pending_requests[request_id]['status'] = 'failed' + _pending_requests[request_id]['error'] = 'Download source not configured' + return + + result = run_async(soulseek.search_and_download_best(query)) + + with _requests_lock: + if request_id in _pending_requests: + if result: + _pending_requests[request_id]['status'] = 'downloading' + _pending_requests[request_id]['download_id'] = result + else: + _pending_requests[request_id]['status'] = 'not_found' + _pending_requests[request_id]['error'] = 'No match found' + _pending_requests[request_id]['completed_at'] = datetime.now().isoformat() + + # Send notification to callback URL if provided + if notify_url: + try: + with _requests_lock: + payload = dict(_pending_requests.get(request_id, {})) + # Remove non-serializable datetime + payload.pop('created_at', None) + http_requests.post(notify_url, json=payload, timeout=10) + except Exception as e: + logger.warning(f"Failed to POST to notify_url {notify_url}: {e}") + + except Exception as e: + logger.error(f"Request {request_id} failed: {e}") + with _requests_lock: + if request_id in _pending_requests: + _pending_requests[request_id]['status'] = 'failed' + _pending_requests[request_id]['error'] = str(e) + _pending_requests[request_id]['completed_at'] = datetime.now().isoformat() + + +def register_routes(bp): + + @bp.route("/request", methods=["POST"]) + @require_api_key + def create_request(): + """Accept a music search query and trigger the download pipeline. + + Body: + query (str, required): Search query, e.g. "Artist - Track Name" + notify_url (str, optional): URL to POST results to on completion + metadata (dict, optional): Passthrough data included in automation events + + Returns 202 with request_id for async status polling. + """ + body = request.get_json(silent=True) or {} + query = (body.get("query") or "").strip() + + if not query: + return api_error("BAD_REQUEST", "Missing 'query' in request body.", 400) + + # Cleanup old requests on each new request + _cleanup_old_requests() + + request_id = str(uuid.uuid4()) + notify_url = (body.get("notify_url") or "").strip() or None + metadata = body.get("metadata") or {} + + with _requests_lock: + _pending_requests[request_id] = { + 'request_id': request_id, + 'query': query, + 'status': 'queued', + 'created_at': datetime.now(), + 'completed_at': None, + 'download_id': None, + 'error': None, + } + + # Emit webhook_received event so automation engine triggers fire + engine = current_app.soulsync.get('automation_engine') + if engine: + engine.emit('webhook_received', { + 'query': query, + 'request_id': request_id, + 'source': 'api', + 'metadata': metadata, + }) + + # Start background search-download (Feature A: works without automations) + app = current_app._get_current_object() + thread = threading.Thread( + target=lambda: _run_with_app_context(app, request_id, query, notify_url), + daemon=True + ) + thread.start() + + logger.info(f"Music request queued: '{query}' (id={request_id})") + + return api_success({ + "request_id": request_id, + "status": "queued", + "query": query, + }), 202 + + @bp.route("/request/", methods=["GET"]) + @require_api_key + def get_request_status(request_id): + """Check the status of a music request. + + Returns current status: queued → searching → downloading → completed/not_found/failed + """ + with _requests_lock: + req = _pending_requests.get(request_id) + + if not req: + return api_error("NOT_FOUND", "Request not found or expired.", 404) + + return api_success({ + "request_id": req['request_id'], + "query": req['query'], + "status": req['status'], + "download_id": req.get('download_id'), + "error": req.get('error'), + "completed_at": req.get('completed_at'), + }) + + +def _run_with_app_context(app, request_id, query, notify_url): + """Run the background worker within Flask app context.""" + with app.app_context(): + _run_search_and_download(request_id, query, notify_url) diff --git a/web_server.py b/web_server.py index 93ea2c0c..829ccc5c 100644 --- a/web_server.py +++ b/web_server.py @@ -1835,6 +1835,42 @@ def _register_automation_handlers(): automation_engine.register_action_handler('clean_search_history', _auto_clean_search_history) automation_engine.register_action_handler('clean_completed_downloads', _auto_clean_completed_downloads) + def _auto_search_and_download(config): + """Search for a track and download the best match.""" + automation_id = config.get('_automation_id') + query = config.get('query', '').strip() + # Event-triggered: pull query from event data (e.g. webhook_received) + if not query: + event_data = config.get('_event_data', {}) + query = (event_data.get('query', '') or '').strip() + if not query: + if automation_id: + _update_automation_progress(automation_id, + log_line='No search query provided', log_type='error') + return {'status': 'error', 'error': 'No search query provided'} + try: + if automation_id: + _update_automation_progress(automation_id, + phase='Searching', log_line=f'Searching: {query}', log_type='info') + result = run_async(soulseek_client.search_and_download_best(query)) + if result: + if automation_id: + _update_automation_progress(automation_id, + log_line=f'Download started for: {query}', log_type='success') + return {'status': 'completed', 'query': query, 'download_id': result} + else: + if automation_id: + _update_automation_progress(automation_id, + log_line=f'No match found for: {query}', log_type='warning') + return {'status': 'not_found', 'query': query, 'error': 'No match found'} + except Exception as e: + if automation_id: + _update_automation_progress(automation_id, + log_line=f'Error: {e}', log_type='error') + return {'status': 'error', 'query': query, 'error': str(e)} + + automation_engine.register_action_handler('search_and_download', _auto_search_and_download) + # Register progress tracking callbacks def _progress_init(aid, name, action_type): _init_automation_progress(aid, name, action_type) @@ -2108,6 +2144,7 @@ try: 'tidal_client': tidal_client, 'matching_engine': matching_engine, 'config_manager': config_manager, + 'automation_engine': automation_engine, 'hydrabase_client': None, # updated after Hydrabase init 'hydrabase_worker': None, # updated after Hydrabase init } @@ -6350,6 +6387,10 @@ def get_automation_blocks(): {"key": "signal_name", "type": "signal_input", "label": "Signal Name"} ], "variables": ["signal_name"]}, + # Webhook trigger + {"type": "webhook_received", "label": "Webhook Received", "icon": "globe", + "description": "When an external API request is received (POST /api/v1/request)", "available": True, + "variables": ["query", "request_id", "source"]}, ], "actions": [ {"type": "process_wishlist", "label": "Process Wishlist", "icon": "list", "description": "Retry failed downloads from wishlist", "available": True, @@ -6414,6 +6455,12 @@ def get_automation_blocks(): "description": "Full library comparison without losing enrichment data", "available": True}, {"type": "run_script", "label": "Run Script", "icon": "terminal", "description": "Execute a script from the scripts folder", "available": True}, + {"type": "search_and_download", "label": "Search & Download", "icon": "download", + "description": "Search for a track and download the best match", "available": True, + "config_fields": [ + {"key": "query", "type": "text", "label": "Search Query", + "placeholder": "Artist - Track (leave empty to use trigger's query)"} + ]}, ], "notifications": [ {"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True, diff --git a/webui/index.html b/webui/index.html index 2215599c..cfafb3fd 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5937,6 +5937,25 @@ + + +
+

Inbound Request Endpoint

+
+ Trigger music downloads from external tools (Discord bots, Home Assistant, curl, etc.) + using any API key above. +
+
+ curl -X POST http://<host>:8008/api/v1/request \ + -H "Authorization: Bearer <your-api-key>" \ + -H "Content-Type: application/json" \ + -d '{"query": "Artist - Track Name"}' +
+
+ Returns a request_id for status polling. Add "notify_url" to POST results to a webhook on completion. + Also triggers any Webhook Received automations in the Automation Hub. +
+
diff --git a/webui/static/helper.js b/webui/static/helper.js index e7f22af7..21fbd83d 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3608,6 +3608,7 @@ const WHATS_NEW = { { title: 'Fix Allow Duplicates Setting Not Saving', desc: 'The "Allow duplicate tracks across albums" toggle was never persisted — it silently reset to ON on every page reload. Now saves correctly' }, { title: 'Fix Wishlist Dropping Cross-Album Tracks', desc: 'Wishlist cleanup was removing same-titled tracks from different albums even when Allow Duplicates was enabled. Cleanup now respects the setting — same song from different albums can coexist in the wishlist' }, { title: 'Fix "Replace Lower Quality" Setting Not Persisting', desc: 'The import section appeared twice in the settings save payload — the second instance (with only staging_path) overwrote the first (with replace_lower_quality). Merged into a single block' }, + { title: 'Inbound Music Request API', desc: 'New POST /api/v1/request endpoint — trigger downloads from Discord bots, Home Assistant, curl, or any external tool. Async with status polling and optional notify_url callback. New "Webhook Received" automation trigger and "Search & Download" action in the Automation Hub' }, // --- April 14, 2026 --- { date: 'April 14, 2026' },