Skip to content

Commit 1e4b9a3

Browse files
committed
Centralize and remove duplicated functionality
1 parent 456fedb commit 1e4b9a3

19 files changed

Lines changed: 398 additions & 876 deletions

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ The following table details the most important paths in the repository, their pu
2828
| :---- | :---- |
2929
| app.py, app_*.py | The main entry point for the Flask web application. It handles the initialization of the Flask app, database connections, and the registration of API routes and blueprints. |
3030
| tasks/ | **The Core Logic Hub.** This is where the most intensive computations occur. Each API or async task then point to an specific implementation in this directory|
31-
| tasks/mediaserver/ | In this package the generic methods to interact with the mediaservers (`__init__.py`) dispatch to the specific backend (`jellyfin.py`, `navidrome.py`, `emby.py`, `lyrion.py`, `mpd.py`) |
31+
| tasks/mediaserver/ | In this package the generic methods to interact with the mediaservers (`__init__.py`) dispatch to the specific backend (`jellyfin.py`, `navidrome.py`, `emby.py`, `lyrion.py`) |
3232
| tasks/ai/ | All AI / MCP code. `tasks/ai/api.py` is the provider dispatcher (called via `tasks.ai.api.call_with_tools()`), with backends in `tasks/ai/providers/{openai,gemini,mistral,ollama}.py`. Prompts are in `tasks/ai/prompts.py`. MCP tool schemas + dispatcher in `tasks/ai/tools.py`; tool bodies in `tasks/ai/tool_impl.py`. Two-stage planner (intent classifier + plan validation + execution) in `tasks/ai/planner.py`. Vocabulary normalization helpers in `tasks/ai/vocab.py`. |
3333
| config.py | Contains the application's default, non-sensitive configuration parameters. These values serve as fallbacks and can be easily overridden by environment variables, providing a flexible and secure configuration system. |
3434
| Authentication | Configured in `config.py` by `AUTH_ENABLED`, `AUDIOMUSE_USER`, `AUDIOMUSE_PASSWORD`, `API_TOKEN`, and `JWT_SECRET`. Enforcement happens in `app.py` and `app_helepr.py` functionality |

app_clustering.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ def start_clustering_endpoint():
247247
"""
248248
# Local imports to prevent circular dependency at startup
249249
from app_helper import rq_queue_high, get_active_main_task
250-
from app_helper import clean_up_previous_main_tasks, save_task_status, TASK_STATUS_PENDING, TASK_STATUS_FAILURE
250+
from app_helper import clean_up_previous_main_tasks, save_task_status, TASK_STATUS_PENDING
251251

252252
# Check for any existing active main task to prevent parallel batch runs
253253
active_task = get_active_main_task()

app_helper.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@
1010
import psycopg2
1111
from psycopg2.extras import DictCursor
1212
import numpy as np
13-
from flask import g
1413

15-
from database import get_db, close_db
14+
from database import get_db
1615
from taskqueue import (
1716
redis_conn,
1817
rq_queue_high,
@@ -1775,18 +1774,7 @@ def cancel_job_and_children_recursive(job_id, task_type_from_db=None, reason="Ta
17751774
# JWT handling, the Flask routes) lives in ``app_auth``. The re-exports
17761775
# below keep the legacy ``from app_helper import ...`` paths working.
17771776
from app_auth import ( # noqa: E402 (intentional late import to avoid cycles)
1778-
USER_ROLE_USER,
1779-
USER_ROLE_ADMIN,
17801777
check_setup_needed,
1781-
check_auth_needed,
1782-
check_admin_needed,
1783-
is_admin_path,
1784-
list_additional_users,
17851778
count_admin_users,
1786-
get_additional_user_by_id,
1787-
create_additional_user,
1788-
delete_additional_user_safe,
1789-
verify_additional_user,
17901779
upsert_admin_user,
1791-
seed_admin_from_env,
17921780
)

app_map.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@
1010

1111
# Try to reuse the shared projection helpers
1212
try:
13-
from tasks.alchemy_projections import _project_with_umap, _project_to_2d, _project_aligned_add_sub, _project_with_discriminant
13+
from tasks.alchemy_projections import _project_with_umap, _project_to_2d, _project_with_discriminant
1414
except Exception:
1515
# Fallbacks will be used if import fails
1616
_project_with_umap = None
1717
_project_to_2d = None
18-
_project_aligned_add_sub = None
1918
_project_with_discriminant = None
2019

2120
logger = logging.getLogger(__name__)

docs/ALGORITHM.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,13 +266,12 @@ The Song Analysis functionality is configured by the following environment varia
266266

267267
#### **Media Server**
268268

269-
* MEDIASERVER\_TYPE: **(Required)** Specifies the media server to connect to. (e.g., jellyfin, navidrome, emby, lyrion, mpd).
269+
* MEDIASERVER\_TYPE: **(Required)** Specifies the media server to connect to. (e.g., jellyfin, navidrome, emby, lyrion).
270270
* MUSIC\_LIBRARIES: (Optional) A comma-separated list of library names to scan. If empty, all music libraries are scanned.
271271
* JELLYFIN\_URL, JELLYFIN\_USER\_ID, JELLYFIN\_TOKEN: Credentials for Jellyfin (if MEDIASERVER\_TYPE="jellyfin").
272272
* EMBY\_URL, EMBY\_USER\_ID, EMBY\_TOKEN: Credentials for Emby (if MEDIASERVER\_TYPE="emby").
273273
* NAVIDROME\_URL, NAVIDROME\_USER, NAVIDROME\_PASSWORD: Credentials for Navidrome (if MEDIASERVER\_TYPE="navidrome").
274-
* LYRION\_URL: Credentials for Lyrion (if MEDIASERVER\_TYPE="lyrion").
275-
* MPD\_HOST, MPD\_PORT, MPD\_PASSWORD, MPD\_MUSIC\_DIRECTORY: Credentials for MPD (if MEDIASERVER\_TYPE="mpd").
274+
* LYRION\_URL: Credentials for Lyrion (if MEDIASERVER\_TYPE="lyrion").
276275

277276
#### **Task & Performance Tuning**
278277

rq_janitor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
try:
99
# We need the queue objects to get their registries
10-
from app_helper import redis_conn, rq_queue_high, rq_queue_default
10+
from app_helper import rq_queue_high, rq_queue_default
1111
from app_logging import configure_logging
1212
except ImportError as e:
1313
print(f"Error importing from app.py: {e}")

tasks/clap_text_search.py

Lines changed: 32 additions & 189 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,8 @@
33
Provides in-memory caching and fast text-based music search using CLAP embeddings.
44
"""
55

6-
import gc
7-
import json
86
import logging
9-
import re
107
import sys
11-
import tempfile
128
import threading
139
import time
1410

@@ -60,145 +56,35 @@ def get_clap_cache_size() -> int:
6056

6157

6258
def _fetch_clap_metadata(item_ids: list) -> Dict[str, Dict[str, str]]:
63-
"""Fetch metadata for CLAP result item_ids from the database."""
64-
metadata_map: Dict[str, Dict[str, str]] = {}
65-
if not item_ids:
66-
return metadata_map
67-
68-
from app_helper import get_score_data_by_ids
69-
try:
70-
track_details_list = get_score_data_by_ids(item_ids)
71-
for row in track_details_list:
72-
item_id = row['item_id']
73-
metadata_map[item_id] = {
74-
'title': row.get('title', ''),
75-
'author': row.get('author', ''),
76-
'album': row.get('album', ''),
77-
}
78-
except Exception:
79-
pass
80-
81-
return metadata_map
59+
from .commons import fetch_track_metadata_map
60+
return fetch_track_metadata_map(item_ids)
8261

8362

8463
def _load_clap_index_from_db() -> bool:
8564
"""Load a persisted CLAP voyager index from the database."""
8665

8766
from app_helper import get_db
8867
from config import CLAP_EMBEDDING_DIMENSION, VOYAGER_QUERY_EF
68+
from .index_build_helpers import load_voyager_index_from_db
8969

9070
try:
91-
conn = get_db()
92-
with conn.cursor() as cur:
93-
cur.execute("SET LOCAL statement_timeout = 0")
94-
cur.execute(
95-
"SELECT index_data, id_map_json, embedding_dimension FROM clap_index_data WHERE index_name = %s",
96-
('clap_index',)
97-
)
98-
row = cur.fetchone()
99-
100-
index_stream = None
101-
try:
102-
if row:
103-
index_binary_data, id_map_json, db_embedding_dim = row
104-
index_stream = tempfile.TemporaryFile()
105-
index_stream.write(index_binary_data)
106-
index_stream.seek(0)
107-
else:
108-
seg_pattern = re.compile(r'^clap_index_(\d+)_(\d+)$')
109-
parts = []
110-
total_expected = None
111-
with conn.cursor(name='clap_index_segments') as seg_cur:
112-
seg_cur.itersize = 50
113-
seg_cur.execute(
114-
"SELECT index_name, index_data, id_map_json, embedding_dimension FROM clap_index_data WHERE index_name LIKE %s ESCAPE '\\'",
115-
(r'clap_index\_%\_%',)
116-
)
117-
for name, part_data, part_id_map_json, part_dim in seg_cur:
118-
m = seg_pattern.match(name)
119-
if not m:
120-
continue
121-
part_no = int(m.group(1))
122-
total = int(m.group(2))
123-
if total_expected is None:
124-
total_expected = total
125-
elif total_expected != total:
126-
logger.error(f"Segment total mismatch for CLAP index parts ({total_expected} vs {total}).")
127-
return False
128-
parts.append((part_no, part_data, part_id_map_json, part_dim))
129-
130-
if total_expected is None or len(parts) != total_expected:
131-
logger.error(f"Incomplete CLAP index segments: expected {total_expected}, found {len(parts)}.")
132-
return False
133-
134-
parts.sort(key=lambda p: p[0])
135-
from .index_build_helpers import reassemble_segmented_id_map
136-
id_map_json_candidate = reassemble_segmented_id_map((p[0], p[2]) for p in parts)
137-
for _, _, _, part_dim in parts:
138-
if part_dim != CLAP_EMBEDDING_DIMENSION:
139-
logger.error(f"CLAP index embedding_dimension mismatch in segmented parts: expected {CLAP_EMBEDDING_DIMENSION}, got {part_dim}.")
140-
return False
141-
142-
if not id_map_json_candidate:
143-
logger.error("No id_map_json found in segmented CLAP index rows.")
144-
return False
145-
146-
db_embedding_dim = parts[0][3]
147-
index_stream = tempfile.TemporaryFile()
148-
for _, part_data, _, _ in parts:
149-
index_stream.write(part_data)
150-
index_stream.seek(0)
151-
id_map_json = id_map_json_candidate
152-
153-
if index_stream is None:
154-
logger.error("CLAP index binary data was empty.")
155-
return False
156-
157-
if db_embedding_dim != CLAP_EMBEDDING_DIMENSION:
158-
logger.error(f"CLAP index dimension mismatch: db={db_embedding_dim} expected={CLAP_EMBEDDING_DIMENSION}")
159-
index_stream.close()
160-
return False
161-
162-
try:
163-
try:
164-
import voyager # type: ignore
165-
except ImportError:
166-
logger.warning("Voyager library is unavailable; cannot load persisted CLAP index.")
167-
return False
168-
169-
loaded_index = voyager.Index.load(index_stream)
170-
loaded_index.ef = VOYAGER_QUERY_EF
171-
finally:
172-
if index_stream is not None:
173-
try:
174-
index_stream.close()
175-
except Exception as close_error:
176-
logger.warning("Failed to close CLAP index stream: %s", close_error, exc_info=True)
177-
178-
except Exception:
179-
if index_stream is not None:
180-
try:
181-
index_stream.close()
182-
except Exception:
183-
pass
184-
raise
185-
186-
id_map = {int(k): v for k, v in json.loads(id_map_json).items()}
187-
reverse_id_map = {v: k for k, v in id_map.items()}
188-
189-
if not id_map:
190-
logger.error("CLAP index id_map is empty.")
191-
return False
192-
193-
_CLAP_CACHE['loaded'] = True
194-
195-
_CLAP_INDEX_CACHE['index'] = loaded_index
196-
_CLAP_INDEX_CACHE['id_map'] = id_map
197-
_CLAP_INDEX_CACHE['reverse_id_map'] = reverse_id_map
198-
_CLAP_INDEX_CACHE['loaded'] = True
199-
200-
logger.info(f"CLAP index loaded from database with {len(id_map)} items.")
201-
return True
71+
loaded = load_voyager_index_from_db(
72+
get_db(), 'clap_index_data', 'clap_index',
73+
CLAP_EMBEDDING_DIMENSION, VOYAGER_QUERY_EF, label='CLAP',
74+
)
75+
if loaded is None:
76+
return False
77+
loaded_index, id_map, reverse_id_map = loaded
78+
79+
_CLAP_CACHE['loaded'] = True
80+
81+
_CLAP_INDEX_CACHE['index'] = loaded_index
82+
_CLAP_INDEX_CACHE['id_map'] = id_map
83+
_CLAP_INDEX_CACHE['reverse_id_map'] = reverse_id_map
84+
_CLAP_INDEX_CACHE['loaded'] = True
85+
86+
logger.info(f"CLAP index loaded from database with {len(id_map)} items.")
87+
return True
20288
except Exception as e:
20389
logger.error(f"Failed to load CLAP index from DB: {e}", exc_info=True)
20490
return False
@@ -208,63 +94,21 @@ def build_and_store_clap_index(db_conn=None):
20894
"""Build a CLAP text search voyager index from stored CLAP embeddings and save it to the DB."""
20995
from app_helper import get_db
21096
from config import CLAP_EMBEDDING_DIMENSION, VOYAGER_METRIC
211-
from .index_build_helpers import (
212-
iter_embedding_batches,
213-
build_voyager_index_bytes_streaming,
214-
store_voyager_index_segmented,
215-
build_id_map,
216-
EmptyIndexError,
217-
)
218-
219-
try:
220-
import voyager # type: ignore # noqa: F401
221-
except ImportError:
222-
logger.warning("Voyager library is unavailable; cannot build CLAP index.")
223-
return False
97+
from .index_build_helpers import build_and_store_index_streaming
22498

22599
if db_conn is None:
226100
db_conn = get_db()
227101

228-
try:
229-
logger.info("Building CLAP voyager index (streaming)...")
230-
batches = iter_embedding_batches(
231-
table="clap_embedding",
232-
column="embedding",
233-
dim=CLAP_EMBEDDING_DIMENSION,
234-
)
235-
try:
236-
index_bytes, item_ids = build_voyager_index_bytes_streaming(
237-
batches, CLAP_EMBEDDING_DIMENSION, metric=VOYAGER_METRIC,
238-
)
239-
except EmptyIndexError as ve:
240-
logger.warning(f"No valid CLAP embedding vectors found for CLAP index build: {ve}")
241-
return False
242-
gc.collect()
243-
244-
if not index_bytes:
245-
logger.error("Generated CLAP index binary is empty. Aborting storage.")
246-
return False
247-
248-
id_map = build_id_map(item_ids)
249-
store_voyager_index_segmented(
250-
db_conn,
251-
target_table="clap_index_data",
252-
index_name="clap_index",
253-
index_bytes=index_bytes,
254-
id_map=id_map,
255-
embedding_dimension=CLAP_EMBEDDING_DIMENSION,
256-
)
257-
258-
db_conn.commit()
259-
logger.info("CLAP text search index build successful.")
260-
return True
261-
except Exception as e:
262-
logger.error(f"Failed to build and store CLAP index: {e}", exc_info=True)
263-
try:
264-
db_conn.rollback()
265-
except Exception:
266-
pass
267-
return False
102+
return build_and_store_index_streaming(
103+
db_conn,
104+
source_table="clap_embedding",
105+
source_column="embedding",
106+
dim=CLAP_EMBEDDING_DIMENSION,
107+
target_table="clap_index_data",
108+
index_name="clap_index",
109+
metric=VOYAGER_METRIC,
110+
label="CLAP",
111+
)
268112

269113

270114
def _unload_timer_worker():
@@ -360,9 +204,8 @@ def load_clap_cache_from_db():
360204
Returns True if successful, False otherwise.
361205
"""
362206

363-
from app_helper import get_db
364207
from config import CLAP_ENABLED
365-
208+
366209
if not CLAP_ENABLED:
367210
logger.info("CLAP is disabled, skipping cache load.")
368211
return False

tasks/clustering.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,7 @@ def _monitor_and_process_batches(state_dict, parent_task_id, initial_check=False
668668
CRITICAL: This prevents the main task from hanging at 4980/5000 runs
669669
by implementing timeouts and forced progress tracking.
670670
"""
671-
from app_helper import redis_conn, get_child_tasks_from_db, get_task_info_from_db, TASK_STATUS_SUCCESS, TASK_STATUS_FAILURE, TASK_STATUS_REVOKED, TASK_STATUS_STARTED, TASK_STATUS_PROGRESS
671+
from app_helper import redis_conn, get_child_tasks_from_db, TASK_STATUS_SUCCESS, TASK_STATUS_FAILURE, TASK_STATUS_REVOKED, TASK_STATUS_STARTED
672672

673673
current_time = time.time()
674674
timeout_seconds = CLUSTERING_BATCH_TIMEOUT_MINUTES * 60

0 commit comments

Comments
 (0)