diff --git a/app.py b/app.py index 98606f0e..f1722c98 100644 --- a/app.py +++ b/app.py @@ -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 }) diff --git a/app_alchemy.py b/app_alchemy.py index dd36e0ac..d38cebc1 100644 --- a/app_alchemy.py +++ b/app_alchemy.py @@ -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 @@ -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' + 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/', 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/', 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(): """ diff --git a/app_clap_search.py b/app_clap_search.py index 1c35fe14..42b8413f 100644 --- a/app_clap_search.py +++ b/app_clap_search.py @@ -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 diff --git a/app_cron.py b/app_cron.py index a9a8cda8..c68f8a90 100644 --- a/app_cron.py +++ b/app_cron.py @@ -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". @@ -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". @@ -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() + 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'])) diff --git a/app_helper.py b/app_helper.py index 40df4d6e..a054df36 100644 --- a/app_helper.py +++ b/app_helper.py @@ -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 ( @@ -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. diff --git a/app_logging.py b/app_logging.py index 03e0b8c2..314731cd 100644 --- a/app_logging.py +++ b/app_logging.py @@ -10,14 +10,83 @@ ``app.py``. When one of them forgot to call ``basicConfig`` at all, every ``logger.info(...)`` from task modules fell through to Python's ``lastResort`` handler — silently dropping INFO-level output during long-running jobs. + +Emoji safety: the ``EmojiStrippingFilter`` removes emoji and other non-Latin-1 +symbols from every log record before it reaches a handler. This prevents +``UnicodeEncodeError`` / ``UnicodeDecodeError`` crashes on Windows when stdout +is a pipe (PyInstaller native build) or when the console code-page cannot +represent the character. HTML templates and web-UI progress messages are +unaffected — only the Python ``logging`` pipeline is sanitised. """ import logging +import re LOG_FORMAT = "[%(levelname)s]-[%(asctime)s]-%(message)s" LOG_DATEFMT = "%d-%m-%Y %H-%M-%S" +# --------------------------------------------------------------------------- +# Emoji / symbol stripping for console-safe logging +# --------------------------------------------------------------------------- +# Ranges cover all common emoji blocks plus Dingbats, Misc Symbols, +# Geometric Shapes, Supplemental Symbols, and variation selectors. +# Characters within Latin-1 (U+0000–U+00FF) are *not* stripped, so +# European accented letters (e.g. é, ñ, ü) pass through unchanged. +_EMOJI_RE = re.compile( + "[\U0001F300-\U0001F9FF" # Misc Symbols, Emoticons, Transport, Supplemental + "\U0001FA00-\U0001FAFF" # Chess Symbols, Symbols Extended-A + "\U00002190-\U000027BF" # Arrows (→ ← ↑ ↓ ↔), Misc Technical, Dingbats (✓ ✗ ✕ ★ ☆ ♯ ♭ etc.) + "\U000025A0-\U000025FF" # Geometric Shapes (● ○ ■ □ ◆ ◇ ▲ ▼ etc.) + "\U00002B00-\U00002BFF" # Misc Symbols & Arrows + "\U0001F000-\U0001F02F" # Mahjong Tiles + "\U0001F0A0-\U0001F0FF" # Playing Cards + "\uFE0F\u200D" # Variation Selector-16, Zero-Width Joiner + "]+" +) + + +def _strip_emoji(text: str) -> str: + """Remove emoji and symbol characters from *text*, returning a plain string.""" + if not isinstance(text, str): + return text + cleaned = _EMOJI_RE.sub("", text) + # Collapse multiple spaces that may result from removing a symbol + return re.sub(r" {2,}", " ", cleaned).strip() + + +class EmojiStrippingFilter(logging.Filter): + """Logging filter that strips emoji/symbols from ``record.msg`` and ``record.args``. + + Attach this to the root logger's handlers so every log record is sanitised + before it reaches a ``StreamHandler`` (console / pipe). File-based handlers + with ``propagate=False`` (e.g. the Windows supervisor's own log) are not + affected. + """ + + def filter(self, record): + if isinstance(record.msg, str): + record.msg = _strip_emoji(record.msg) + if record.args: + if isinstance(record.args, dict): + record.args = { + k: _strip_emoji(v) if isinstance(v, str) else v + for k, v in record.args.items() + } + elif isinstance(record.args, (list, tuple)): + record.args = tuple( + _strip_emoji(a) if isinstance(a, str) else a + for a in record.args + ) + return True + def configure_logging(level: int = logging.INFO) -> None: - """Install the project-wide root logger format. Idempotent.""" + """Install the project-wide root logger format. Idempotent. + + An ``EmojiStrippingFilter`` is attached to every handler on the root logger, + making all console / pipe output safe on Windows regardless of code-page. + """ logging.basicConfig(level=level, format=LOG_FORMAT, datefmt=LOG_DATEFMT) + for handler in logging.root.handlers: + if not any(isinstance(f, EmojiStrippingFilter) for f in handler.filters): + handler.addFilter(EmojiStrippingFilter()) diff --git a/config.py b/config.py index 8f4ff9a6..3cbaedb8 100644 --- a/config.py +++ b/config.py @@ -107,7 +107,7 @@ def _compute_headers(): # --- General Constants (Read from Environment Variables where applicable) --- -APP_VERSION = "v2.1.4" +APP_VERSION = "v2.1.5" MAX_DISTANCE = float(os.environ.get("MAX_DISTANCE", "0.5")) MAX_SONGS_PER_CLUSTER = int(os.environ.get("MAX_SONGS_PER_CLUSTER", "0")) MAX_SONGS_PER_ARTIST = int(os.getenv("MAX_SONGS_PER_ARTIST", "3")) # Max songs per artist in similarity results and clustering diff --git a/restart_listener.py b/restart_listener.py index abf6a37a..1a15e310 100644 --- a/restart_listener.py +++ b/restart_listener.py @@ -1,14 +1,14 @@ import logging import os import time -import traceback from redis import Redis import config +from app_logging import configure_logging from restart_manager import RESTART_CHANNEL, restart_supervisor_workers, stop_supervisor_workers, start_supervisor_workers logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(asctime)s %(message)s') +configure_logging() def main(): @@ -60,8 +60,7 @@ def main(): else: logger.warning('Worker start failed; will continue listening') except Exception: - logger.error('Restart listener connection error, retrying in 5 seconds') - traceback.print_exc() + logger.exception('Restart listener connection error, retrying in 5 seconds') time.sleep(5) diff --git a/rq_janitor.py b/rq_janitor.py index 8941da25..299212f7 100644 --- a/rq_janitor.py +++ b/rq_janitor.py @@ -15,9 +15,10 @@ sys.exit(1) configure_logging() +logger = logging.getLogger(__name__) if __name__ == '__main__': - logging.info("🧹 RQ Janitor process starting. Cleaning registries every 10 seconds.") + logger.info("RQ Janitor process starting. Cleaning registries every 10 seconds.") queues_to_clean = [rq_queue_high, rq_queue_default] while True: try: @@ -29,7 +30,7 @@ started_after = started_registry.count started_cleaned = started_before - started_after if started_cleaned > 0: - logging.info("Janitor cleaned %d orphaned jobs from '%s' started_job_registry.", started_cleaned, queue.name) + logger.info("Janitor cleaned %d orphaned jobs from '%s' started_job_registry.", started_cleaned, queue.name) # 2. Clean FinishedJobRegistry - completed jobs older than TTL (default 500s) # CRITICAL: This prevents memory/thread leaks from accumulated finished jobs @@ -39,7 +40,7 @@ finished_after = finished_registry.count finished_cleaned = finished_before - finished_after if finished_cleaned > 0: - logging.info("Janitor cleaned %d expired finished jobs from '%s' finished_job_registry.", finished_cleaned, queue.name) + logger.info("Janitor cleaned %d expired finished jobs from '%s' finished_job_registry.", finished_cleaned, queue.name) # 3. Clean FailedJobRegistry - failed jobs older than TTL failed_registry = queue.failed_job_registry @@ -48,9 +49,9 @@ failed_after = failed_registry.count failed_cleaned = failed_before - failed_after if failed_cleaned > 0: - logging.info("Janitor cleaned %d expired failed jobs from '%s' failed_job_registry.", failed_cleaned, queue.name) + logger.info("Janitor cleaned %d expired failed jobs from '%s' failed_job_registry.", failed_cleaned, queue.name) except Exception as e: - logging.error("Error in RQ Janitor loop: %s", e, exc_info=True) + logger.exception("Error in RQ Janitor loop: %s", e) # Sleep for the desired monitoring interval. time.sleep(10) \ No newline at end of file diff --git a/rq_worker.py b/rq_worker.py index 3b5ac43f..7701cbe6 100644 --- a/rq_worker.py +++ b/rq_worker.py @@ -1,5 +1,6 @@ import os import sys +import logging # Ensure the /app directory (where app.py and tasks.py are) is in the Python path # This is important if rq_worker.py is in the root and app.py/tasks.py are in /app @@ -42,6 +43,7 @@ print("Note: This may be expected in some test/CI environments, but could lead to task failures in production.") configure_logging() +logger = logging.getLogger(__name__) # The queues the worker will listen on. # The order is important! Workers will always check 'high' before 'default'. @@ -61,8 +63,8 @@ # The queues_to_listen are already configured with this connection. # Use the list of names directly for the log message - print(f"DEFAULT RQ Worker starting. Version: {APP_VERSION}. Listening on queues: {queues_to_listen}") - print(f"Using Redis connection: {redis_conn.connection_pool.connection_kwargs}") + logger.info(f"DEFAULT RQ Worker starting. Version: {APP_VERSION}. Listening on queues: {queues_to_listen}") + logger.info(f"Using Redis connection: {redis_conn.connection_pool.connection_kwargs}") # Create a worker instance, explicitly passing the connection. # The 'app' object is passed to `with app.app_context():` within the tasks themselves @@ -86,8 +88,8 @@ # You can set logging_level for more verbose output. # Common levels: DEBUG, INFO, WARNING, ERROR, CRITICAL logging_level = os.getenv("RQ_LOGGING_LEVEL", "INFO").upper() - print(f"RQ Worker logging level set to: {logging_level}") - print(f"Worker will restart after {max_jobs_before_restart} jobs to prevent memory leaks") + logger.info(f"RQ Worker logging level set to: {logging_level}") + logger.info(f"Worker will restart after {max_jobs_before_restart} jobs to prevent memory leaks") try: # The `with app.app_context():` here is generally NOT how RQ workers are run. @@ -106,5 +108,5 @@ worker.work(logging_level=logging_level, max_jobs=max_jobs_before_restart) except Exception as e: - print(f"RQ Worker failed to start or encountered an error: {e}") + logger.exception(f"RQ Worker failed to start or encountered an error: {e}") sys.exit(1) diff --git a/rq_worker_high_priority.py b/rq_worker_high_priority.py index 034ac0fc..b81f6297 100644 --- a/rq_worker_high_priority.py +++ b/rq_worker_high_priority.py @@ -1,5 +1,6 @@ import os import sys +import logging sys.path.append(os.path.dirname(os.path.abspath(__file__))) @@ -38,13 +39,14 @@ # logger.info(...) from task modules falls through to Python's lastResort handler # and gets silently dropped during long-running jobs. configure_logging() +logger = logging.getLogger(__name__) # This worker ONLY listens to the 'high' queue. queues_to_listen = ['high'] if __name__ == '__main__': - print(f"HIGH PRIORITY RQ Worker starting. Version: {APP_VERSION}. Listening ONLY on queues: {queues_to_listen}") - print(f"Using Redis connection: {redis_conn.connection_pool.connection_kwargs}") + logger.info(f"HIGH PRIORITY RQ Worker starting. Version: {APP_VERSION}. Listening ONLY on queues: {queues_to_listen}") + logger.info(f"Using Redis connection: {redis_conn.connection_pool.connection_kwargs}") # High priority worker doesn't analyze songs, so no CLAP preload needed # Only rq_worker.py (default queue) handles song analysis tasks @@ -62,12 +64,12 @@ max_jobs_before_restart = int(os.getenv('RQ_MAX_JOBS_HIGH', '100')) logging_level = os.getenv("RQ_LOGGING_LEVEL", "INFO").upper() - print(f"RQ Worker logging level set to: {logging_level}") - print(f"Worker will restart after {max_jobs_before_restart} jobs to prevent memory leaks") + logger.info(f"RQ Worker logging level set to: {logging_level}") + logger.info(f"Worker will restart after {max_jobs_before_restart} jobs to prevent memory leaks") try: # The job function itself is responsible for creating an app context if needed. worker.work(logging_level=logging_level, max_jobs=max_jobs_before_restart) except Exception as e: - print(f"High Priority RQ Worker failed to start or encountered an error: {e}") + logger.exception(f"High Priority RQ Worker failed to start or encountered an error: {e}") sys.exit(1) \ No newline at end of file diff --git a/tasks/clap_analyzer.py b/tasks/clap_analyzer.py index fd1b19fe..fb5dbb18 100644 --- a/tasks/clap_analyzer.py +++ b/tasks/clap_analyzer.py @@ -323,9 +323,7 @@ def initialize_clap_audio_model(): logger.info("✓ CLAP audio model initialized successfully (for music analysis)") return True except Exception as e: - logger.error(f"Failed to initialize CLAP audio model: {e}") - import traceback - traceback.print_exc() + logger.exception(f"Failed to initialize CLAP audio model: {e}") return False @@ -351,9 +349,7 @@ def initialize_clap_text_model(): logger.info("✓ CLAP text model initialized successfully (for text search)") return True except Exception as e: - logger.error(f"Failed to initialize CLAP text model: {e}") - import traceback - traceback.print_exc() + logger.exception(f"Failed to initialize CLAP text model: {e}") return False @@ -607,9 +603,7 @@ def retry_fn(): return audio_embedding, duration_sec, num_segments except Exception as e: - logger.error(f"CLAP analysis failed for {audio_path}: {e}") - import traceback - traceback.print_exc() + logger.exception(f"CLAP analysis failed for {audio_path}: {e}") comprehensive_memory_cleanup(force_cuda=True, reset_onnx_pool=True) return None, 0, 0 finally: @@ -665,9 +659,7 @@ def get_text_embedding(query_text: str) -> Optional[np.ndarray]: return text_embedding except Exception as e: - logger.error(f"Failed to get text embedding for '{query_text}': {e}") - import traceback - traceback.print_exc() + logger.exception(f"Failed to get text embedding for '{query_text}': {e}") return None @@ -722,9 +714,7 @@ def get_text_embeddings_batch(query_texts: list) -> Optional[np.ndarray]: return text_embeddings except Exception as e: - logger.error(f"Failed to get batch text embeddings: {e}") - import traceback - traceback.print_exc() + logger.exception(f"Failed to get batch text embeddings: {e}") return None @@ -811,9 +801,7 @@ def get_or_cache_other_feature_text_embeddings(redis_conn) -> Optional[dict]: logger.warning(f"Failed to write text embeddings to Redis: {e}") return result except Exception as e: - logger.error(f"Failed to compute CLAP text embeddings for other features: {e}") - import traceback - traceback.print_exc() + logger.exception(f"Failed to compute CLAP text embeddings for other features: {e}") return None finally: # Unload text model after computing (worker only needs audio model) diff --git a/tasks/clap_text_search.py b/tasks/clap_text_search.py index 2e0489af..34e7010d 100644 --- a/tasks/clap_text_search.py +++ b/tasks/clap_text_search.py @@ -491,9 +491,7 @@ def search_by_text(query_text: str, limit: int = 100) -> List[Dict]: return results except Exception as e: - logger.error(f"Text search failed for '{query_text}': {e}") - import traceback - traceback.print_exc() + logger.exception(f"Text search failed for '{query_text}': {e}") return [] diff --git a/tasks/mediaserver.py b/tasks/mediaserver.py index 4666ca2e..1d3ee9e1 100644 --- a/tasks/mediaserver.py +++ b/tasks/mediaserver.py @@ -107,11 +107,24 @@ def resolve_emby_jellyfin_user(identifier, token): if config.MEDIASERVER_TYPE == 'emby': return emby_resolve_user(identifier, token) return [] -def delete_automatic_playlists(): - """Deletes all playlists ending with '_automatic' using admin credentials.""" - logger.info("Starting deletion of all '_automatic' playlists.") +def _delete_matching_playlists(playlists_to_check, delete_function, suffix): + """Deletes every playlist whose name ends with the suffix; keeps going if one deletion fails.""" deleted_count = 0 - + for p in playlists_to_check: + # Navidrome uses 'id', others use 'Id'. Check for both. + playlist_id = p.get('Id') or p.get('id') + try: + if p.get('Name', '').endswith(suffix) and delete_function(playlist_id): + deleted_count += 1 + except Exception: + logger.exception(f"Failed to delete playlist {playlist_id}; continuing with the remaining playlists.") + return deleted_count + +def delete_playlists_by_suffix(suffix): + """Deletes all playlists whose name ends with the given suffix using admin credentials.""" + logger.info(f"Starting deletion of all '{suffix}' playlists.") + deleted_count = 0 + playlists_to_check = [] delete_function = None @@ -132,14 +145,14 @@ def delete_automatic_playlists(): delete_function = emby_delete_playlist if delete_function: - for p in playlists_to_check: - # Navidrome uses 'id', others use 'Id'. Check for both. - playlist_id = p.get('Id') or p.get('id') - if p.get('Name', '').endswith('_automatic') and delete_function(playlist_id): - deleted_count += 1 - + deleted_count = _delete_matching_playlists(playlists_to_check, delete_function, suffix) + logger.info(f"Finished deletion. Deleted {deleted_count} playlists.") +def delete_automatic_playlists(): + """Deletes all playlists ending with '_automatic' using admin credentials.""" + delete_playlists_by_suffix('_automatic') + def get_recent_albums(limit): """Fetches recently added albums using admin credentials.""" if config.MEDIASERVER_TYPE == 'jellyfin': return jellyfin_get_recent_albums(limit) diff --git a/tasks/radio_manager.py b/tasks/radio_manager.py new file mode 100644 index 00000000..e6db1428 --- /dev/null +++ b/tasks/radio_manager.py @@ -0,0 +1,64 @@ +import logging + +from .song_alchemy import song_alchemy +from .mediaserver import create_playlist, delete_playlists_by_suffix + +logger = logging.getLogger(__name__) + +RADIO_PLAYLIST_SUFFIX = '_radio' + + +def run_radio_playlists(): + """Generate one playlist per enabled radio (anchor + temperature + number of results). + + Runs synchronously, like the sonic fingerprint cron flow: compute all + playlists first, then delete every existing playlist ending with '_radio', + then create the new ones on the media server. + """ + from app_helper import get_alchemy_radios + + radios = [r for r in get_alchemy_radios() if r.get('enabled')] + logger.info(f"Radio playlist run started for {len(radios)} enabled radios.") + + generated = [] + failed = [] + for radio in radios: + playlist_name = f"{radio['name']}{RADIO_PLAYLIST_SUFFIX}" + try: + outcome = song_alchemy( + add_items=[{'type': 'anchor', 'id': radio['anchor_id']}], + n_results=int(radio['n_results']), + temperature=float(radio['temperature']) + ) + item_ids = [r['item_id'] for r in (outcome.get('results') or []) if r.get('item_id')] + if item_ids: + generated.append((playlist_name, item_ids)) + else: + failed.append(playlist_name) + logger.warning(f"Radio '{radio['name']}' produced no results; skipping playlist creation.") + except Exception: + failed.append(playlist_name) + logger.exception(f"Radio '{radio['name']}' failed; skipping playlist creation.") + + try: + delete_playlists_by_suffix(RADIO_PLAYLIST_SUFFIX) + except Exception: + logger.exception(f"Failed to delete old '{RADIO_PLAYLIST_SUFFIX}' playlists; continuing with playlist creation.") + + created = 0 + for playlist_name, item_ids in generated: + try: + create_playlist(playlist_name, item_ids) + created += 1 + except Exception: + failed.append(playlist_name) + logger.exception(f"Failed to create playlist '{playlist_name}' on the media server.") + + summary = { + "message": f"Created {created} radio playlist(s) from {len(radios)} enabled radio(s).", + "radios_enabled": len(radios), + "playlists_created": created, + "failed": failed, + } + logger.info(f"Radio playlist run finished: {summary}") + return summary diff --git a/templates/alchemy.html b/templates/alchemy.html index c64ba49d..f838a43d 100644 --- a/templates/alchemy.html +++ b/templates/alchemy.html @@ -60,38 +60,32 @@ /* Mobile-friendly anchor manager actions */ @media (max-width: 768px) { - #anchor-manager table, - #anchor-manager table tbody, - #anchor-manager table tr, - #anchor-manager table td { - display: block; - width: 100%; - } - #anchor-manager table tr { - margin-bottom: 0.75rem; - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.75rem; - } - #anchor-manager table td { - text-align: left; - padding: 0.35rem 0; - } - #anchor-manager table td:last-child button { - display: block; - width: 100%; - margin: 0.25rem 0; - } - #anchor-manager > div { flex-wrap: wrap; gap: 0.5rem; } - #back-to-alchemy-btn, - #anchor-manager .btn { + #back-to-alchemy-btn { width: 100%; box-sizing: border-box; } } + + #radio-manager table { width: 100%; border-collapse: collapse; } + #radio-manager th { text-align: left; } + #radio-manager th, #radio-manager td { padding: 0.4rem 0.3rem; vertical-align: middle; } + #radio-manager .radio-name { overflow-wrap: anywhere; } + #radio-manager .radio-temperature, + #radio-manager .radio-n-results { width: 6.5rem; box-sizing: border-box; } + #radio-manager .radio-anchor-select { width: 100%; } + #add-radio-btn { width: 100%; border-style: dashed; } + @media (max-width: 768px) { + #radio-manager table { font-size: 0.85rem; } + #radio-manager th, #radio-manager td { padding: 0.3rem 0.15rem; } + #radio-manager .radio-temperature { width: 3.6rem; } + #radio-manager .radio-n-results { width: 4.8rem; min-width: 4.8rem; } + #radio-manager > div { flex-wrap: wrap; gap: 0.5rem; } + #radio-back-to-alchemy-btn { width: 100%; box-sizing: border-box; } + }