Skip to content
Merged
32 changes: 31 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,35 @@ def health_check():
)


@app.route('/api/version', methods=['GET'])
def version_api():
"""
Return the running application version to authenticated clients.
---
tags:
- Config
summary: Application version, separate from model metadata.
responses:
200:
description: Actual runtime APP_VERSION, preserving release suffixes.
content:
application/json:
schema:
type: object
required: [app_version]
properties:
app_version:
type: string
401:
description: Authentication required under the existing policy.
403:
description: Initial setup required under the existing policy.
"""
response = jsonify({'app_version': config.APP_VERSION})
response.headers['Cache-Control'] = 'no-store'
return response


# --- Swagger Setup ---
app.config['SWAGGER'] = {'title': 'AudioMuse-AI API', 'uiversion': 3, 'openapi': '3.0.0'}
swagger = Swagger(app)
Expand Down Expand Up @@ -1160,14 +1189,15 @@ def _register_blueprints(flask_app):
from app_music_servers import music_servers_bp
from app_hyperbolic import hyperbolic_bp
from app_recording_search import recording_search_bp
from app_models import models_bp

flask_app.register_blueprint(chat_bp, url_prefix='/chat')
flask_app.register_blueprint(external_bp, url_prefix='/external')
for blueprint in (
clustering_bp, analysis_bp, cron_bp, ivf_bp, sonic_fingerprint_bp, path_bp,
alchemy_bp, map_bp, artist_similarity_bp, clap_search_bp, lyrics_search_bp,
sem_grove_bp, backup_bp, migration_bp, dashboard_bp, users_bp, sync_bp,
music_servers_bp, hyperbolic_bp, recording_search_bp,
music_servers_bp, hyperbolic_bp, recording_search_bp, models_bp,
):
flask_app.register_blueprint(blueprint)

Expand Down
131 changes: 131 additions & 0 deletions app_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# AudioMuse-AI - https://github.com/NeptuneHub/AudioMuse-AI
# Copyright (C) 2025 NeptuneHub
# SPDX-License-Identifier: AGPL-3.0-only

"""Authenticated model coverage for non-admin clients.

Main Features:
* Returns enablement and global counts/percentages for all setup-wizard models.
* Includes local coverage of the selected server, the default when none is passed.
* Keeps version reporting separate and avoids model warmup side effects.
"""

import logging

from flask import Blueprint, jsonify, request

import app_server_context
from error.error_dictionary import ERR_INVALID_REQUEST, ERR_SEARCH_FAILED
from error.responses import json_exception
from tasks.model_coverage import get_model_coverage

logger = logging.getLogger(__name__)
models_bp = Blueprint('models_bp', __name__)


@models_bp.after_app_request
def model_metadata_no_store(response):
if request.endpoint in ('models_bp.models_api', 'version_api'):
response.headers['Cache-Control'] = 'no-store'
return response


@models_bp.route('/api/models', methods=['GET'])
def models_api():
"""
Model enablement and catalogue coverage for any authenticated user.
---
tags:
- Models
summary: Global and server-local model coverage.
description: |
Returns musicnn (MusiCNN), clap (DCLAP), lyrics and neural-fingerprint.
Each model has enabled, global coverage and the local coverage of one
server: the one selected with server or server_id, or the default server
when none is supplied. The resolved id is echoed as server_id. Both are
absent only when no music server is configured. Counts describe indexed
tracks; percentage uses the whole relevant catalogue, including for Lyrics.
Unknown counts/percentages are null. Empty catalogues have zero percent.
Does not load models or search indexes.
parameters:
- name: server_id
in: query
required: false
schema:
type: string
description: Server ID or name. Omitted or empty selects the default server.
- name: server
in: query
required: false
schema:
type: string
description: Alias for server_id; takes precedence when both are supplied.
responses:
200:
description: All four models, including disabled models.
content:
application/json:
schema:
type: object
required: [models]
properties:
server_id:
type: string
models:
type: object
required: [musicnn, clap, lyrics, neural-fingerprint]
additionalProperties:
type: object
required: [enabled, global]
properties:
enabled:
type: boolean
global:
type: object
required: [count, total, percentage]
properties:
count:
type: integer
minimum: 0
nullable: true
total:
type: integer
minimum: 0
percentage:
type: number
minimum: 0
maximum: 100
nullable: true
local:
type: object
required: [count, total, percentage]
properties:
count:
type: integer
minimum: 0
nullable: true
total:
type: integer
minimum: 0
percentage:
type: number
minimum: 0
maximum: 100
nullable: true
400:
description: Unknown server selection.
401:
description: Authentication required under the existing policy.
403:
description: Initial setup required under the existing policy.
500:
description: Coverage could not be determined; no internal detail is exposed.
"""
try:
try:
server_id, is_default = app_server_context.selected_server_scope()
except ValueError as exc:
return json_exception(exc, ERR_INVALID_REQUEST)
return jsonify(get_model_coverage(server_id, include_legacy=is_default))
except Exception as exc:
return json_exception(exc, ERR_SEARCH_FAILED, 'Could not determine model coverage.')
25 changes: 6 additions & 19 deletions app_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,34 +558,21 @@ def model_coverage_level(indexed, eligible):
return 1 + sum(1 for edge in MODEL_COVERAGE_BANDS if ratio >= edge)


def _count_rows(cur, sql):
cur.execute(sql)
row = cur.fetchone()
return int(row[0]) if row and row[0] is not None else 0


def model_coverage_levels():
from database import get_db
from tasks.paged_ivf import paged_ivf_item_count
from tasks.neural_fingerprint_index import indexed_track_count
from tasks.model_coverage import model_coverage_pairs

try:
db = get_db()
except Exception:
app.logger.exception('Model coverage could not open the database for the setup wizard')
return {}
try:
with db.cursor() as cur:
total_songs = _count_rows(cur, "SELECT COUNT(*) FROM score")
songs_with_lyrics = _count_rows(
cur, "SELECT COUNT(*) FROM lyrics_embedding WHERE embedding IS NOT NULL"
)
pairs = {
'musicnn': (paged_ivf_item_count(db, config.INDEX_NAME), total_songs),
'clap': (paged_ivf_item_count(db, 'clap_index'), total_songs),
'lyrics': (paged_ivf_item_count(db, 'lyrics_index'), songs_with_lyrics),
'neural-fingerprint': (indexed_track_count() or 0, total_songs),
}
pairs = model_coverage_pairs(db)
# Preserve the wizard's unloaded-neural empty band; the client API
# retains None so unknown coverage is not reported as known zero.
count, total = pairs['neural-fingerprint']
pairs['neural-fingerprint'] = (count or 0, total)
except Exception:
app.logger.exception('Model coverage could not be read for the setup wizard')
try:
Expand Down
89 changes: 89 additions & 0 deletions tasks/model_coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# AudioMuse-AI - https://github.com/NeptuneHub/AudioMuse-AI
# Copyright (C) 2025 NeptuneHub
# SPDX-License-Identifier: AGPL-3.0-only

"""Shared model coverage for the setup wizard and authenticated clients.

Main Features:
* Reuses directory-header counts and the resident neural fingerprint pack.
* Keeps wizard eligibility denominators while API percentages use the catalogue.
* Adds the selected server's coverage without loading encoders or search indexes.
"""

import config


def _count_rows(cur, sql, params=None):
cur.execute(sql, params)
row = cur.fetchone()
return int(row[0]) if row and row[0] is not None else 0


def model_coverage_pairs(db):
"""Index counts and wizard-eligible totals; unknown index counts stay None."""
from tasks.paged_ivf import paged_ivf_item_count
from tasks.neural_fingerprint_index import indexed_track_count

with db.cursor() as cur:
total = _count_rows(cur, 'SELECT COUNT(*) FROM score')
lyrics_total = _count_rows(cur, 'SELECT COUNT(*) FROM lyrics_embedding WHERE embedding IS NOT NULL')
return {
'musicnn': (paged_ivf_item_count(db, config.INDEX_NAME), total),
'clap': (paged_ivf_item_count(db, 'clap_index'), total),
'lyrics': (paged_ivf_item_count(db, 'lyrics_index'), lyrics_total),
'neural-fingerprint': (indexed_track_count(), total),
}


def _coverage(count, total):
if count is None:
percentage = None
elif total:
percentage = round(min(100.0, max(0.0, count * 100.0 / total)), 2)
else:
percentage = 0.0
return {
'count': count,
'total': total,
'percentage': percentage,
}


def get_model_coverage(server_id=None, include_legacy=False):
"""All four models; local coverage is present whenever a server is resolved."""
from database import get_db
from tasks.mediaserver import registry
from tasks.neural_fingerprint_index import get_scoped_status
from tasks.paged_ivf import paged_ivf_scoped_item_count

db = get_db()
try:
pairs = model_coverage_pairs(db)
total = pairs['musicnn'][1]
enabled = {
'musicnn': True, # The setup wizard's always-on model has no flag.
'clap': bool(config.CLAP_ENABLED),
'lyrics': bool(config.LYRICS_ENABLED),
'neural-fingerprint': bool(config.NEURAL_FINGERPRINT_ENABLED),
}
models = {
model: {'enabled': enabled[model], 'global': _coverage(pair[0], total)}
for model, pair in pairs.items()
}
result = {'models': models}
if server_id is not None:
with db.cursor() as cur:
local_total = _count_rows(
cur, 'SELECT COUNT(*) FROM score s WHERE ' + registry.availability_sql('s'),
(server_id, include_legacy),
)
for model, name in (('musicnn', config.INDEX_NAME), ('clap', 'clap_index'), ('lyrics', 'lyrics_index')):
count = paged_ivf_scoped_item_count(db, name, server_id)
models[model]['local'] = _coverage(count, local_total)
count = get_scoped_status(server_id)['indexed_tracks']
models['neural-fingerprint']['local'] = _coverage(count, local_total)
result['server_id'] = server_id
return result
except Exception:
db.rollback()
raise
9 changes: 6 additions & 3 deletions tasks/neural_fingerprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,16 @@ def is_enabled():
return bool(config.NEURAL_FINGERPRINT_ENABLED)


def is_available():
if not is_enabled():
return False
def model_files_available():
"""Check required files without loading the encoder or consulting its enable flag."""
paths = (config.NEURAL_FINGERPRINT_MODEL_PATH, config.NEURAL_FINGERPRINT_CODEBOOK_PATH)
return all(bool(path) and os.path.isfile(path) for path in paths)


def is_available():
return is_enabled() and model_files_available()


def _filterbank():
if _STATE['filterbank'] is None:
_STATE['filterbank'] = librosa.filters.mel(
Expand Down
Loading
Loading