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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions app_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,8 +497,6 @@ def _run_chat_pipeline(data, log_messages):
all_songs = plan_result['songs']
song_sources = plan_result['song_sources']
tools_used_history = plan_result['tools_used_history']
tool_execution_summary = plan_result['tool_execution_summary']
detected_min_rating = plan_result['detected_min_rating']
plan_notes = plan_result.get('plan_notes', [])
executed_query_str = plan_result['executed_query_str']
filter_applied = plan_result.get('filter_applied', False)
Expand Down
1 change: 0 additions & 1 deletion app_provider_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1481,7 +1481,6 @@ def matched_albums(session_id):

dry = state.get('dry_run') or {}
auto_matches = dry.get('matches') or {}
match_tiers = dry.get('match_tiers') or {}
manual_matches = state.get('manual_matches') or {}
manual_unmatches = set(state.get('manual_unmatches') or [])
new_meta = state.get('new_meta') or {}
Expand Down
5 changes: 5 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ def _compute_headers():
# --- GPU Acceleration for Clustering (Optional, requires NVIDIA GPU and RAPIDS cuML) ---
USE_GPU_CLUSTERING = os.environ.get("USE_GPU_CLUSTERING", "False").lower() == "true"

# --- Clustering Cleanup Behavior ---
# When True (default), existing '_automatic' playlists are deleted before new clusters are created.
# Set to False to preserve old automatic playlists when running clustering.
CLUSTERING_CLEANING = os.environ.get("CLUSTERING_CLEANING", "True").lower() == "true"

# --- DBSCAN Only Constants (Ranges for Evolutionary Approach) ---
# Default ranges for DBSCAN parameters
DBSCAN_EPS_MIN = float(os.getenv("DBSCAN_EPS_MIN", "0.1"))
Expand Down
1 change: 0 additions & 1 deletion query/brainstorm_real_gmm_080.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,6 @@ def main():
item_ids, all_X = load_all_embeddings()

clap_scores = load_clap_scores() if USE_CLAP else {}
id_to_idx = {iid: i for i, iid in enumerate(item_ids)}

pred_w = load_prediction_weights()
print("Loaded MSD prediction weights")
Expand Down
1 change: 0 additions & 1 deletion static/menu.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ html.sidebar-open .sidebar {
color: #d1d5db;
text-decoration: none;
padding: 0.75rem 0;
font-size: 1rem;
white-space: nowrap;
transition: background-color 0.2s, color 0.2s;
border-radius: 0.375rem;
Expand Down
2 changes: 1 addition & 1 deletion static/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ function renderAdvancedFields(fields) {
label: field.name,
placeholder: field.default ? field.default : '',
type: field.type === 'bool' ? 'boolean' : field.type,
inputType: field.type === 'boolean' ? 'text' : 'text',
inputType: 'text',
secret: secret,
has_value: field.has_value,
options: Array.isArray(field.options) ? field.options : null,
Expand Down
2 changes: 1 addition & 1 deletion static/sunburst.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const SunburstChart = (() => {
function buildTree(data) {
const TAG_DEPTH = 3;
const root = {name: 'root', children: [], depth: 0};
for (const mood of Object.keys(data).sort()) {
for (const mood of Object.keys(data).sort((a, b) => a.localeCompare(b))) {
const moodNode = {name: mood, children: [], depth: 1, mood: mood};
data[mood].forEach(c => {
let parent = moodNode;
Expand Down
3 changes: 0 additions & 3 deletions tasks/artist_gmm_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,6 @@ def fit_artist_gmm(artist_name: str, track_embeddings: List[np.ndarray]) -> Opti
if n_samples < 5:
logger.info(f"Artist '{artist_name}' has {n_samples} tracks - using each song as a GMM component with equal weights")

# Use a small fixed covariance for numerical stability
# This acts like narrow Gaussians centered on each actual song
fixed_variance = 0.01

# Each song becomes one component with equal weight
n_components = n_samples
Expand Down
5 changes: 1 addition & 4 deletions tasks/clap_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,6 @@ def _load_text_model():
provider_options=[p[1] for p in provider_options]
)

active_provider = session.get_providers()[0]
logger.info(f"✓ CLAP text model loaded successfully (~478MB)")

except Exception as e:
Expand Down Expand Up @@ -694,9 +693,7 @@ def get_text_embeddings_batch(query_texts: list) -> Optional[np.ndarray]:
# Get text-only model for text search
session = get_clap_text_model()
tokenizer = get_tokenizer()

batch_size = len(query_texts)


# Tokenize all texts at once (max_length=77 for CLAP)
encoded = tokenizer(
query_texts,
Expand Down
95 changes: 28 additions & 67 deletions tasks/clustering.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,23 @@
from psycopg2.extras import DictCursor

# Import configuration
from config import MAX_SONGS_PER_CLUSTER, MOOD_LABELS, STRATIFIED_GENRES, MUTATION_KMEANS_COORD_FRACTION, MUTATION_INT_ABS_DELTA, MUTATION_FLOAT_ABS_DELTA, TOP_N_ELITES, EXPLOITATION_START_FRACTION, EXPLOITATION_PROBABILITY_CONFIG, SAMPLING_PERCENTAGE_CHANGE_PER_RUN, ITERATIONS_PER_BATCH_JOB, MAX_CONCURRENT_BATCH_JOBS, MIN_PLAYLIST_SIZE_FOR_TOP_N, CLUSTERING_BATCH_TIMEOUT_MINUTES, CLUSTERING_MAX_FAILED_BATCHES
from config import MAX_SONGS_PER_CLUSTER, MOOD_LABELS, STRATIFIED_GENRES, MUTATION_KMEANS_COORD_FRACTION, MUTATION_INT_ABS_DELTA, MUTATION_FLOAT_ABS_DELTA, TOP_N_ELITES, EXPLOITATION_START_FRACTION, EXPLOITATION_PROBABILITY_CONFIG, SAMPLING_PERCENTAGE_CHANGE_PER_RUN, ITERATIONS_PER_BATCH_JOB, MAX_CONCURRENT_BATCH_JOBS, MIN_PLAYLIST_SIZE_FOR_TOP_N, CLUSTERING_BATCH_TIMEOUT_MINUTES, CLUSTERING_MAX_FAILED_BATCHES, CLUSTERING_CLEANING

from error import error_manager
from error.error_dictionary import ERR_CLUSTERING_FAILED

# Import AI naming function and prompt template
from tasks.ai.api import get_ai_playlist_name
from tasks.ai.prompts import creative_prompt_template
# (used by clustering_helper._try_ai_name_playlist, imported there)
# Import media server functions
from .mediaserver import create_playlist, delete_automatic_playlists
# Import refactored clustering helpers
from .clustering_helper import (
_get_stratified_song_subset,
get_job_result_safely,
_perform_single_clustering_iteration
_perform_single_clustering_iteration,
_shuffle_playlist_songs,
_assign_playlist_chunks,
_try_ai_name_playlist,
)
# Import post-processing functions from dedicated module
from .clustering_postprocessing import (
Expand Down Expand Up @@ -399,15 +401,12 @@ def _log_and_update(message, progress, details_to_add_or_update=None, task_state
# --- 2. Batch Job Orchestration ---
num_total_batches = (num_clustering_runs + ITERATIONS_PER_BATCH_JOB - 1) // ITERATIONS_PER_BATCH_JOB if ITERATIONS_PER_BATCH_JOB > 0 else 0
next_batch_to_launch = 0
batches_completed_count = 0

# STATE RECOVERY
child_tasks_from_db = get_child_tasks_from_db(current_task_id)
if child_tasks_from_db:
logger.info(f"Found {len(child_tasks_from_db)} existing child tasks. Attempting state recovery.")
_monitor_and_process_batches(_main_task_accumulated_details, current_task_id, initial_check=True)
# Count batches processed during recovery (these are now in processed_job_ids)
batches_completed_count = len(_main_task_accumulated_details.get('processed_job_ids', set()))

# Determine next batch to launch based on total runs accounted for
runs_accounted_for = _main_task_accumulated_details["runs_completed"]
Expand Down Expand Up @@ -540,12 +539,14 @@ def _log_and_update(message, progress, details_to_add_or_update=None, task_state
ollama_model_name_param,
openai_server_url_param, openai_model_name_param, openai_api_key_param,
gemini_api_key_param, gemini_model_name_param,
mistral_api_key_param, mistral_model_name_param,
enable_clustering_embeddings_param
mistral_api_key_param, mistral_model_name_param
)

_log_and_update("Deleting existing automatic playlists...", 97)
delete_automatic_playlists()
if CLUSTERING_CLEANING:
_log_and_update("Deleting existing automatic playlists...", 97)
delete_automatic_playlists()
else:
_log_and_update("CLUSTERING_CLEANING is disabled — skipping deletion of existing automatic playlists.", 97)
Comment thread
NeptuneHub marked this conversation as resolved.

# *** ABSOLUTE FINAL SHUFFLE: Guarantee random order right before database storage ***
logger.info("=== ABSOLUTE FINAL SHUFFLE: Randomizing all playlists before database storage ===")
Expand Down Expand Up @@ -905,50 +906,35 @@ def _launch_batch_job(state_dict, parent_task_id, batch_idx, total_runs, genre_m
logger.info(f"Enqueued batch job {new_job.id} for runs {start_run}-{start_run + num_iterations - 1}.")


def _name_and_prepare_playlists(best_result, ai_provider, ollama_url, ollama_model, openai_url, openai_model, openai_key, gemini_key, gemini_model, mistral_key, mistral_model, embeddings_used):
def _name_and_prepare_playlists(best_result, ai_provider, ollama_url, ollama_model, openai_url, openai_model, openai_key, gemini_key, gemini_model, mistral_key, mistral_model):
"""
Uses AI to name playlists and formats them for creation.
Returns a dictionary mapping final playlist names to lists of song tuples (id, title, author).
"""
final_playlists = {}
centroids = best_result.get("playlist_centroids", {})
named_playlists = best_result.get("named_playlists", {})
max_songs = best_result.get("parameters", {}).get("max_songs_per_cluster", MAX_SONGS_PER_CLUSTER)

for original_name, songs in named_playlists.items():
if not songs:
continue

final_name = original_name
if ai_provider in ["OLLAMA", "OPENAI", "GEMINI", "MISTRAL"]:
if ai_provider in ("OLLAMA", "OPENAI", "GEMINI", "MISTRAL"):
try:
# Simplified feature extraction for AI prompt
name_parts = original_name.split('_')
feature1 = name_parts[0] if len(name_parts) > 0 else "Music"
feature2 = name_parts[1] if len(name_parts) > 1 else "Vibes"
feature3 = name_parts[2] if len(name_parts) > 2 else "Collection"
if embeddings_used:
feature1, feature2, feature3 = "Vibe", "Focused", "Collection"

ai_config = {
'provider': ai_provider,
'ollama_url': ollama_url, 'ollama_model': ollama_model,
'openai_url': openai_url, 'openai_model': openai_model, 'openai_key': openai_key,
'gemini_key': gemini_key, 'gemini_model': gemini_model,
'mistral_key': mistral_key, 'mistral_model': mistral_model,
}
ai_name = get_ai_playlist_name(
creative_prompt_template,
[{'title': s_title, 'author': s_author} for _, s_title, s_author in songs],
centroids.get(original_name, {}),
ai_config,
final_name = _try_ai_name_playlist(
original_name, songs,
best_result.get("playlist_centroids", {}),
ai_provider,
ollama_url, ollama_model,
openai_url, openai_model, openai_key,
gemini_key, gemini_model,
mistral_key, mistral_model,
)
if ai_name and "Error" not in ai_name:
final_name = ai_name.strip().replace("\n", " ")
else:
logger.warning(f"AI naming failed for '{original_name}': {ai_name}. Using original name.")
except Exception as e:
logger.warning(f"AI naming failed for '{original_name}': {e}. Using original name.")
final_name = original_name
else:
final_name = original_name

# Ensure unique names
temp_name = final_name
Expand All @@ -958,34 +944,9 @@ def _name_and_prepare_playlists(best_result, ai_provider, ollama_url, ollama_mod
temp_name = f"{final_name} ({suffix})"
final_name = temp_name

# Add suffix and handle chunking
base_name_with_suffix = f"{final_name}_automatic"

# The 'songs' variable is already the list of tuples: [(item_id, title, author), ...]
# *** FINAL SAFETY SHUFFLE: Ensure songs are randomized in final playlists ***
final_songs = songs.copy()
n = len(final_songs)

if n > 1:
# FISHER-YATES MANUAL SHUFFLE - GUARANTEED TO RANDOMIZE
current_time_seed = int(time.time() * 1000000) % 1000000

for i in range(n - 1, 0, -1):
j = (random.randint(0, i) + current_time_seed + i * 7) % (i + 1)
final_songs[i], final_songs[j] = final_songs[j], final_songs[i]
current_time_seed = (current_time_seed * 1103515245 + 12345) % (2**31)

logger.info(f"FINAL FISHER-YATES SHUFFLE applied to '{base_name_with_suffix}': {len(final_songs)} songs")
logger.info(f"FINAL ORDER: First song = '{final_songs[0][1]}', Last song = '{final_songs[-1][1]}'")
else:
logger.info(f"FINAL: '{base_name_with_suffix}' has only {n} songs - no shuffling needed")

if max_songs > 0 and len(final_songs) > max_songs:
chunks = [final_songs[i:i+max_songs] for i in range(0, len(final_songs), max_songs)]
for idx, chunk in enumerate(chunks, 1):
final_playlists[f"{base_name_with_suffix} ({idx})"] = chunk # Store the chunk of tuples
else:
final_playlists[base_name_with_suffix] = final_songs # Store the list of tuples
base_name = f"{final_name}_automatic"
shuffled = _shuffle_playlist_songs(songs, base_name)
_assign_playlist_chunks(shuffled, max_songs, base_name, final_playlists)

return final_playlists

Expand Down
60 changes: 60 additions & 0 deletions tasks/clustering_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import random
import logging
import time
import numpy as np
from collections import defaultdict
# time, re, and cdist imports moved to clustering_postprocessing.py
Expand Down Expand Up @@ -38,6 +39,65 @@
USE_GPU_CLUSTERING)
from .commons import score_vector

# Import AI naming for playlist helpers
from tasks.ai.api import get_ai_playlist_name
from tasks.ai.prompts import creative_prompt_template


# --- Playlist Naming & Shuffling Helpers ---

def _shuffle_playlist_songs(songs, playlist_name):
"""Fisher-Yates shuffle a list of song tuples; log the result."""
final_songs = songs.copy()
n = len(final_songs)
if n <= 1:
logger.info("FINAL: '%s' has only %d songs - no shuffling needed", playlist_name, n)

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
Comment thread
NeptuneHub marked this conversation as resolved.
Dismissed
return final_songs

current_time_seed = int(time.time() * 1000000) % 1000000
for i in range(n - 1, 0, -1):
j = (random.randint(0, i) + current_time_seed + i * 7) % (i + 1)
final_songs[i], final_songs[j] = final_songs[j], final_songs[i]
current_time_seed = (current_time_seed * 1103515245 + 12345) % (2 ** 31)

logger.info("FINAL FISHER-YATES SHUFFLE applied to '%s': %d songs", playlist_name, len(final_songs))

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
Comment thread
NeptuneHub marked this conversation as resolved.
Dismissed
logger.info("FINAL ORDER: First song = '%s', Last song = '%s'", final_songs[0][1], final_songs[-1][1])
return final_songs


def _assign_playlist_chunks(final_songs, max_songs, base_name, final_playlists):
"""Chunk oversized playlists or store the list as-is."""
if max_songs > 0 and len(final_songs) > max_songs:
chunks = [final_songs[i:i + max_songs] for i in range(0, len(final_songs), max_songs)]
for idx, chunk in enumerate(chunks, 1):
final_playlists[f"{base_name} ({idx})"] = chunk
else:
final_playlists[base_name] = final_songs


def _try_ai_name_playlist(original_name, songs, centroids, ai_provider,
ollama_url, ollama_model, openai_url, openai_model, openai_key,
gemini_key, gemini_model, mistral_key, mistral_model):
"""Attempt AI naming; return the original name on failure."""
ai_config = {
'provider': ai_provider,
'ollama_url': ollama_url, 'ollama_model': ollama_model,
'openai_url': openai_url, 'openai_model': openai_model, 'openai_key': openai_key,
'gemini_key': gemini_key, 'gemini_model': gemini_model,
'mistral_key': mistral_key, 'mistral_model': mistral_model,
}
ai_name = get_ai_playlist_name(
creative_prompt_template,
[{'title': s_title, 'author': s_author} for _, s_title, s_author in songs],
centroids.get(original_name, {}),
ai_config,
)
if ai_name and "Error" not in ai_name:
return ai_name.strip().replace("\n", " ")
logger.warning("AI naming failed for '%s': %s. Using original name.", original_name, ai_name)

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
Comment thread
NeptuneHub marked this conversation as resolved.
Dismissed
return original_name


# --- Main Orchestrator for a Single Iteration ---

def _perform_single_clustering_iteration(
Expand Down
2 changes: 1 addition & 1 deletion tasks/index_build_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ def load_segmented_blob(
row = cur.fetchone()
if row and row[0]:
data = row[0]
return bytes(data) if not isinstance(data, (bytes, bytearray)) else bytes(data)
return bytes(data)

cur.execute(select_segments_sql, (like_pattern,))
rows = cur.fetchall()
Expand Down
4 changes: 0 additions & 4 deletions tasks/mediaserver_emby.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,19 +541,15 @@ def _select_best_artist(item, title="Unknown"):
if item.get('ArtistItems') and len(item['ArtistItems']) > 0:
track_artist = item['ArtistItems'][0].get('Name', 'Unknown Artist')
artist_id = item['ArtistItems'][0].get('Id')
used_field = 'ArtistItems[0]'
elif item.get('Artists') and len(item['Artists']) > 0:
track_artist = item['Artists'][0] # Take first artist if multiple
artist_id = None
used_field = 'Artists[0]'
elif item.get('AlbumArtist'):
track_artist = item['AlbumArtist']
artist_id = None
used_field = 'AlbumArtist'
else:
track_artist = 'Unknown Artist'
artist_id = None
used_field = 'fallback'

return track_artist, artist_id

Expand Down
4 changes: 0 additions & 4 deletions tasks/mediaserver_jellyfin.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,19 +298,15 @@ def _select_best_artist(item, title="Unknown"):
if item.get('ArtistItems') and len(item['ArtistItems']) > 0:
track_artist = item['ArtistItems'][0].get('Name', 'Unknown Artist')
artist_id = item['ArtistItems'][0].get('Id')
used_field = 'ArtistItems[0]'
elif item.get('Artists') and len(item['Artists']) > 0:
track_artist = item['Artists'][0] # Take first artist if multiple
artist_id = None
used_field = 'Artists[0]'
elif item.get('AlbumArtist'):
track_artist = item['AlbumArtist']
artist_id = None
used_field = 'AlbumArtist'
else:
track_artist = 'Unknown Artist'
artist_id = None
used_field = 'fallback'

return track_artist, artist_id

Expand Down
Loading
Loading