Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
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