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: 1 addition & 1 deletion AudioMuse-AI.spec
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ hiddenimports = [
"numkong._numkong",
]
hiddenimports += cfg["extra_hiddenimports"]
for _mod in ("tasks", "lyrics", "sklearn", *cfg["collect_submodules"]):
for _mod in ("tasks", "lyrics", "sklearn", "joblib", *cfg["collect_submodules"]):
hiddenimports += collect_submodules(_mod)
hiddenimports = list(dict.fromkeys(hiddenimports))

Expand Down
18 changes: 4 additions & 14 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,11 @@
import rq_job_state
from tasks.setup_manager import setup_manager

# Redis client
from redis import Redis

# Swagger imports
from flasgger import Swagger

# Import configuration
from config import TEMP_DIR, REDIS_URL, APP_VERSION, ENABLE_PROXY_FIX, JWT_SECRET
from config import TEMP_DIR, APP_VERSION, ENABLE_PROXY_FIX, JWT_SECRET

if ENABLE_PROXY_FIX:
# Werkzeug import for reverse proxy support
Expand Down Expand Up @@ -931,16 +928,9 @@ def listen_for_index_reloads():
"""
# Create a new Redis connection for this thread.
# Sharing the main redis_conn object across threads is not recommended.
from taskqueue import redis_socket_options

thread_redis_conn = Redis.from_url(
REDIS_URL,
socket_connect_timeout=30,
socket_timeout=60,
health_check_interval=30,
retry_on_timeout=True,
**redis_socket_options(REDIS_URL),
)
from taskqueue import new_redis_connection

thread_redis_conn = new_redis_connection()
pubsub = thread_redis_conn.pubsub()
pubsub.subscribe('index-updates')
logger.info(
Expand Down
35 changes: 10 additions & 25 deletions app_alchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,38 +87,23 @@ def search_artists():
items:
type: object
"""
from tasks.artist_gmm_manager import search_artists_by_name
from app_artist_similarity import artist_search_response
from app_helper import index_error_body
from error.error_dictionary import UNKNOWN_ERROR_CODE

query = request.args.get('query', '')
query = request.args.get('query', '', type=str)

if not query or len(query) < 2:
return jsonify([])

# Pagination: start / end (0-based). Defaults to first 20 results.
start = request.args.get('start', 0, type=int)
end = request.args.get('end', None, type=int)
if start < 0:
start = 0
if end is not None and end <= start:
return jsonify([])
limit = (end - start) if end is not None else 20
offset = start

try:
server_id, include_legacy = app_server_context.selected_server_scope()
except ValueError:
logger.warning("Invalid server selection.", exc_info=True)
return jsonify({'error': 'Invalid server selection.'}), 400
try:
results = search_artists_by_name(
query,
limit=limit,
offset=offset,
server_id=server_id,
include_legacy_default=include_legacy,
)
results = app_server_context.scope_artist_results(results)
return jsonify(results)
return artist_search_response(query, start, end, 100)
except Exception:
logger.exception("Artist search failed")
return jsonify([]), 200 # Return empty list on error
logger.exception("Error during artist search")
return jsonify(index_error_body(UNKNOWN_ERROR_CODE, "An error occurred during search.")), 500


def _cached_all_playlists(server_id):
Expand Down
66 changes: 31 additions & 35 deletions app_artist_similarity.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,41 @@
import logging

import app_server_context
from error import error_manager
from app_helper import index_error_body
from error.error_dictionary import ERR_INDEX_EMPTY, UNKNOWN_ERROR_CODE
from tasks.artist_gmm_manager import find_similar_artists, search_artists_by_name, get_artist_tracks

logger = logging.getLogger(__name__)


# Structured error body with a stable, user-facing 'error' string plus the numeric
# error_code so API consumers can distinguish an unbuilt index from a real crash.
def _index_error_body(code, message):
payload = error_manager.build(code)
payload["error"] = message
return payload

# Create Blueprint
artist_similarity_bp = Blueprint('artist_similarity_bp', __name__, template_folder='templates')


def artist_search_response(query, start, end, cap):
if start < 0:
start = 0
if end is not None and end <= start:
return jsonify([])
limit = (end - start) if end is not None else 20
if cap is not None:
limit = min(limit, cap)
offset = start
try:
server_id, include_legacy = app_server_context.selected_server_scope()
except ValueError:
logger.warning("Invalid server selection.", exc_info=True)
return jsonify({'error': 'Invalid server selection.'}), 400
results = search_artists_by_name(
query,
limit=limit,
offset=offset,
server_id=server_id,
include_legacy_default=include_legacy,
)
results = app_server_context.scope_artist_results(results)
return jsonify(results)


@artist_similarity_bp.route('/artist_similarity', methods=['GET'])
def artist_similarity_page():
"""
Expand Down Expand Up @@ -91,35 +108,14 @@ def search_artists_endpoint():
if not query or len(query) < 2:
return jsonify([])

# Pagination: start / end (0-based). Defaults to first 20 results.
start = request.args.get('start', 0, type=int)
end = request.args.get('end', None, type=int)
if start < 0:
start = 0
if end is not None and end <= start:
return jsonify([])
limit = (end - start) if end is not None else 20
limit = min(limit, 100)
offset = start

try:
try:
server_id, include_legacy = app_server_context.selected_server_scope()
except ValueError:
logger.warning("Invalid server selection.", exc_info=True)
return jsonify({'error': 'Invalid server selection.'}), 400
results = search_artists_by_name(
query,
limit=limit,
offset=offset,
server_id=server_id,
include_legacy_default=include_legacy,
)
results = app_server_context.scope_artist_results(results)
return jsonify(results)
return artist_search_response(query, start, end, 100)
except Exception:
logger.exception("Error during artist search")
return jsonify(_index_error_body(UNKNOWN_ERROR_CODE, "An error occurred during search.")), 500
return jsonify(index_error_body(UNKNOWN_ERROR_CODE, "An error occurred during search.")), 500


@artist_similarity_bp.route('/api/similar_artists', methods=['GET'])
Expand Down Expand Up @@ -263,15 +259,15 @@ def get_similar_artists_endpoint():
f"Runtime error finding similar artists for '{query_artist}'"
)
return jsonify(
_index_error_body(
index_error_body(
ERR_INDEX_EMPTY, "The artist similarity search service is currently unavailable."
)
), 503
except Exception:
logger.exception(
f"Unexpected error finding similar artists for '{query_artist}'"
)
return jsonify(_index_error_body(UNKNOWN_ERROR_CODE, "An unexpected error occurred.")), 500
return jsonify(index_error_body(UNKNOWN_ERROR_CODE, "An unexpected error occurred.")), 500


@artist_similarity_bp.route('/api/artist_tracks', methods=['GET'])
Expand Down Expand Up @@ -332,5 +328,5 @@ def get_artist_tracks_endpoint():
except Exception:
logger.exception(f"Error getting tracks for artist '{query_artist}'")
return jsonify(
_index_error_body(UNKNOWN_ERROR_CODE, "An error occurred while fetching tracks.")
index_error_body(UNKNOWN_ERROR_CODE, "An error occurred while fetching tracks.")
), 500
13 changes: 8 additions & 5 deletions app_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,10 @@
import zipfile
from datetime import datetime
from flask import Blueprint, render_template, jsonify, request, send_file
from redis import Redis
from redis.exceptions import RedisError
import config
from config import POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB
import restart_manager
from taskqueue import new_redis_connection
from error import error_manager
from error.error_dictionary import (
ERR_BACKUP_VERSION_MISMATCH,
Expand All @@ -61,7 +60,9 @@
def _acquire_restore_lock():
"""SET NX EX. Returns True if we got the lock, False if held or Redis is down."""
try:
client = Redis.from_url(config.REDIS_URL, socket_timeout=5, decode_responses=True)
client = new_redis_connection(
socket_connect_timeout=5, socket_timeout=5, decode_responses=True
)
return bool(client.set(RESTORE_LOCK_KEY, '1', nx=True, ex=RESTORE_LOCK_TTL_SECONDS))
except RedisError:
logger.exception("Redis unavailable while acquiring restore lock; failing closed.")
Expand All @@ -70,7 +71,7 @@ def _acquire_restore_lock():

def _release_restore_lock():
try:
Redis.from_url(config.REDIS_URL, socket_timeout=5).delete(RESTORE_LOCK_KEY)
new_redis_connection(socket_connect_timeout=5, socket_timeout=5).delete(RESTORE_LOCK_KEY)
except RedisError:
logger.exception("Redis unavailable while releasing restore lock; relying on TTL.")

Expand All @@ -82,7 +83,9 @@ def _restore_lock_held():
than to let it write into a possibly-orphaned chunks_dir.
"""
try:
client = Redis.from_url(config.REDIS_URL, socket_timeout=5, decode_responses=True)
client = new_redis_connection(
socket_connect_timeout=5, socket_timeout=5, decode_responses=True
)
return bool(client.exists(RESTORE_LOCK_KEY))
except RedisError:
logger.exception("Redis unavailable while checking restore lock; failing closed.")
Expand Down
84 changes: 65 additions & 19 deletions app_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
Coverage, Tempo) via ``refresh_dashboard_charts_stats()`` hourly, carrying its
own ``charts_updated_at`` stamp so the UI can label it honestly.
* LIVE tier: workers (Redis), recent tasks and cron (tiny tables) only.
* Both distribution pies count each song ONCE under its winning label. Summing
the raw per-song scores instead makes every slice converge on the same value
as the library grows, because the CLAP-derived mood scores share a large
constant offset (issue #826).
"""

import json
Expand All @@ -38,7 +42,7 @@
from psycopg2.extras import DictCursor

import config
from database import get_db
from database import get_db, like_contains_pattern
from taskqueue import redis_conn
from tasks.mediaserver import registry
from tz_helper import LOCAL_TZ_FMT, UTC_NOW_SQL, to_local_str
Expand Down Expand Up @@ -92,11 +96,10 @@ def browse_page():
)


def _browse_like(value):
# ILIKE contains-pattern with the user's own % / _ / \ escaped, so a search for
# "50%" matches a literal percent instead of acting as a wildcard.
escaped = value.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
return '%' + escaped + '%'
# ILIKE contains-pattern with the user's own % / _ / \ escaped, so a search for
# "50%" matches a literal percent instead of acting as a wildcard. One shared
# escaper (database.like_contains_pattern) serves browse and the ivf fuzzy match.
_browse_like = like_contains_pattern


def _browse_songs_sql(server_id, filt, q):
Expand Down Expand Up @@ -578,14 +581,23 @@ def _collect_charts_metrics(cur):
# hourly cadence and one `charts_updated_at` stamp, kept off the 60s path.
#
# - mood_dominant_counts: per-song dominant-label counts -> Genres chart.
# - other_feature_totals: emotional mood scores summed across songs
# - other_feature_dominant_counts: per-song dominant EMOTIONAL label counts
# (other_features column) -> Moods Coverage pie.
# BOTH are per-song argmax counts, never sums of the raw scores. The
# other_features values are CLAP audio-vs-text cosine similarities pushed
# through (sim+1)/2, and CLAP's modality gap pins every one of them into a
# narrow band well above 0, so summing them converged on N*mean(label) and
# drew all six slices at 1/6 +- <1% - the bigger the library, the MORE
# uniform the pie looked (issue #826). Taking the argmax cancels that shared
# offset, and counting each song exactly once makes the pie a real partition
# instead of six independent scores posing as parts of a whole.
# Both columns are the plain `key:value,key:value` text produced by
# save_track_analysis_and_embedding(), parsed directly (never JSON). A NAMED
# server-side cursor streams the whole table in chunks so the web process
# never buffers all rows at once (an unnamed cursor would).
mood_dominant_counts = {}
other_feature_totals = {}
other_feature_dominant_counts = {}
tied_songs = 0
complete = True
try:
with cur.connection.cursor(name='dash_mood_scan') as scan:
Expand All @@ -600,28 +612,52 @@ def _collect_charts_metrics(cur):
parsed = _parse_keyval(mv)
if not parsed:
continue
dom = max(parsed.items(), key=lambda kv: kv[1])[0]
mood_dominant_counts[dom] = mood_dominant_counts.get(dom, 0) + 1
dom = _dominant_label(parsed)
if dom:
mood_dominant_counts[dom[0]] = mood_dominant_counts.get(dom[0], 0) + 1
else:
tied_songs += 1

if of:
of_parsed = _parse_keyval(of)
for k, s in of_parsed.items():
if k in ('tempo_normalized', 'energy_normalized'):
continue
other_feature_totals[k] = other_feature_totals.get(k, 0.0) + s
emotional_scores = {
k: s for k, s in _parse_keyval(of).items()
if k not in ('tempo_normalized', 'energy_normalized')
}
top = _dominant_label(emotional_scores)
# analyze_track writes ZERO_OTHER_FEATURES up front and only
# refresh_other_features fills it in once CLAP lands, so an
# all-zero row means "not scored yet", not "the least
# danceable song in the library". Counting its argmax would
# hand every un-CLAPped song to whichever label parses first.
if top is None:
tied_songs += 1
elif top[1] > 0:
other_feature_dominant_counts[top[0]] = (
other_feature_dominant_counts.get(top[0], 0) + 1
)
if tied_songs:
logger.info(
"dashboard: %d song(s) had no single strongest label and were left "
"out of the dominance counts rather than handed to whichever label "
"parses first", tied_songs,
)
except Exception as e:
logger.debug(f"dashboard: mood aggregation failed: {e}")
_safe_rollback(cur)
complete = False

# Genre breakdown: dominant-mood counts from mood_vector (genre-like labels).
top_genre = sorted(mood_dominant_counts.items(), key=lambda kv: kv[1], reverse=True)
# Moods Coverage: emotional mood vector (other_features):
# danceable / aggressive / happy / party / relaxed / sad.
emotional = sorted(other_feature_totals.items(), key=lambda kv: kv[1], reverse=True)
# Moods Coverage: how many songs each emotional label (danceable /
# aggressive / happy / party / relaxed / sad) actually WINS. `count`, not the
# old `score`: the key rename is deliberate, so a stale snapshot holding the
# summed scores renders the placeholder instead of the flat six-way pie.
emotional = sorted(
other_feature_dominant_counts.items(), key=lambda kv: kv[1], reverse=True
)
metrics = {
'top_genre': [{'label': k, 'count': int(v)} for k, v in top_genre],
'moods_coverage': [{'label': k, 'score': round(v, 2)} for k, v in emotional],
'moods_coverage': [{'label': k, 'count': int(v)} for k, v in emotional],
}

# Tempo profile: bucket songs into slow/medium/fast/very-fast. Always populate
Expand Down Expand Up @@ -661,6 +697,16 @@ def _collect_charts_metrics(cur):
return metrics


def _dominant_label(scores):
if not scores:
return None
best = max(scores.values())
winners = [label for label, score in scores.items() if score == best]
if len(winners) != 1:
return None
return winners[0], best


def _parse_keyval(s):
"""Parse a ``key:value,key:value`` string (as stored in the ``score``
table's ``mood_vector`` / ``other_features`` columns) into a dict of
Expand Down
Loading
Loading