From 7e953326e7c9e9eea7d3b4cb9d14eef449cfbc6d Mon Sep 17 00:00:00 2001 From: neptunehub Date: Thu, 11 Jun 2026 09:19:42 +0200 Subject: [PATCH 01/11] version bump to 2.1.5 --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From f469ca90e6d434c598e14598e1e35bd7ddceda38 Mon Sep 17 00:00:00 2001 From: neptunehub Date: Thu, 11 Jun 2026 10:55:25 +0200 Subject: [PATCH 02/11] Added Radio functionality - #476 --- app.py | 5 +- app_alchemy.py | 236 ++++++++++++++ app_chat.py | 26 +- app_clap_search.py | 2 +- app_cron.py | 11 +- app_helper.py | 74 +++++ app_provider_migration.py | 4 +- app_sync.py | 6 +- app_waveform.py | 8 +- lyrics/lyrics_transcriber.py | 22 +- lyrics/whisper_onnx.py | 2 +- query/CLAMP3/clamp3_search_demo.py | 20 +- query/brainstorm_real_gmm_080.py | 2 +- rq_janitor.py | 2 +- scripts/onnx_export/export_gte_to_onnx.py | 2 +- tasks/analysis.py | 30 +- tasks/analysis_helper.py | 12 +- tasks/clap_analyzer.py | 26 +- tasks/clap_text_search.py | 4 +- tasks/cleaning.py | 58 ++-- tasks/lyrics_manager.py | 4 +- tasks/mediaserver.py | 16 +- tasks/mediaserver_emby.py | 6 +- tasks/mediaserver_jellyfin.py | 6 +- tasks/mediaserver_lyrion.py | 12 +- tasks/mediaserver_mpd.py | 4 +- tasks/mediaserver_navidrome.py | 4 +- tasks/provider_migration_matcher.py | 8 +- tasks/provider_migration_tasks.py | 12 +- tasks/radio_manager.py | 61 ++++ tasks/radius_walk_helper.py | 2 +- tasks/sem_grove_manager.py | 6 +- tasks/song_alchemy.py | 2 +- tasks/voyager_manager.py | 2 +- templates/alchemy.html | 298 ++++++++++++++---- templates/cron.html | 26 +- test/test_analysis_integration.py | 2 +- test/test_clap_analysis_integration.py | 6 +- test/verify_onnx_embeddings.py | 34 +- tests/unit/test_ai.py | 4 +- tests/unit/test_app_alchemy_radio.py | 260 +++++++++++++++ tests/unit/test_app_chat.py | 6 +- tests/unit/test_app_cron.py | 10 +- tests/unit/test_playlist_ordering.py | 26 +- tests/unit/test_provider_migration_matcher.py | 2 +- tests/unit/test_sem_grove_manager.py | 12 +- tests/unit/test_setup_manager.py | 4 +- windows/control_server.py | 2 +- 48 files changed, 1120 insertions(+), 269 deletions(-) create mode 100644 tasks/radio_manager.py create mode 100644 tests/unit/test_app_alchemy_radio.py diff --git a/app.py b/app.py index 98606f0e..de49c4af 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 }) @@ -639,7 +640,7 @@ def get_playlists_endpoint(): summary: Return every saved playlist with its tracks, grouped by playlist name. responses: 200: - description: Playlist map (playlist_name → list of tracks). + description: Playlist map (playlist_name -> list of tracks). content: application/json: schema: @@ -739,7 +740,7 @@ def listen_for_index_reloads(): logger.warning(f"SemGrove cache reload failed: {e}") sg_success = False - logger.info(f"In-memory reload complete: Voyager ✓, Artist ✓, Maps ✓, CLAP {'✓' if clap_success else '✗'}, Lyrics {'✓' if lyrics_success else '✗'}, SemGrove {'✓' if sg_success else '✗'}") + logger.info(f"In-memory reload complete: Voyager , Artist , Maps , CLAP {'' if clap_success else ''}, Lyrics {'' if lyrics_success else ''}, SemGrove {'' if sg_success else ''}") except Exception as e: logger.error(f"Error reloading indexes/maps from background listener: {e}", exc_info=True) elif message_data == 'reload-artist': diff --git a/app_alchemy.py b/app_alchemy.py index dd36e0ac..d54e3e08 100644 --- a/app_alchemy.py +++ b/app_alchemy.py @@ -308,6 +308,242 @@ 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' + 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_chat.py b/app_chat.py index 8c87d256..d023bcfa 100644 --- a/app_chat.py +++ b/app_chat.py @@ -228,7 +228,7 @@ def chat_playlist_api(): 3. search_database - Search by genre, mood, tempo, energy, key (ALL filters in ONE call) 4. ai_brainstorm - AI suggests famous songs (trending, top hits, radio classics, etc.) - AI analyzes request → calls tools → combines results → returns 100 songs + AI analyzes request -> calls tools -> combines results -> returns 100 songs Non-streaming variant: runs the whole pipeline then returns the full JSON. """ @@ -339,13 +339,13 @@ def _run_chat_pipeline(data, log_messages): original_user_input = data.get('userInput') # Detect if user's request mentions ratings (guard against AI hallucinating rating filters) _user_wants_rating = bool(re.search( - r'\b(rat(ed|ing|ings)|stars?|⭐|favorit|best[\s-]?rated|top[\s-]?rated|highly[\s-]?rated)\b', + r'\b(rat(ed|ing|ings)|stars?||favorit|best[\s-]?rated|top[\s-]?rated|highly[\s-]?rated)\b', original_user_input, re.IGNORECASE )) ai_provider = data.get('ai_provider', config.AI_MODEL_PROVIDER).upper() ai_model_from_request = data.get('ai_model') - log_messages.append(f"🎵 NEW MCP-BASED PLAYLIST GENERATION") + log_messages.append(f" NEW MCP-BASED PLAYLIST GENERATION") log_messages.append(f"Request: '{original_user_input}'") log_messages.append(f"AI Provider: {ai_provider}") @@ -450,7 +450,7 @@ def _run_chat_pipeline(data, log_messages): # MCP AGENTIC WORKFLOW # ==================== - log_messages.append("\n🤖 Using MCP Agentic Workflow for playlist generation") + log_messages.append("\n Using MCP Agentic Workflow for playlist generation") log_messages.append("Target: 100 songs") # Get MCP tools and library context @@ -528,7 +528,7 @@ def _run_chat_pipeline(data, log_messages): diversity_removed = len(all_songs) - len(diversified_pool) if diversity_removed > 0: - log_messages.append(f"\n🎨 Artist diversity: removed {diversity_removed} excess songs from pool (max {max_per_artist}/artist)") + log_messages.append(f"\n Artist diversity: removed {diversity_removed} excess songs from pool (max {max_per_artist}/artist)") # --- Phase 2: Proportional sampling from diversified pool --- if len(diversified_pool) <= target_song_count: @@ -562,7 +562,7 @@ def _run_chat_pipeline(data, log_messages): if backfill_added == 0: break # No progress at this cap level, stop if current_cap > max_per_artist: - log_messages.append(f" Progressive cap relaxation: {max_per_artist} → {current_cap}/artist to reach {len(final_query_results_list)} songs") + log_messages.append(f" Progressive cap relaxation: {max_per_artist} -> {current_cap}/artist to reach {len(final_query_results_list)} songs") else: # More diversified songs than target — sample proportionally by tool call songs_by_call = {} @@ -590,7 +590,7 @@ def _run_chat_pipeline(data, log_messages): final_query_results_list = final_query_results_list[:target_song_count] - log_messages.append(f"\n📊 Pool: {len(all_songs)} collected → {len(diversified_pool)} after diversity cap → {len(final_query_results_list)} in final playlist") + log_messages.append(f"\n Pool: {len(all_songs)} collected -> {len(diversified_pool)} after diversity cap -> {len(final_query_results_list)} in final playlist") # --- Song Ordering for Smooth Transitions (Phase 3A) --- # Only when NO filter drove the result. When a filter/score was applied @@ -599,7 +599,7 @@ def _run_chat_pipeline(data, log_messages): # similarity. Re-sorting by tempo/energy/key here would scramble that and # bury the matched songs, so the scored order is preserved instead. if filter_applied: - log_messages.append(f"\n🎵 Playlist kept in filter-ranked order (matched songs first); smooth-transition reorder skipped") + log_messages.append(f"\n Playlist kept in filter-ranked order (matched songs first); smooth-transition reorder skipped") else: try: from tasks.playlist_ordering import order_playlist @@ -611,10 +611,10 @@ def _run_chat_pipeline(data, log_messages): # Rebuild list in new order id_to_song = {s['item_id']: s for s in final_query_results_list} final_query_results_list = [id_to_song[sid] for sid in ordered_ids if sid in id_to_song] - log_messages.append(f"\n🎵 Playlist ordered for smooth transitions (tempo/energy/key)") + log_messages.append(f"\n Playlist ordered for smooth transitions (tempo/energy/key)") except Exception: logger.warning("Playlist ordering failed (non-fatal)", exc_info=True) - log_messages.append("\n⚠️ Playlist ordering skipped due to an internal processing issue") + log_messages.append("\n Playlist ordering skipped due to an internal processing issue") final_executed_query_str = executed_query_str @@ -623,12 +623,12 @@ def _run_chat_pipeline(data, log_messages): for n in plan_notes: log_messages.append(f" {n}") - log_messages.append(f"\n✅ SUCCESS! Generated playlist with {len(final_query_results_list)} songs") + log_messages.append(f"\n SUCCESS! Generated playlist with {len(final_query_results_list)} songs") log_messages.append(f" Total songs collected: {len(all_songs)}") log_messages.append(f" Tools called: {len(tools_used_history)}") # Show tool contribution breakdown (collected vs final) - log_messages.append(f"\n📊 Tool Contribution (Collected → Final Playlist):") + log_messages.append(f"\n Tool Contribution (Collected -> Final Playlist):") # Count songs in final playlist by tool call final_by_call = {} @@ -658,7 +658,7 @@ def _run_chat_pipeline(data, log_messages): call_index = tool_info.get('call_index', -1) final_count = final_by_call.get(call_index, 0) if song_count != final_count: - log_messages.append(f" • {tool_name}({args_str}): {song_count} collected → {final_count} in final playlist") + log_messages.append(f" • {tool_name}({args_str}): {song_count} collected -> {final_count} in final playlist") else: log_messages.append(f" • {tool_name}({args_str}): {song_count} songs") else: diff --git a/app_clap_search.py b/app_clap_search.py index 1c35fe14..533f31a6 100644 --- a/app_clap_search.py +++ b/app_clap_search.py @@ -18,7 +18,7 @@ def clap_search_page(): --- tags: - CLAP Search - summary: HTML page for natural-language music search powered by CLAP audio↔text embeddings. + summary: HTML page for natural-language music search powered by CLAP audio<->text embeddings. responses: 200: description: HTML page rendered. diff --git a/app_cron.py b/app_cron.py index a9a8cda8..1b9ce225 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 as e: + logger.error(f"Cron: error running radio playlists: {e}") # 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..b0c78ba8 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 as e: + logger.error(f"Failed to load alchemy radios: {e}") + 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 as e: + conn.rollback() + logger.error(f"Failed to create alchemy radio for anchor_id={anchor_id}: {e}") + 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 as e: + conn.rollback() + logger.error(f"Failed to update alchemy radio id={radio_id}: {e}") + 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 as e: + conn.rollback() + logger.error(f"Failed to delete alchemy radio id={radio_id}: {e}") + 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_provider_migration.py b/app_provider_migration.py index 1eb24897..524b0368 100644 --- a/app_provider_migration.py +++ b/app_provider_migration.py @@ -643,7 +643,7 @@ def source_paths_refresh(): --- tags: - Provider Migration - summary: Re-probe the currently active provider to build a {item_id → real_path} override map. + summary: Re-probe the currently active provider to build a {item_id -> real_path} override map. description: | Called when `score.file_path` is unusable (e.g. Navidrome analyzed without "Report Real Path"). After refresh, the dry-run can use the @@ -1358,7 +1358,7 @@ def dry_run_report(session_id): --- tags: - Provider Migration - summary: CSV showing the planned old→new mapping for every score row (orphans have blank new-side cells). + summary: CSV showing the planned old->new mapping for every score row (orphans have blank new-side cells). description: | Columns: old_id, old_artist, old_album, old_album_artist, old_track, old_path, new_id, new_artist, new_album, new_album_artist, new_track, new_path, match_source diff --git a/app_sync.py b/app_sync.py index ea53b845..7e8cdb23 100644 --- a/app_sync.py +++ b/app_sync.py @@ -4,10 +4,10 @@ (metadata, mood/energy, MusiCNN + CLAP embeddings, UMAP 2D coordinates). Three modes, all read-only (no schema, no triggers, no write path): - * ``?fields=index`` → lightweight ``{id, fp}`` manifest (<=1000/page) for + * ``?fields=index`` -> lightweight ``{id, fp}`` manifest (<=1000/page) for client-side change detection. - * ``?ids=a,b,c`` → full payloads for a specific id set (<=500). - * (default) → full paginated export (<=500/page). + * ``?ids=a,b,c`` -> full payloads for a specific id set (<=500). + * (default) -> full paginated export (<=500/page). ``fp`` is a read-time fingerprint over the analysis columns; a client diffs the manifest against its local fingerprints to derive adds/updates/deletes. diff --git a/app_waveform.py b/app_waveform.py index 8215355d..bf8e4532 100644 --- a/app_waveform.py +++ b/app_waveform.py @@ -246,7 +246,7 @@ def get_waveform_endpoint(): } fetch_time = time.time() - start_time - logger.info(f"⏱️ Fetched track metadata in {fetch_time:.2f}s") + logger.info(f" Fetched track metadata in {fetch_time:.2f}s") # Create a temporary directory for this download temp_dir = tempfile.mkdtemp(prefix='waveform_') @@ -255,13 +255,13 @@ def get_waveform_endpoint(): download_start = time.time() temp_file = download_track(temp_dir, item) download_time = time.time() - download_start - logger.info(f"⏱️ Downloaded track in {download_time:.2f}s") + logger.info(f" Downloaded track in {download_time:.2f}s") if not temp_file or not os.path.exists(temp_file): return jsonify({"error": "Failed to download track from media server"}), 500 # Generate waveform peaks in a thread pool with timeout - logger.info(f"🌊 Generating waveform with librosa for song={title}, item_id={item_id}") + logger.info(f" Generating waveform with librosa for song={title}, item_id={item_id}") waveform_start = time.time() # Submit to thread pool for parallel execution @@ -276,7 +276,7 @@ def get_waveform_endpoint(): waveform_time = time.time() - waveform_start total_time = time.time() - start_time - logger.info(f"✅ Generated {len(peaks)} waveform peaks in {waveform_time:.2f}s (total: {total_time:.2f}s)") + logger.info(f" Generated {len(peaks)} waveform peaks in {waveform_time:.2f}s (total: {total_time:.2f}s)") response = { "peaks": peaks, diff --git a/lyrics/lyrics_transcriber.py b/lyrics/lyrics_transcriber.py index 45faa563..87492c70 100644 --- a/lyrics/lyrics_transcriber.py +++ b/lyrics/lyrics_transcriber.py @@ -247,15 +247,15 @@ def _clip_audio(audio: np.ndarray, sr: int, "\U0001E000-\U0001E02F" "\U0001F000-\U0001F02F" "\U0001F0A0-\U0001F0FF" - "☀-⛿" - "✀-➿" - "⌀-⏿" - "←-⇿" - "─-╿" - "▀-▟" - "■-◿" + "-" + "-" + "-" + "<--<->" + "-" + "-" + "-" "\U0001F1E6-\U0001F1FF" - "‍️︎" + "︎" "]", flags=re.UNICODE, ) @@ -802,7 +802,7 @@ def _alarm_handler(signum, frame): logger.info('STEP 5 raw ASR output: %s', raw_text or '') _resolved, _script, _reject = _resolve_lang_and_quality(raw_text, asr_lang) if _script and _script != asr_lang: - logger.info('STEP 5: CJK script override %r → %r', asr_lang, _script) + logger.info('STEP 5: CJK script override %r -> %r', asr_lang, _script) if _resolved: detected_lang = _resolved if _reject: @@ -819,12 +819,12 @@ def _alarm_handler(signum, frame): except Exception as exc: logger.warning('STEP 6: langdetect failed (%s)', exc) text_lang, text_conf = '', 0.0 - logger.info('STEP 6: langdetect (%s chars) → %r (conf=%.2f)', + logger.info('STEP 6: langdetect (%s chars) -> %r (conf=%.2f)', len(raw_text), text_lang, text_conf) _resolved, _script, _reject = _resolve_lang_and_quality(raw_text, text_lang) if _script: if _script != text_lang: - logger.info('STEP 6: CJK script override %r → %r (langdetect conf=%.2f)', + logger.info('STEP 6: CJK script override %r -> %r (langdetect conf=%.2f)', text_lang, _script, text_conf) text_lang = _resolved if _reject: diff --git a/lyrics/whisper_onnx.py b/lyrics/whisper_onnx.py index 579f0fda..562d4520 100644 --- a/lyrics/whisper_onnx.py +++ b/lyrics/whisper_onnx.py @@ -544,7 +544,7 @@ def transcribe(self, wav: np.ndarray, dropped_by_compression = True preview = (cleaned[:80] + '…') if len(cleaned) > 80 else cleaned logger.info( - "Whisper-small: chunk %d/%d (%.2fs, %d samples) → " + "Whisper-small: chunk %d/%d (%.2fs, %d samples) -> " "%d chars, avg_logprob=%s%s | %r", chunk_idx + 1, len(encoder_outputs), chunk_seconds, chunk_samples, len(cleaned), diff --git a/query/CLAMP3/clamp3_search_demo.py b/query/CLAMP3/clamp3_search_demo.py index 1d59fcf8..e6b1760e 100644 --- a/query/CLAMP3/clamp3_search_demo.py +++ b/query/CLAMP3/clamp3_search_demo.py @@ -23,7 +23,7 @@ import warnings warnings.filterwarnings('ignore') -print("✓ All imports successful!") +print(" All imports successful!") # ============================================================================= @@ -87,7 +87,7 @@ def __init__(self, weights_path): print(f" - Audio proj params: {len(audio_proj_dict)}") if len(audio_model_dict) == 0 or len(text_model_dict) == 0: - print(" ⚠️ WARNING: Model weights appear to be missing or incorrectly formatted!") + print(" WARNING: Model weights appear to be missing or incorrectly formatted!") print(" Available keys in checkpoint:", list(state_dict.keys())[:10]) self.text_model.load_state_dict(text_model_dict, strict=False) @@ -96,7 +96,7 @@ def __init__(self, weights_path): self.audio_proj.load_state_dict(audio_proj_dict) self.eval() - print("✓ CLAMP3 model loaded successfully") + print(" CLAMP3 model loaded successfully") def avg_pooling(self, features, masks): """Average pooling with mask.""" @@ -163,7 +163,7 @@ def __init__(self, model_name='m-a-p/MERT-v1-95M', device='cpu', return_attention_mask=True, do_normalize=True, ) - print("✓ MERT model loaded") + print(" MERT model loaded") def load_audio(self, audio_path): """Load audio file and resample to target sample rate.""" @@ -228,7 +228,7 @@ def extract_features(self, audio_path): # Stack: (num_windows, hidden_dim) all_features = torch.stack(all_features) - print(f"→ {len(all_features)} windows") + print(f"-> {len(all_features)} windows") return all_features.numpy() @@ -386,7 +386,7 @@ def analyze_folder(self, folder_path): process_as_npy = True elif raw_audio: if self.mert_extractor is None: - print(f"⚠️ Found {len(raw_audio)} audio file(s) but MERT extractor not initialized") + print(f" Found {len(raw_audio)} audio file(s) but MERT extractor not initialized") print(f" Re-run with extract_mert_on_fly=True to enable on-the-fly extraction") return print(f"Found {len(raw_audio)} audio file(s) - will extract MERT features on-the-fly") @@ -418,7 +418,7 @@ def analyze_folder(self, folder_path): self.audio_files.append(audio_file) print() - print(f"✓ Analyzed {len(self.audio_embeddings)} audio files successfully") + print(f" Analyzed {len(self.audio_embeddings)} audio files successfully") def search(self, query_text, top_k=5): """Search audio files using a text query.""" @@ -509,16 +509,16 @@ def main(): # Check if MERT features exist, otherwise process raw audio if mert_folder.exists(): - print(f"✓ Found MERT features folder: {mert_folder}") + print(f" Found MERT features folder: {mert_folder}") searcher.analyze_folder(mert_folder) else: - print(f"⚠️ MERT features folder not found: {mert_folder}") + print(f" MERT features folder not found: {mert_folder}") print(f" Will extract MERT features on-the-fly from audio files in: {audio_folder}") if audio_folder.exists(): searcher.analyze_folder(audio_folder) else: - print(f"⚠️ Audio folder not found: {audio_folder}") + print(f" Audio folder not found: {audio_folder}") sys.exit(1) # Search queries diff --git a/query/brainstorm_real_gmm_080.py b/query/brainstorm_real_gmm_080.py index 08363f5e..32afe792 100644 --- a/query/brainstorm_real_gmm_080.py +++ b/query/brainstorm_real_gmm_080.py @@ -259,7 +259,7 @@ def main(): print(f"\n Centroid MSD tag profiles (top-8 tags):") print(f" {'#':>3} {'songs':>5} {'mood_score':>10} top MSD tags") - print(f" {'─'*75}") + print(f" {''*75}") centroid_data = [] for c in range(best_k): diff --git a/rq_janitor.py b/rq_janitor.py index 8941da25..230e7acf 100644 --- a/rq_janitor.py +++ b/rq_janitor.py @@ -17,7 +17,7 @@ configure_logging() if __name__ == '__main__': - logging.info("🧹 RQ Janitor process starting. Cleaning registries every 10 seconds.") + logging.info(" RQ Janitor process starting. Cleaning registries every 10 seconds.") queues_to_clean = [rq_queue_high, rq_queue_default] while True: try: diff --git a/scripts/onnx_export/export_gte_to_onnx.py b/scripts/onnx_export/export_gte_to_onnx.py index 983a60a2..a79b6394 100644 --- a/scripts/onnx_export/export_gte_to_onnx.py +++ b/scripts/onnx_export/export_gte_to_onnx.py @@ -83,7 +83,7 @@ def export_gte_to_onnx(input_dir: str, output_path: str, do_constant_folding=True, ) - print(f'Quantizing to INT8 → {output_path}...', flush=True) + print(f'Quantizing to INT8 -> {output_path}...', flush=True) from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_input=fp32_path, diff --git a/tasks/analysis.py b/tasks/analysis.py index f7b7e3bd..37132b97 100644 --- a/tasks/analysis.py +++ b/tasks/analysis.py @@ -141,7 +141,7 @@ def _step(label, fn, progress=None, banner=None, fatal=False): pass try: fn() - logger.info(f"✓ {label}") + logger.info(f" {label}") except Exception as e: logger.warning(f"Failed to build/store {label}: {e}") if fatal: @@ -178,12 +178,12 @@ def _step(label, fn, progress=None, banner=None, fatal=False): progress=97, banner="Building artist component projection...") try: redis_conn.publish('index-updates', 'reload') - logger.info('✓ Published reload message to Flask container') + logger.info(' Published reload message to Flask container') except Exception as e: logger.warning(f'Could not publish reload message: {e}') _release_freed_ram_to_os() - logger.info('✓ Released freed RAM back to OS after index rebuild') + logger.info(' Released freed RAM back to OS after index rebuild') # --- Core Analysis Functions --- @@ -245,14 +245,14 @@ def robust_load_audio_with_fallback(file_path, target_sr=16000): def rebuild_all_indexes_task(): """Rebuild all indexes as a standalone RQ task (enqueued on default queue).""" - logger.info("🔨 Starting index rebuild task (enqueued as subtask)...") + logger.info(" Starting index rebuild task (enqueued as subtask)...") with app.app_context(): try: _run_all_index_builds() - logger.info("✅ Index rebuild task completed successfully") + logger.info(" Index rebuild task completed successfully") return {"status": "SUCCESS", "message": "All indexes rebuilt"} except Exception as e: - logger.error(f"❌ Index rebuild task failed: {e}", exc_info=True) + logger.error(f" Index rebuild task failed: {e}", exc_info=True) return {"status": "FAILURE", "message": str(e)} def analyze_track(file_path, mood_labels_list, model_paths, onnx_sessions=None, return_audio=False): @@ -351,7 +351,7 @@ def analyze_track(file_path, mood_labels_list, model_paths, onnx_sessions=None, # The old code then applied sigmoid(mean(those probs)) on top — a # "double sigmoid" that pushed values into the ~0.50-0.56 range. # The new musicnn_prediction.onnx outputs raw logits, so we replicate - # the full old pipeline: sigmoid(logits) → mean → sigmoid. + # the full old pipeline: sigmoid(logits) -> mean -> sigmoid. mood_probs_per_patch = sigmoid(mood_logits) final_mood_predictions = sigmoid(np.mean(mood_probs_per_patch, axis=0)) @@ -466,7 +466,7 @@ def log_and_update_album_task(message, progress, **kwargs): try: clap_label_embeddings = get_or_cache_other_feature_text_embeddings(redis_conn) if clap_label_embeddings: - logger.info(f"✓ CLAP other feature text embeddings ready ({len(clap_label_embeddings)} labels)") + logger.info(f" CLAP other feature text embeddings ready ({len(clap_label_embeddings)} labels)") else: logger.warning("Could not load CLAP text embeddings - other_features will be zeros") except Exception as e: @@ -549,7 +549,7 @@ def _ensure_track_download(): comprehensive_memory_cleanup(force_cuda=True, reset_onnx_pool=True) onnx_sessions = load_musicnn_sessions(model_paths) if onnx_sessions: - logger.info(f"✓ Recycled {len(onnx_sessions)} MusiCNN model sessions") + logger.info(f" Recycled {len(onnx_sessions)} MusiCNN model sessions") session_recycler.mark_recycled() if needs_lyrics and LYRICS_ENABLED: @@ -596,7 +596,7 @@ def _ensure_track_download(): logger.info(f" - Other Features: {other_features}") _ah.persist_musicnn_results(item, musicnn_analysis, top_moods, musicnn_embedding, other_features) - # CLAP must be saved AFTER score (FK: clap_embedding.item_id → score.item_id). + # CLAP must be saved AFTER score (FK: clap_embedding.item_id -> score.item_id). _ah.persist_clap_embedding(item['Id'], clap_embedding_for_track, needs_clap) if _ah.run_lyrics_for_track(item, path, track_audio, track_sr, track_name_full, @@ -685,11 +685,11 @@ def log_and_update_main(message, progress, **kwargs): save_task_status(current_task_id, "main_analysis", task_state, progress=progress, details=details) try: - log_and_update_main("🚀 Starting main analysis process...", 0) + log_and_update_main(" Starting main analysis process...", 0) clean_temp(TEMP_DIR) all_albums = get_recent_albums(num_recent_albums) if not all_albums: - log_and_update_main("⚠️ No new albums to analyze.", 100, albums_found=0, task_state=TASK_STATUS_SUCCESS) + log_and_update_main(" No new albums to analyze.", 100, albums_found=0, task_state=TASK_STATUS_SUCCESS) return {"status": "SUCCESS", "message": "No new albums to analyze."} total_albums_to_check = len(all_albums) @@ -754,7 +754,7 @@ def monitor_and_clear_jobs(): 'tasks.analysis.rebuild_all_indexes_task', job_id=str(uuid.uuid4()), job_timeout=-1, retry=Retry(max=3), ) - logger.info(f"⏰ Enqueued index rebuild job {rebuild_job.id} on default queue") + logger.info(f" Enqueued index rebuild job {rebuild_job.id} on default queue") last_rebuild_count = albums_completed for idx, album in enumerate(all_albums): @@ -845,11 +845,11 @@ def monitor_and_clear_jobs(): except OperationalError as e: logger.critical(f"FATAL ERROR: Main analysis task failed due to DB connection issue: {e}", exc_info=True) err = error_manager.record(ERR_DB_CONNECTION, str(e), exc=e) - log_and_update_main(f"❌ Main analysis failed due to a database connection error. The task may be retried.", current_progress, task_state=TASK_STATUS_FAILURE, error_message=str(e), error=err) + log_and_update_main(f" Main analysis failed due to a database connection error. The task may be retried.", current_progress, task_state=TASK_STATUS_FAILURE, error_message=str(e), error=err) # Re-raise to allow RQ to handle retries if configured on the task itself raise except Exception as e: logger.critical(f"FATAL ERROR: Analysis failed: {e}", exc_info=True) err = error_manager.record(error_manager.classify(e, ERR_ANALYSIS_FAILED), str(e), exc=e) - log_and_update_main(f"❌ Main analysis failed: {e}", current_progress, task_state=TASK_STATUS_FAILURE, error_message=str(e), error=err) + log_and_update_main(f" Main analysis failed: {e}", current_progress, task_state=TASK_STATUS_FAILURE, error_message=str(e), error=err) raise diff --git a/tasks/analysis_helper.py b/tasks/analysis_helper.py index 421f39c1..26908c56 100644 --- a/tasks/analysis_helper.py +++ b/tasks/analysis_helper.py @@ -75,7 +75,7 @@ def resolve_providers(allow_coreml=False, role=None, cuda_options=None): """Centralized ONNX provider selection. Returns an ordered ``[(provider_name, options), ...]`` chain following the - priority NVIDIA CUDA → Apple CoreML (M1-M4) → CPU. Providers that are not + priority NVIDIA CUDA -> Apple CoreML (M1-M4) -> CPU. Providers that are not available on the current machine are skipped, and CPU is always appended last as the universal fallback. @@ -160,7 +160,7 @@ def load_musicnn_sessions(model_paths): opts = resolve_providers(allow_coreml=False) try: sessions = {n: create_onnx_session(p, opts, label=n) for n, p in model_paths.items()} - logger.info(f"✓ Loaded {len(sessions)} MusiCNN models for album reuse") + logger.info(f" Loaded {len(sessions)} MusiCNN models for album reuse") return sessions except Exception as e: logger.error(f"Failed to load MusiCNN models: {e}") @@ -417,7 +417,7 @@ def refresh_track_metadata(item, album_name): def upsert_artist_mappings_for_tracks(tracks, album_name=None): - """Bulk-store artist_name → artist_id for a list of tracks. Errors are logged, never raised.""" + """Bulk-store artist_name -> artist_id for a list of tracks. Errors are logged, never raised.""" for t in tracks: name, aid = t.get('AlbumArtist'), t.get('ArtistId') if name and aid: @@ -427,7 +427,7 @@ def upsert_artist_mappings_for_tracks(tracks, album_name=None): logger.error(f"Failed to upsert artist mapping for '{name}': {e}") elif name: scope = f" in album '{album_name}'" if album_name else "" - logger.warning(f"✗ No artist_id for '{name}'{scope}") + logger.warning(f" No artist_id for '{name}'{scope}") # --- Per-track decision / status -------------------------------------------- @@ -461,7 +461,7 @@ def build_feature_status_parts(clap_available, lyrics_enabled, include_check_mar if lyrics_enabled: parts.append("Lyrics") if include_check_marks: - return [f"{p}: ✓" for p in parts] + return [f"{p}: " for p in parts] return parts @@ -539,7 +539,7 @@ def run_lyrics_for_track(item, path, track_audio, track_sr, track_name_full, top_moods=None, download_fn=None): """Run lyrics analysis and persist embeddings. Returns True on save. - ``top_moods`` is the MusicNN top-N moods dict (label → score). When it + ``top_moods`` is the MusicNN top-N moods dict (label -> score). When it includes 'instrumental', analyze_lyrics short-circuits the entire pipeline (skips Whisper-small ASR + gte embedding) and writes the instrumental sentinel directly. When it includes 'female vocalists' / 'male vocalists' diff --git a/tasks/clap_analyzer.py b/tasks/clap_analyzer.py index fd1b19fe..f237a8f6 100644 --- a/tasks/clap_analyzer.py +++ b/tasks/clap_analyzer.py @@ -118,7 +118,7 @@ def _load_audio_model(): # GPU support: ONNX Runtime handles CUDA availability internally session = None - # Centralized provider selection: CUDA → CoreML (Apple Silicon) → CPU. + # Centralized provider selection: CUDA -> CoreML (Apple Silicon) -> CPU. # Keep CLAP audio's original CUDA tuning (cudnn DEFAULT, no copy-in-default- # stream) so the containerized GPU path stays byte-identical to before; this # cuda_options override is CUDA-only and has no effect on the macOS CoreML @@ -163,7 +163,7 @@ def _create_session(model_input, providers, provider_opts): # 1) Preferred providers. For CoreML this is the static-shape model bytes; # otherwise the direct path so ORT resolves external data natively. session = _create_session(preferred_model_input, preferred_providers, preferred_opts) - logger.info("✓ CLAP audio model loaded successfully (direct path)") + logger.info(" CLAP audio model loaded successfully (direct path)") except Exception as direct_err: logger.warning(f"Direct path load failed: {direct_err}") @@ -180,7 +180,7 @@ def _create_session(model_input, providers, provider_opts): del _model_proto gc.collect() session = _create_session(_model_bytes, preferred_providers, preferred_opts) - logger.info("✓ CLAP audio model loaded (in-memory external data)") + logger.info(" CLAP audio model loaded (in-memory external data)") except Exception as mem_err: logger.warning(f"In-memory fallback failed: {mem_err}") session = None @@ -192,7 +192,7 @@ def _create_session(model_input, providers, provider_opts): logger.info("Attempting final CPU-only fallback…") try: session = _create_session(model_path, cpu_providers, cpu_opts) - logger.info("✓ CLAP audio model loaded (CPU fallback, direct path)") + logger.info(" CLAP audio model loaded (CPU fallback, direct path)") except Exception as cpu_err: logger.error(f"Failed to load ONNX audio model even with CPU: {cpu_err}") raise @@ -265,7 +265,7 @@ def _load_text_model(): provider_options=[p[1] for p in provider_options] ) - logger.info(f"✓ CLAP text model loaded successfully (~478MB)") + logger.info(f" CLAP text model loaded successfully (~478MB)") except Exception as e: logger.warning(f"Failed to load with preferred providers: {e}") @@ -276,7 +276,7 @@ def _load_text_model(): sess_options=sess_options, providers=['CPUExecutionProvider'] ) - logger.info(f"✓ CLAP text model loaded successfully (CPU fallback)") + logger.info(f" CLAP text model loaded successfully (CPU fallback)") except Exception as cpu_error: logger.error(f"Failed to load ONNX text model even with CPU: {cpu_error}") raise @@ -298,7 +298,7 @@ def _load_tokenizer(): # a misconfigured network can never trigger a download. tokenizer = AutoTokenizer.from_pretrained("roberta-base", local_files_only=True) - logger.info("✓ Tokenizer loaded successfully") + logger.info(" Tokenizer loaded successfully") return tokenizer @@ -320,7 +320,7 @@ def initialize_clap_audio_model(): try: _audio_session = _load_audio_model() - logger.info("✓ CLAP audio model initialized successfully (for music analysis)") + 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}") @@ -348,7 +348,7 @@ def initialize_clap_text_model(): try: _tokenizer = _load_tokenizer() _text_session = _load_text_model() - logger.info("✓ CLAP text model initialized successfully (for text search)") + 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}") @@ -375,7 +375,7 @@ def unload_clap_audio_only(): gc.collect() from .memory_utils import cleanup_cuda_memory cleanup_cuda_memory(force=True) - logger.info("✓ CLAP audio model unloaded (~268MB freed), text cache preserved") + logger.info(" CLAP audio model unloaded (~268MB freed), text cache preserved") return True except Exception as e: logger.error(f"Error unloading CLAP audio model: {e}") @@ -411,7 +411,7 @@ def unload_clap_model(): from .memory_utils import comprehensive_memory_cleanup comprehensive_memory_cleanup(force_cuda=True, reset_onnx_pool=True) - logger.info(f"✓ CLAP model(s) unloaded from memory (~{freed_mb}MB freed + GPU memory released)") + logger.info(f" CLAP model(s) unloaded from memory (~{freed_mb}MB freed + GPU memory released)") return True except Exception as e: logger.error(f"Error unloading CLAP model: {e}") @@ -756,7 +756,7 @@ def get_or_cache_other_feature_text_embeddings(redis_conn) -> Optional[dict]: ``get_text_embeddings_batch`` and immediately cache it. The external API remains unchanged (takes a redis connection, returns a - dict label→embedding) so callers in ``tasks/analysis.py`` continue to work + dict label->embedding) so callers in ``tasks/analysis.py`` continue to work without modification. """ if not config.CLAP_ENABLED: @@ -846,6 +846,6 @@ def compute_other_features_from_clap(audio_embedding: np.ndarray, label_embeddin for label, text_emb in label_embeddings.items(): # Cosine similarity = dot product for L2-normalized vectors (range [-1, 1]) similarity = float(np.dot(audio_embedding, text_emb)) - # Map cosine similarity [-1, 1] → probability-like [0, 1] + # Map cosine similarity [-1, 1] -> probability-like [0, 1] result[label] = (similarity + 1.0) / 2.0 return result diff --git a/tasks/clap_text_search.py b/tasks/clap_text_search.py index 2e0489af..928610e5 100644 --- a/tasks/clap_text_search.py +++ b/tasks/clap_text_search.py @@ -387,9 +387,9 @@ def refresh_clap_cache(): result = load_clap_cache_from_db() new_count = get_clap_cache_size() if result: - logger.info(f"✓ CLAP cache refreshed: {old_count} → {new_count} songs ({new_count - old_count:+d})") + logger.info(f" CLAP cache refreshed: {old_count} -> {new_count} songs ({new_count - old_count:+d})") else: - logger.error(f"✗ CLAP cache refresh failed! Still at {new_count} songs") + logger.error(f" CLAP cache refresh failed! Still at {new_count} songs") return result diff --git a/tasks/cleaning.py b/tasks/cleaning.py index ed3e9517..c22e4410 100644 --- a/tasks/cleaning.py +++ b/tasks/cleaning.py @@ -63,31 +63,31 @@ def log_and_update_main(message, progress, **kwargs): save_task_status(current_task_id, "cleaning", task_state, progress=progress, details=details) try: - log_and_update_main("🔍 Starting orphaned album identification...", 5) + log_and_update_main(" Starting orphaned album identification...", 5) # Step 1: Get all albums from media server (fetch all albums with limit=0) - log_and_update_main("📡 Fetching all albums from media server...", 10) + log_and_update_main(" Fetching all albums from media server...", 10) all_media_server_albums = get_recent_albums(0) # 0 means fetch all albums if not all_media_server_albums: - log_and_update_main("⚠️ No albums found on media server.", 95, task_state=TASK_STATUS_PROGRESS) - log_and_update_main(f"🔄 Rebuilding all indexes and maps...", 96) + log_and_update_main(" No albums found on media server.", 95, task_state=TASK_STATUS_PROGRESS) + log_and_update_main(f" Rebuilding all indexes and maps...", 96) try: from .analysis import _run_all_index_builds _run_all_index_builds(log_fn=None) - log_and_update_main(f"✅ All indexes and maps rebuilt successfully.", 99) + log_and_update_main(f" All indexes and maps rebuilt successfully.", 99) except Exception as e: logger.warning(f"Failed to rebuild indexes and maps: {e}") - log_and_update_main(f"⚠️ Warning: Failed to rebuild indexes and maps: {str(e)}", 99) + log_and_update_main(f" Warning: Failed to rebuild indexes and maps: {str(e)}", 99) summary = {"status": "SUCCESS", "message": "No albums found on media server.", "orphaned_albums": [], "deleted_count": 0} - log_and_update_main("✅ Database cleaning completed - no albums on media server!", 100, task_state=TASK_STATUS_SUCCESS, final_summary_details=summary) + log_and_update_main(" Database cleaning completed - no albums on media server!", 100, task_state=TASK_STATUS_SUCCESS, final_summary_details=summary) return summary - log_and_update_main(f"📊 Found {len(all_media_server_albums)} albums on media server", 20) + log_and_update_main(f" Found {len(all_media_server_albums)} albums on media server", 20) # Step 2: Get all track IDs that exist on the media server - log_and_update_main("🎵 Collecting all track IDs from media server...", 25) + log_and_update_main(" Collecting all track IDs from media server...", 25) media_server_track_ids = set() albums_processed = 0 @@ -102,16 +102,16 @@ def log_and_update_main(message, progress, **kwargs): # Update progress every 10 albums if idx % 10 == 0: progress = 25 + int(50 * (idx / float(len(all_media_server_albums)))) - log_and_update_main(f"📝 Processed {albums_processed}/{len(all_media_server_albums)} albums...", progress) + log_and_update_main(f" Processed {albums_processed}/{len(all_media_server_albums)} albums...", progress) except Exception as e: logger.warning(f"Failed to get tracks for album {album.get('Name', 'Unknown')}: {e}") continue - log_and_update_main(f"🎯 Found {len(media_server_track_ids)} total tracks on media server", 75) + log_and_update_main(f" Found {len(media_server_track_ids)} total tracks on media server", 75) # Step 3: Get all track IDs from database - log_and_update_main("🗄️ Fetching all track IDs from database...", 80) + log_and_update_main(" Fetching all track IDs from database...", 80) with get_db() as conn, conn.cursor() as cur: cur.execute(""" SELECT DISTINCT s.item_id, s.title, s.author @@ -121,11 +121,11 @@ def log_and_update_main(message, progress, **kwargs): database_tracks = cur.fetchall() database_track_ids = {row[0] for row in database_tracks} - log_and_update_main(f"📚 Found {len(database_track_ids)} tracks in database", 85) + log_and_update_main(f" Found {len(database_track_ids)} tracks in database", 85) # Step 4: Identify orphaned tracks (in database but not on media server) orphaned_track_ids = database_track_ids - media_server_track_ids - log_and_update_main(f"🧹 Identified {len(orphaned_track_ids)} orphaned tracks", 90) + log_and_update_main(f" Identified {len(orphaned_track_ids)} orphaned tracks", 90) # Step 5: Group orphaned tracks by artist/album for better presentation orphaned_albums_info = defaultdict(lambda: {"tracks": [], "track_count": 0}) @@ -158,7 +158,7 @@ def log_and_update_main(message, progress, **kwargs): safety_limit_applied = False if total_orphaned_albums > CLEANING_SAFETY_LIMIT: safety_limit_applied = True - log_and_update_main(f"⚠️ Safety limit: Found {total_orphaned_albums} orphaned albums, limiting to first {CLEANING_SAFETY_LIMIT} for safety", 92) + log_and_update_main(f" Safety limit: Found {total_orphaned_albums} orphaned albums, limiting to first {CLEANING_SAFETY_LIMIT} for safety", 92) # Keep only first CLEANING_SAFETY_LIMIT albums orphaned_albums_list = orphaned_albums_list[:CLEANING_SAFETY_LIMIT] # Recalculate track IDs for limited albums @@ -169,15 +169,15 @@ def log_and_update_main(message, progress, **kwargs): orphaned_track_ids = limited_track_ids if len(orphaned_track_ids) == 0: - log_and_update_main("✅ No orphaned tracks found. Database is clean!", 95, task_state=TASK_STATUS_PROGRESS) - log_and_update_main(f"🔄 Rebuilding all indexes and maps...", 96) + log_and_update_main(" No orphaned tracks found. Database is clean!", 95, task_state=TASK_STATUS_PROGRESS) + log_and_update_main(f" Rebuilding all indexes and maps...", 96) try: from .analysis import _run_all_index_builds _run_all_index_builds(log_fn=None) - log_and_update_main(f"✅ All indexes and maps rebuilt successfully.", 99) + log_and_update_main(f" All indexes and maps rebuilt successfully.", 99) except Exception as e: logger.warning(f"Failed to rebuild indexes and maps: {e}") - log_and_update_main(f"⚠️ Warning: Failed to rebuild indexes and maps: {str(e)}", 99) + log_and_update_main(f" Warning: Failed to rebuild indexes and maps: {str(e)}", 99) summary = { "total_media_server_albums": len(all_media_server_albums), @@ -188,14 +188,14 @@ def log_and_update_main(message, progress, **kwargs): "deleted_count": 0 } - log_and_update_main("✅ Database cleaning completed - no orphaned tracks found!", 100, task_state=TASK_STATUS_SUCCESS, final_summary_details=summary) + log_and_update_main(" Database cleaning completed - no orphaned tracks found!", 100, task_state=TASK_STATUS_SUCCESS, final_summary_details=summary) return { "status": "SUCCESS", "message": "No orphaned tracks found. Database is clean!", **summary } - log_and_update_main(f"🧹 Starting automatic deletion of {len(orphaned_track_ids)} orphaned tracks...", 93) + log_and_update_main(f" Starting automatic deletion of {len(orphaned_track_ids)} orphaned tracks...", 93) # Step 6: Automatically delete all orphaned tracks deletion_result = delete_orphaned_albums_sync(list(orphaned_track_ids)) @@ -213,21 +213,21 @@ def log_and_update_main(message, progress, **kwargs): } if deletion_result["status"] == "SUCCESS": - log_and_update_main(f"✅ Successfully deleted {deletion_result['deleted_count']} orphaned tracks.", 96) + log_and_update_main(f" Successfully deleted {deletion_result['deleted_count']} orphaned tracks.", 96) - log_and_update_main(f"🔄 Rebuilding all indexes and maps after cleaning...", 97) + log_and_update_main(f" Rebuilding all indexes and maps after cleaning...", 97) try: from .analysis import _run_all_index_builds _run_all_index_builds(log_fn=None) - log_and_update_main(f"✅ All indexes and maps rebuilt successfully after cleaning.", 99) + log_and_update_main(f" All indexes and maps rebuilt successfully after cleaning.", 99) except Exception as e: logger.warning(f"Failed to rebuild indexes and maps after cleaning: {e}") - log_and_update_main(f"⚠️ Warning: Failed to rebuild indexes and maps: {str(e)}", 99) + log_and_update_main(f" Warning: Failed to rebuild indexes and maps: {str(e)}", 99) safety_message = f" (Safety limit: deleted {len(orphaned_albums_list)} out of {total_orphaned_albums} albums)" if safety_limit_applied else "" log_and_update_main( - f"✅ Cleaning complete! Identified and deleted {len(orphaned_albums_list)} orphaned albums ({deletion_result['deleted_count']} tracks).{safety_message}", + f" Cleaning complete! Identified and deleted {len(orphaned_albums_list)} orphaned albums ({deletion_result['deleted_count']} tracks).{safety_message}", 100, task_state=TASK_STATUS_SUCCESS, final_summary_details=summary @@ -237,7 +237,7 @@ def log_and_update_main(message, progress, **kwargs): if safety_limit_applied: remaining_count = total_orphaned_albums - len(orphaned_albums_list) if remaining_count > 0: - log_and_update_main(f"ℹ️ Safety note: {remaining_count} additional orphaned albums remain. Run cleaning again to process more.", 100, task_state=TASK_STATUS_SUCCESS) + log_and_update_main(f"ℹ Safety note: {remaining_count} additional orphaned albums remain. Run cleaning again to process more.", 100, task_state=TASK_STATUS_SUCCESS) return { "status": "SUCCESS", @@ -246,7 +246,7 @@ def log_and_update_main(message, progress, **kwargs): } else: log_and_update_main( - f"⚠️ Cleaning partially failed. Deletion error: {deletion_result.get('message', 'Unknown error')}", + f" Cleaning partially failed. Deletion error: {deletion_result.get('message', 'Unknown error')}", 100, task_state=TASK_STATUS_FAILURE, final_summary_details=summary @@ -261,7 +261,7 @@ def log_and_update_main(message, progress, **kwargs): except Exception as e: logger.critical(f"Orphaned album identification failed: {e}", exc_info=True) err = error_manager.record(error_manager.classify(e, ERR_CLEANING_FAILED), str(e), exc=e) - log_and_update_main(f"❌ Orphaned album identification failed: {e}", current_progress, task_state=TASK_STATUS_FAILURE, error=err, final_summary_details={"error": str(e)}) + log_and_update_main(f" Orphaned album identification failed: {e}", current_progress, task_state=TASK_STATUS_FAILURE, error=err, final_summary_details={"error": str(e)}) raise diff --git a/tasks/lyrics_manager.py b/tasks/lyrics_manager.py index c08ec384..7cf79c8b 100644 --- a/tasks/lyrics_manager.py +++ b/tasks/lyrics_manager.py @@ -570,8 +570,8 @@ def search_by_axes(targets: Dict[str, str], limit: int = 50) -> List[Dict]: """ Voyager nearest-neighbor search over the binary axis vector. - targets: {axis_name: label_str} — at most ONE label per axis. Selected → 1.0, - everything else → 0.0. Axes the user did not pick contribute 0 across + targets: {axis_name: label_str} — at most ONE label per axis. Selected -> 1.0, + everything else -> 0.0. Axes the user did not pick contribute 0 across all their labels. """ from config import LYRICS_ENABLED, MAX_SONGS_PER_ARTIST diff --git a/tasks/mediaserver.py b/tasks/mediaserver.py index 4666ca2e..ca01d3b6 100644 --- a/tasks/mediaserver.py +++ b/tasks/mediaserver.py @@ -107,11 +107,11 @@ 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_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 @@ -135,11 +135,15 @@ def delete_automatic_playlists(): 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): + if p.get('Name', '').endswith(suffix) and delete_function(playlist_id): deleted_count += 1 - + 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/mediaserver_emby.py b/tasks/mediaserver_emby.py index 39bd57f1..1e61f449 100644 --- a/tasks/mediaserver_emby.py +++ b/tasks/mediaserver_emby.py @@ -893,7 +893,7 @@ def create_instant_playlist(playlist_name, item_ids, user_creds=None): headers = {"X-Emby-Token": token} - # ✅ 5. No JSON body should be sent — Emby expects query parameters only + # 5. No JSON body should be sent — Emby expects query parameters only r = requests.post(url, headers=headers, timeout=REQUESTS_TIMEOUT) r.raise_for_status() @@ -1022,7 +1022,7 @@ def create_or_replace_playlist(playlist_name, item_ids, user_creds=None): if rest and not _add_items_to_playlist(new_id, rest, user_id, headers): logger.error(f"Emby create_or_replace_playlist: created '{playlist_name}' but failed to add overflow tracks") - logger.info(f"✅ Emby: created playlist '{playlist_name}' (Id={new_id}) with {len(item_ids)} tracks") + logger.info(f" Emby: created playlist '{playlist_name}' (Id={new_id}) with {len(item_ids)} tracks") return {**created, 'Id': new_id, 'Name': created.get('Name', playlist_name)} playlist_id = existing.get("Id") @@ -1042,6 +1042,6 @@ def create_or_replace_playlist(playlist_name, item_ids, user_creds=None): logger.error(f"Emby create_or_replace_playlist: failed to add tracks to playlist {playlist_id}") return None - logger.info(f"✅ Emby: replaced contents of playlist '{playlist_name}' (Id={playlist_id}, tracks={len(item_ids)})") + logger.info(f" Emby: replaced contents of playlist '{playlist_name}' (Id={playlist_id}, tracks={len(item_ids)})") return {**existing, 'Id': playlist_id, 'Name': existing.get('Name', playlist_name)} diff --git a/tasks/mediaserver_jellyfin.py b/tasks/mediaserver_jellyfin.py index 42663b23..9cf4aa2a 100644 --- a/tasks/mediaserver_jellyfin.py +++ b/tasks/mediaserver_jellyfin.py @@ -461,7 +461,7 @@ def create_playlist(base_name, item_ids): body = {"Name": base_name, "Ids": item_ids, "UserId": config.JELLYFIN_USER_ID} try: r = requests.post(url, headers=config.HEADERS, json=body, timeout=REQUESTS_TIMEOUT) - if r.ok: logger.info("✅ Created Jellyfin playlist '%s'", base_name) + if r.ok: logger.info(" Created Jellyfin playlist '%s'", base_name) except Exception as e: logger.error("Exception creating Jellyfin playlist '%s': %s", base_name, e, exc_info=True) @@ -669,7 +669,7 @@ def _create_fresh_playlist(playlist_name, item_ids): if rest and not _add_items_to_playlist(new_id, rest): logger.error(f"Jellyfin _create_fresh_playlist: created '{playlist_name}' but failed to add overflow tracks") - logger.info(f"✅ Jellyfin: created playlist '{playlist_name}' (Id={new_id}) with {len(item_ids)} tracks") + logger.info(f" Jellyfin: created playlist '{playlist_name}' (Id={new_id}) with {len(item_ids)} tracks") return {**created, 'Id': new_id, 'Name': created.get('Name', playlist_name)} @@ -720,6 +720,6 @@ def create_or_replace_playlist(playlist_name, item_ids, user_creds=None): logger.error(f"Jellyfin create_or_replace_playlist: failed to add tracks to playlist {playlist_id}") return None - logger.info(f"✅ Jellyfin: replaced contents of playlist '{playlist_name}' (Id={playlist_id}, tracks={len(item_ids)})") + logger.info(f" Jellyfin: replaced contents of playlist '{playlist_name}' (Id={playlist_id}, tracks={len(item_ids)})") return {**existing, 'Id': playlist_id, 'Name': existing.get('Name', playlist_name)} diff --git a/tasks/mediaserver_lyrion.py b/tasks/mediaserver_lyrion.py index e3f8e6f1..05c38b36 100644 --- a/tasks/mediaserver_lyrion.py +++ b/tasks/mediaserver_lyrion.py @@ -835,7 +835,7 @@ def _add_to_playlist(playlist_id, item_ids): return False # Method: Load playlist to player, add tracks, then use playlists edit to update - logger.info(f"Using method: Load → Add → Update original playlist via edit command") + logger.info(f"Using method: Load -> Add -> Update original playlist via edit command") # Step 1: Load the saved playlist into the player's current playlist logger.debug(f"Step 1: Loading playlist {playlist_id} to player {player_id}") @@ -894,7 +894,7 @@ def _add_to_playlist(playlist_id, item_ids): if save_response and "__playlist_id" in save_response: final_playlist_id = save_response["__playlist_id"] if str(final_playlist_id) == str(playlist_id): - logger.info(f"✅ Successfully updated original playlist {playlist_id} with {total_added} tracks") + logger.info(f" Successfully updated original playlist {playlist_id} with {total_added} tracks") return True else: logger.warning(f"Created new playlist {final_playlist_id} instead of updating {playlist_id}") @@ -907,7 +907,7 @@ def _add_to_playlist(playlist_id, item_ids): logger.error(f"Error handling new playlist: {e}") return False elif total_added > 0: - logger.info(f"✅ Successfully added {total_added} tracks (save response: {save_response})") + logger.info(f" Successfully added {total_added} tracks (save response: {save_response})") return True else: logger.warning("No tracks were added to the playlist") @@ -933,12 +933,12 @@ def _create_playlist_batched(playlist_name, item_ids): ) if playlist_id: - logger.info(f"✅ Created Lyrion playlist '{playlist_name}' (ID: {playlist_id}).") + logger.info(f" Created Lyrion playlist '{playlist_name}' (ID: {playlist_id}).") # Step 2: Add tracks using the web interface method if item_ids: if _add_to_playlist(playlist_id, item_ids): - logger.info(f"✅ Successfully added {len(item_ids)} tracks to playlist '{playlist_name}'.") + logger.info(f" Successfully added {len(item_ids)} tracks to playlist '{playlist_name}'.") else: logger.warning(f"Playlist '{playlist_name}' created but some tracks may not have been added.") @@ -973,7 +973,7 @@ def delete_playlist(playlist_id): # used to mis-treat as failure. response = _jsonrpc_request("playlists", ["delete", f"playlist_id:{playlist_id}"]) if response is not None: - logger.info(f"🗑️ Deleted Lyrion playlist ID: {playlist_id}") + logger.info(f" Deleted Lyrion playlist ID: {playlist_id}") return True logger.error(f"Failed to delete playlist ID '{playlist_id}' on Lyrion") return False diff --git a/tasks/mediaserver_mpd.py b/tasks/mediaserver_mpd.py index e4b6ed28..19f89f79 100644 --- a/tasks/mediaserver_mpd.py +++ b/tasks/mediaserver_mpd.py @@ -246,7 +246,7 @@ def create_playlist(base_name, item_ids): for item_path in item_ids: client.playlistadd(base_name, item_path) - logger.info(f"✅ Created/updated MPD playlist '{base_name}' with {len(item_ids)} songs.") + logger.info(f" Created/updated MPD playlist '{base_name}' with {len(item_ids)} songs.") except Exception as e: logger.error(f"Exception creating MPD playlist '{base_name}': {e}", exc_info=True) finally: @@ -277,7 +277,7 @@ def delete_playlist(playlist_id): success = False try: client.rm(playlist_id) - logger.info(f"🗑️ Deleted MPD playlist: {playlist_id}") + logger.info(f" Deleted MPD playlist: {playlist_id}") success = True except Exception as e: logger.error(f"Exception deleting MPD playlist '{playlist_id}': {e}", exc_info=True) diff --git a/tasks/mediaserver_navidrome.py b/tasks/mediaserver_navidrome.py index 86eeadde..6c8859d4 100644 --- a/tasks/mediaserver_navidrome.py +++ b/tasks/mediaserver_navidrome.py @@ -542,7 +542,7 @@ def _create_playlist_batched(playlist_name, item_ids, user_creds=None): logger.error(f"Navidrome playlist '{playlist_name}' was created, but the response did not contain an ID.") return None - logger.info(f"✅ Created Navidrome playlist '{playlist_name}' (ID: {new_playlist_id}) with the first {len(ids_for_creation)} songs.") + logger.info(f" Created Navidrome playlist '{playlist_name}' (ID: {new_playlist_id}) with the first {len(ids_for_creation)} songs.") # Immediately update playlist to public (Navidrome requires updatePlaylist for visibility). update_response = _navidrome_request( @@ -583,7 +583,7 @@ def delete_playlist(playlist_id): """Deletes a playlist on Navidrome using admin credentials.""" response = _navidrome_request("deletePlaylist", {"id": playlist_id}, method='post') if response and response.get("status") == "ok": - logger.info(f"🗑️ Deleted Navidrome playlist ID: {playlist_id}") + logger.info(f" Deleted Navidrome playlist ID: {playlist_id}") return True logger.error(f"Failed to delete playlist ID '{playlist_id}' on Navidrome") return False diff --git a/tasks/provider_migration_matcher.py b/tasks/provider_migration_matcher.py index 05e04dae..5db9a1f3 100644 --- a/tasks/provider_migration_matcher.py +++ b/tasks/provider_migration_matcher.py @@ -178,7 +178,7 @@ def normalize_meta(s): def _best_artist_old(row): """Track-level artist for a source (``score``) row. - Precedence: ``author`` → ``artist`` → ``album_artist``. + Precedence: ``author`` -> ``artist`` -> ``album_artist``. ``score.author`` holds the track performer that mediaserver_*.py picked via ``_select_best_artist``, while ``score.album_artist`` preserves the album-level artist (often "Various Artists" on compilations). Preferring @@ -192,9 +192,9 @@ def _best_artist_old(row): def _best_artist_new(row): """Track-level artist for a target (probe) track. - Precedence: ``artist`` → ``album_artist``. ``provider_probe.py`` already + Precedence: ``artist`` -> ``album_artist``. ``provider_probe.py`` already collapses the provider-specific hierarchy (e.g. Jellyfin's - ``ArtistItems[0].Name`` → ``Artists[0]`` → ``AlbumArtist``) into the + ``ArtistItems[0].Name`` -> ``Artists[0]`` -> ``AlbumArtist``) into the unified ``artist`` field, so ``album_artist`` is only consulted when the probe couldn't resolve a track artist at all. """ @@ -363,7 +363,7 @@ def _pick_meta_candidate(old, candidates): if not matched: proposals.append((None, old, None)) - # Second pass: resolve collisions (multiple old rows → same new_id). + # Second pass: resolve collisions (multiple old rows -> same new_id). # The proposal with the best (lowest-rank) tier keeps the match. best_for_new = {} # new_id -> (tier, old_row) for tier, old, new_id in proposals: diff --git a/tasks/provider_migration_tasks.py b/tasks/provider_migration_tasks.py index 2e7efa66..ca795537 100644 --- a/tasks/provider_migration_tasks.py +++ b/tasks/provider_migration_tasks.py @@ -52,7 +52,7 @@ # enforces PRIMARY KEY / UNIQUE row-by-row during UPDATE, so a single-pass # UPDATE blows up if any mapping new_id happens to already exist in the table # as another row's old_id (common when both providers use small integer IDs, -# e.g., Emby ↔ Emby). Pass 1 stages every row at ||new_id (unique per +# e.g., Emby <-> Emby). Pass 1 stages every row at ||new_id (unique per # new_id) and Pass 2 strips the prefix to land the final new_id. The prefix is # deliberately long and unusual so it can never collide with a real item_id. _MIG_TMP_PREFIX = '__audiomuse_mig_tmp__' @@ -432,8 +432,8 @@ def _run_migration_transaction(cur, mapping, new_meta, # deferrable by default). A single-pass UPDATE would fail with # "duplicate key" whenever a mapping's new_id equals another row's # current item_id — very common when both providers issue small - # integer IDs that happen to overlap (e.g., migrating Emby→Emby or - # Jellyfin→Emby where both servers use "25" for different tracks). + # integer IDs that happen to overlap (e.g., migrating Emby->Emby or + # Jellyfin->Emby where both servers use "25" for different tracks). # # Pass 1 stages every row at (_MIG_TMP_PREFIX || new_id), which is # guaranteed unique (new_id is UNIQUE in the map) and cannot collide @@ -593,10 +593,10 @@ def _write_provider_to_app_config(cur, target_type, target_creds, selected_libra pattern the setup wizard uses via ``MEDIASERVER_OBSOLETE_FIELDS_BY_TYPE``). ``selected_libraries`` — the checkbox selection from the migration wizard: - * ``None`` or empty → DELETE the ``MUSIC_LIBRARIES`` row (scan everything, + * ``None`` or empty -> DELETE the ``MUSIC_LIBRARIES`` row (scan everything, and implicitly wipes the source provider's old filter since the key is shared across providers). - * non-empty list → UPSERT ``MUSIC_LIBRARIES`` with the comma-joined names. + * non-empty list -> UPSERT ``MUSIC_LIBRARIES`` with the comma-joined names. """ import config as cfg @@ -621,7 +621,7 @@ def _write_provider_to_app_config(cur, target_type, target_creds, selected_libra finally: cur.execute("SELECT pg_advisory_unlock(726354821)") - # Build the key→value pairs to upsert + # Build the key->value pairs to upsert values = {'MEDIASERVER_TYPE': target_type} key_map = _CREDS_TO_CONFIG.get(target_type, {}) for cred_key, config_key in key_map.items(): diff --git a/tasks/radio_manager.py b/tasks/radio_manager.py new file mode 100644 index 00000000..c899eb0b --- /dev/null +++ b/tasks/radio_manager.py @@ -0,0 +1,61 @@ +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.") + + delete_playlists_by_suffix(RADIO_PLAYLIST_SUFFIX) + + 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/tasks/radius_walk_helper.py b/tasks/radius_walk_helper.py index 35105a1f..147deb03 100644 --- a/tasks/radius_walk_helper.py +++ b/tasks/radius_walk_helper.py @@ -376,7 +376,7 @@ def execute_radius_walk( logger.warning("Radius walk: Candidate data empty, cannot start.") return [] - # Dict of selected item_id → vector for distance checks + # Dict of selected item_id -> vector for distance checks selected_vectors: Dict[str, np.ndarray] = { playlist_ids[0]: first_song["vector"].astype(np.float32) } diff --git a/tasks/sem_grove_manager.py b/tasks/sem_grove_manager.py index da46d397..ddb798c1 100644 --- a/tasks/sem_grove_manager.py +++ b/tasks/sem_grove_manager.py @@ -13,7 +13,7 @@ 2. Whiten per-dimension (divide by empirical std across the library) 3. Re-normalise to unit length after whitening 4. Scale by w_L = sqrt(WEIGHT_LYRICS) and w_A = sqrt(WEIGHT_AUDIO) - 5. Concatenate → merged vector of dimension (lyrics_dim + audio_dim) + 5. Concatenate -> merged vector of dimension (lyrics_dim + audio_dim) * Builds a Voyager Cosine index over the merged vectors * Persists: - The index binary in ``lyrics_index_data`` (index_name='sem_grove_index') @@ -521,7 +521,7 @@ def refresh_sem_grove_cache() -> bool: logger.info("SemGrove: refreshing cache (current=%d songs)…", old) result = load_sem_grove_cache_from_db() logger.info( - "SemGrove: cache refreshed (%d → %d songs).", + "SemGrove: cache refreshed (%d -> %d songs).", old, _SEM_GROVE_CACHE["song_count"], ) return result @@ -661,7 +661,7 @@ def search_by_song(seed_item_id: str, limit: int = 50, radius_similarity: bool | radius_similarity = SIMILARITY_RADIUS_DEFAULT artist_cap = MAX_SONGS_PER_ARTIST if MAX_SONGS_PER_ARTIST and MAX_SONGS_PER_ARTIST > 0 else 0 - dist_threshold = DUPLICATE_DISTANCE_THRESHOLD_COSINE_LYRICS # cosine dist < this → near-duplicate + dist_threshold = DUPLICATE_DISTANCE_THRESHOLD_COSINE_LYRICS # cosine dist < this -> near-duplicate lookback_n = DUPLICATE_DISTANCE_CHECK_LOOKBACK if DUPLICATE_DISTANCE_CHECK_LOOKBACK > 0 else 0 # +1 because the seed itself may appear and will be skipped if radius_similarity: diff --git a/tasks/song_alchemy.py b/tasks/song_alchemy.py index 4810fcb3..ae8f8f30 100644 --- a/tasks/song_alchemy.py +++ b/tasks/song_alchemy.py @@ -52,7 +52,7 @@ def _normalize(s: str) -> str: if _normalize(gmm_artist) == query_norm: gmm = artist_gmm_params.get(gmm_artist) if gmm: - logger.info(f"Fuzzy GMM match: '{artist_name}' → '{gmm_artist}'") + logger.info(f"Fuzzy GMM match: '{artist_name}' -> '{gmm_artist}'") artist_name = gmm_artist break diff --git a/tasks/voyager_manager.py b/tasks/voyager_manager.py index 534334b1..9af74ac0 100644 --- a/tasks/voyager_manager.py +++ b/tasks/voyager_manager.py @@ -1287,7 +1287,7 @@ def search_tracks_unified(search_query: str, limit: int = 20, offset: int = 0, id_filter_sql = f" AND item_id IN ({id_placeholders})" id_filter_params = list(item_id_filter) - # Final param list order must mirror SQL: WHERE tokens → WHERE id filter → ORDER BY scores → LIMIT/OFFSET + # Final param list order must mirror SQL: WHERE tokens -> WHERE id filter -> ORDER BY scores -> LIMIT/OFFSET all_params = params[:len(tokens)] + id_filter_params + params[len(tokens):] query = f""" diff --git a/templates/alchemy.html b/templates/alchemy.html index c64ba49d..9f0a0709 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; } + }