Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,7 @@ def get_config_endpoint():
"path_distance_metric": config.PATH_DISTANCE_METRIC
,"alchemy_default_n_results": config.ALCHEMY_DEFAULT_N_RESULTS
,"alchemy_max_n_results": config.ALCHEMY_MAX_N_RESULTS
,"alchemy_temperature": config.ALCHEMY_TEMPERATURE
,"alchemy_subtract_distance_angular": config.ALCHEMY_SUBTRACT_DISTANCE_ANGULAR
,"alchemy_subtract_distance_euclid": config.ALCHEMY_SUBTRACT_DISTANCE_EUCLIDEAN
})
Expand Down
239 changes: 239 additions & 0 deletions app_alchemy.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from flask import Blueprint, jsonify, request, render_template
import logging
import math

from tasks.song_alchemy import song_alchemy
from app_helper import attach_song_features
Expand Down Expand Up @@ -308,6 +309,244 @@ def rename_anchor(anchor_id):
return jsonify({'anchor': {'id': anchor['id'], 'name': anchor['name']}})


def _parse_radio_settings(payload):
temperature = payload.get('temperature')
n_results = payload.get('n_results')
if temperature is None:
return None, None, 'Radio temperature is required'
if n_results is None:
return None, None, 'Radio number of results is required'
try:
temperature = float(temperature)
except (TypeError, ValueError):
return None, None, 'Radio temperature must be a number'
Comment thread
NeptuneHub marked this conversation as resolved.
if not math.isfinite(temperature):
return None, None, 'Radio temperature must be a finite number'
try:
n_results = int(n_results)
except (TypeError, ValueError):
return None, None, 'Radio number of results must be an integer'
if temperature < 0:
return None, None, 'Radio temperature must be 0 or greater'
if n_results < 1 or n_results > config.ALCHEMY_MAX_N_RESULTS:
return None, None, f'Radio number of results must be between 1 and {config.ALCHEMY_MAX_N_RESULTS}'
return temperature, n_results, None


@alchemy_bp.route('/api/radios', methods=['GET'])
def list_radios():
"""
List saved alchemy radios.
---
tags:
- Alchemy
summary: Return every saved radio (anchor + temperature + number of results) with its enabled state.
responses:
200:
description: Radio list.
content:
application/json:
schema:
type: object
properties:
radios:
type: array
items:
type: object
properties:
id:
type: integer
anchor_id:
type: integer
name:
type: string
description: Name of the underlying anchor (the radio shares it).
temperature:
type: number
format: float
n_results:
type: integer
enabled:
type: boolean
500:
description: Database error.
"""
from app_helper import get_alchemy_radios
try:
radios = get_alchemy_radios()
return jsonify({'radios': [{
'id': r['id'], 'anchor_id': r['anchor_id'], 'name': r['name'],
'temperature': r['temperature'], 'n_results': r['n_results'], 'enabled': bool(r['enabled'])
} for r in radios]})
except Exception:
logger.exception('Failed to list radios')
return jsonify({'radios': [], 'error': 'Unable to retrieve radios at this time.'}), 500


@alchemy_bp.route('/api/radios', methods=['POST'])
def create_radio():
"""
Save a new alchemy radio.
---
tags:
- Alchemy
summary: Persist a radio (anchor + temperature + number of results) for batch playlist generation.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [anchor_id, temperature, n_results]
properties:
anchor_id:
type: integer
description: Saved anchor the radio is built on (one radio per anchor).
temperature:
type: number
format: float
n_results:
type: integer
enabled:
type: boolean
default: true
responses:
200:
description: Radio saved.
400:
description: Missing or invalid anchor/temperature/number of results.
500:
description: Database failure.
"""
from app_helper import create_alchemy_radio
payload = request.get_json() or {}
anchor_id = payload.get('anchor_id')
try:
anchor_id = int(anchor_id)
except (TypeError, ValueError):
return jsonify({'error': 'Radio anchor is required'}), 400
temperature, n_results, error = _parse_radio_settings(payload)
if error:
return jsonify({'error': error}), 400
enabled = bool(payload.get('enabled', True))
radio = create_alchemy_radio(anchor_id, temperature, n_results, enabled)
if not radio:
return jsonify({'error': 'Failed to save radio. Check that the anchor exists and has no radio yet.'}), 400
return jsonify({'radio': radio})


@alchemy_bp.route('/api/radios/<int:radio_id>', methods=['PUT'])
def update_radio(radio_id):
"""
Update an alchemy radio.
---
tags:
- Alchemy
summary: Update temperature, number of results and enabled state of a saved radio.
parameters:
- name: radio_id
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [temperature, n_results, enabled]
properties:
temperature:
type: number
format: float
n_results:
type: integer
enabled:
type: boolean
responses:
200:
description: Radio updated.
400:
description: Invalid temperature/number of results.
404:
description: Radio not found.
"""
from app_helper import update_alchemy_radio
payload = request.get_json() or {}
temperature, n_results, error = _parse_radio_settings(payload)
if error:
return jsonify({'error': error}), 400
enabled = bool(payload.get('enabled', True))
radio = update_alchemy_radio(radio_id, temperature, n_results, enabled)
if not radio:
return jsonify({'error': 'Radio not found or update failed'}), 404
return jsonify({'radio': radio})


@alchemy_bp.route('/api/radios/<int:radio_id>', methods=['DELETE'])
def remove_radio(radio_id):
"""
Delete an alchemy radio.
---
tags:
- Alchemy
summary: Remove a saved radio by id (the underlying anchor is kept).
parameters:
- name: radio_id
in: path
required: true
schema: { type: integer }
responses:
200:
description: Radio deleted.
404:
description: Radio not found.
"""
from app_helper import delete_alchemy_radio
ok = delete_alchemy_radio(radio_id)
if not ok:
return jsonify({'error': 'Radio not found'}), 404
return jsonify({'deleted': True})


@alchemy_bp.route('/api/radios/run', methods=['POST'])
def run_radio_playlists_endpoint():
"""
Create playlists for all enabled radios.
---
tags:
- Alchemy
summary: Delete old '_radio' playlists, then create one playlist per enabled radio on the media server.
responses:
200:
description: Run summary.
content:
application/json:
schema:
type: object
properties:
message:
type: string
radios_enabled:
type: integer
playlists_created:
type: integer
failed:
type: array
items:
type: string
500:
description: Run failed.
"""
from tasks.radio_manager import run_radio_playlists
try:
summary = run_radio_playlists()
return jsonify(summary)
except Exception:
logger.exception('Radio playlist creation failed')
return jsonify({'error': 'Failed to create radio playlists. Check container logs.'}), 500


@alchemy_bp.route('/api/artist_projections', methods=['GET'])
def artist_projections_api():
"""
Expand Down
4 changes: 1 addition & 3 deletions app_clap_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,7 @@ def clap_search_api():
logger.warning(f"ValueError in DCLAP search API: {e}")
return jsonify({'error': 'Invalid or missing request parameter.'}), 400
except Exception as e:
logger.error(f"DCLAP search API error: {e}")
import traceback
traceback.print_exc()
logger.exception(f"DCLAP search API error: {e}")
return jsonify({'error': 'An internal server error occurred during DCLAP search.'}), 500


Expand Down
11 changes: 9 additions & 2 deletions app_cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def get_cron_entries():
type: string
task_type:
type: string
enum: [analysis, clustering, sonic_fingerprint]
enum: [analysis, clustering, sonic_fingerprint, alchemy_radio]
cron_expr:
type: string
description: 5-field cron expression "min hour day month dow".
Expand Down Expand Up @@ -109,7 +109,7 @@ def save_cron_entry():
type: string
task_type:
type: string
enum: [analysis, clustering, sonic_fingerprint]
enum: [analysis, clustering, sonic_fingerprint, alchemy_radio]
cron_expr:
type: string
description: 5-field cron expression "min hour day month dow".
Expand Down Expand Up @@ -304,6 +304,13 @@ def run_due_cron_jobs():
logger.info(f"Cron: ran sonic fingerprint synchronously (job_id={job_id})")
except Exception as e:
logger.error(f"Cron: error running sonic fingerprint: {e}")
elif task_type == 'alchemy_radio':
from tasks.radio_manager import run_radio_playlists
try:
summary = run_radio_playlists()
Comment thread
NeptuneHub marked this conversation as resolved.
logger.info(f"Cron: ran radio playlists synchronously (job_id={job_id}, summary={summary})")
except Exception:
logger.exception("Cron: error running radio playlists")
# update last_run
cur2 = db.cursor()
cur2.execute("UPDATE cron SET last_run=%s WHERE id=%s", (now_ts, r['id']))
Expand Down
74 changes: 74 additions & 0 deletions app_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ def init_db():
)
# Create 'alchemy_anchors' table to persist named user anchors for reuse
cur.execute("CREATE TABLE IF NOT EXISTS alchemy_anchors (id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, centroid JSONB NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
cur.execute("CREATE TABLE IF NOT EXISTS alchemy_radios (id SERIAL PRIMARY KEY, anchor_id INTEGER UNIQUE NOT NULL REFERENCES alchemy_anchors(id) ON DELETE CASCADE, temperature DOUBLE PRECISION NOT NULL, n_results INTEGER NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
# Provider migration tool: wizard session state (one row per migration attempt)
cur.execute("""
CREATE TABLE IF NOT EXISTS migration_session (
Expand Down Expand Up @@ -1235,6 +1236,79 @@ def update_alchemy_anchor_name(anchor_id, name):
cur.close()


def get_alchemy_radios():
conn = get_db()
cur = conn.cursor(cursor_factory=DictCursor)
try:
cur.execute(
"SELECT r.id, r.anchor_id, a.name, r.temperature, r.n_results, r.enabled "
"FROM alchemy_radios r JOIN alchemy_anchors a ON a.id = r.anchor_id "
"ORDER BY a.name"
)
rows = cur.fetchall()
return [dict(row) for row in rows]
except Exception:
logger.exception("Failed to load alchemy radios")
return []
finally:
cur.close()


def create_alchemy_radio(anchor_id, temperature, n_results, enabled=True):
conn = get_db()
cur = conn.cursor(cursor_factory=DictCursor)
try:
cur.execute(
"INSERT INTO alchemy_radios (anchor_id, temperature, n_results, enabled) "
"VALUES (%s, %s, %s, %s) RETURNING id, anchor_id, temperature, n_results, enabled",
(anchor_id, temperature, n_results, bool(enabled))
)
row = cur.fetchone()
conn.commit()
return dict(row) if row else None
except Exception:
conn.rollback()
logger.exception(f"Failed to create alchemy radio for anchor_id={anchor_id}")
return None
finally:
cur.close()


def update_alchemy_radio(radio_id, temperature, n_results, enabled):
conn = get_db()
cur = conn.cursor(cursor_factory=DictCursor)
try:
cur.execute(
"UPDATE alchemy_radios SET temperature = %s, n_results = %s, enabled = %s "
"WHERE id = %s RETURNING id, anchor_id, temperature, n_results, enabled",
(temperature, n_results, bool(enabled), radio_id)
)
row = cur.fetchone()
conn.commit()
return dict(row) if row else None
except Exception:
conn.rollback()
logger.exception(f"Failed to update alchemy radio id={radio_id}")
return None
finally:
cur.close()


def delete_alchemy_radio(radio_id):
conn = get_db()
cur = conn.cursor()
try:
cur.execute("DELETE FROM alchemy_radios WHERE id = %s", (radio_id,))
conn.commit()
return cur.rowcount > 0
except Exception:
conn.rollback()
logger.exception(f"Failed to delete alchemy radio id={radio_id}")
return False
finally:
cur.close()


def save_map_projection(index_name, id_map, projection_array):
"""
Save a precomputed 2D projection into the map_projection_data table.
Expand Down
Loading
Loading