Add inbound music request API and webhook automation trigger

New POST /api/v1/request endpoint accepts a search query from external
sources (Discord bots, Home Assistant, curl) and triggers the
search-match-download pipeline asynchronously. Returns a request_id
for status polling via GET /api/v1/request/<id>. Optional notify_url
for callback on completion.

Also adds webhook_received trigger type and search_and_download action
type to the automation engine, so users can build custom flows like
"when webhook received → search & download → notify Discord".

Includes info panel in Settings showing endpoint URL and curl example.
This commit is contained in:
Broque Thomas 2026-04-15 20:35:39 -07:00
parent e1cda4eb59
commit 8866c4654b
5 changed files with 245 additions and 0 deletions

View file

@ -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)

176
api/request.py Normal file
View file

@ -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/<request_id>", 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)

View file

@ -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,

View file

@ -5937,6 +5937,25 @@
</div>
</div>
</div>
<!-- Inbound Webhook Info -->
<div class="api-service-frame" style="margin-top: 16px;">
<h4 class="service-title" style="color: #64ffda;">Inbound Request Endpoint</h4>
<div class="setting-help-text" style="margin-bottom: 10px; color: #888; font-size: 12px;">
Trigger music downloads from external tools (Discord bots, Home Assistant, curl, etc.)
using any API key above.
</div>
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 12px; color: #ccc; overflow-x: auto;">
<code style="white-space: pre; display: block;">curl -X POST http://&lt;host&gt;:8008/api/v1/request \
-H "Authorization: Bearer &lt;your-api-key&gt;" \
-H "Content-Type: application/json" \
-d '{"query": "Artist - Track Name"}'</code>
</div>
<div class="setting-help-text" style="margin-top: 8px; color: #666; font-size: 11px;">
Returns a <code>request_id</code> for status polling. Add <code>"notify_url"</code> to POST results to a webhook on completion.
Also triggers any <strong>Webhook Received</strong> automations in the Automation Hub.
</div>
</div>
</div>
<!-- Developer Mode -->

View file

@ -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' },