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:
parent
e1cda4eb59
commit
8866c4654b
5 changed files with 245 additions and 0 deletions
|
|
@ -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
176
api/request.py
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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://<host>:8008/api/v1/request \
|
||||
-H "Authorization: Bearer <your-api-key>" \
|
||||
-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 -->
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue